From 850b023758458593b2d7edc09904b5eb4551d622 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 25 Jan 2014 14:47:23 +0100 Subject: [PATCH 001/495] audio: Add debug logging of stream switching --- mopidy/audio/actor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index ca023125..068981fc 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -115,6 +115,8 @@ class Audio(pykka.ThreadingActor): self._disconnect(source, 'enough-data') self._disconnect(source, 'seek-data') + logger.debug('Ready to switch to new stream') + def _on_new_source(self, element, pad): uri = element.get_property('uri') if not uri or not uri.startswith('appsrc://'): @@ -292,6 +294,9 @@ class Audio(pykka.ThreadingActor): logger.warning( '%s Debug message: %s', str(error).decode('utf-8'), debug.decode('utf-8') or 'None') + elif message.type == gst.MESSAGE_ELEMENT: + if message.structure.has_name('playbin2-stream-changed'): + logger.debug('Playback of new stream started') def _on_playbin_state_changed(self, old_state, new_state, pending_state): if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: From 0cfd69432e8ba9781e1ff1aa40c22ef7d922c4e1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 25 Jan 2014 15:13:10 +0100 Subject: [PATCH 002/495] audio: Queue audio sink data to give us some headroom for about to finish events --- mopidy/audio/actor.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 068981fc..0f510a73 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -160,15 +160,35 @@ class Audio(pykka.ThreadingActor): def _setup_output(self): output_desc = self._config['audio']['output'] try: - output = gst.parse_bin_from_description( + user_output = gst.parse_bin_from_description( output_desc, ghost_unconnected_pads=True) - self._playbin.set_property('audio-sink', output) - logger.info('Audio output set to "%s"', output_desc) except gobject.GError as ex: logger.error( 'Failed to create audio output "%s": %s', output_desc, ex) process.exit_process() + output = gst.Bin('output') + + # Queue element to buy use time between the about to finish event and + # the actual switch, i.e. about to switch can block for longer thanks + # to this queue. + # TODO: make the min-max values a setting? + queue = gst.element_factory_make('queue') + queue.set_property('max-size-buffers', 0) + queue.set_property('max-size-bytes', 0) + queue.set_property('max-size-time', 5 * gst.SECOND) + queue.set_property('min-threshold-time', 3 * gst.SECOND) + + output.add(user_output) + output.add(queue) + + queue.link(user_output) + ghost_pad = gst.GhostPad('sink', queue.get_pad('sink')) + output.add_pad(ghost_pad) + + logger.info('Audio output set to "%s"', output_desc) + self._playbin.set_property('audio-sink', output) + def _setup_visualizer(self): visualizer_element = self._config['audio']['visualizer'] if not visualizer_element: From 100b32af98e9a03555b2ac9864f2a22d65141ac7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 25 Jan 2014 16:29:09 +0100 Subject: [PATCH 003/495] core: Add a bunch of TODOs --- mopidy/audio/actor.py | 2 ++ mopidy/core/playback.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 0f510a73..f2003ca0 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -42,6 +42,7 @@ PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7) PLAYBIN_VIS_FLAGS = PLAYBIN_FLAGS | (1 << 3) +# TODO: split out mixer as these are to intertwined right now class Audio(pykka.ThreadingActor): """ Audio output through `GStreamer `_. @@ -412,6 +413,7 @@ class Audio(pykka.ThreadingActor): We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. """ + # TODO: replace this with emit_data(None)? self._playbin.get_property('source').emit('end-of-stream') def get_position(self): diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index b2acb35a..5f296c89 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -11,6 +11,7 @@ from . import listener logger = logging.getLogger(__name__) +# TODO: split mixing out from playback? class PlaybackController(object): pykka_traversable = True @@ -24,6 +25,7 @@ class PlaybackController(object): self._mute = False def _get_backend(self): + # TODO: take in track instead if self.current_tl_track is None: return None uri = self.current_tl_track.track.uri @@ -129,6 +131,7 @@ class PlaybackController(object): ### Methods + # TODO: remove this. def change_track(self, tl_track, on_error_step=1): """ Change to the given track, keeping the current playback state. @@ -147,6 +150,7 @@ class PlaybackController(object): elif old_state == PlaybackState.PAUSED: self.pause() + # TODO: this is not really end of track, this is on_need_next_track def on_end_of_track(self): """ Tell the playback controller that end of track is reached. @@ -184,6 +188,9 @@ class PlaybackController(object): """ tl_track = self.core.tracklist.next_track(self.current_tl_track) if tl_track: + # TODO: switch to: + # backend.play(track) + # wait for state change? self.change_track(tl_track) else: self.stop(clear_current_track=True) @@ -192,6 +199,9 @@ class PlaybackController(object): """Pause playback.""" backend = self._get_backend() if not backend or backend.playback.pause().get(): + # TODO: switch to: + # backend.track(pause) + # wait for state change? self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() @@ -226,6 +236,10 @@ class PlaybackController(object): assert tl_track in self.core.tracklist.tl_tracks + # TODO: switch to: + # backend.play(track) + # wait for state change? + if self.state == PlaybackState.PLAYING: self.stop() @@ -236,6 +250,7 @@ class PlaybackController(object): if success: self.core.tracklist.mark_playing(tl_track) + # TODO: replace with stream-changed self._trigger_track_playback_started() else: self.core.tracklist.mark_unplayable(tl_track) @@ -253,6 +268,9 @@ class PlaybackController(object): will continue. If it was paused, it will still be paused, etc. """ tl_track = self.current_tl_track + # TODO: switch to: + # self.play(....) + # wait for state change? self.change_track( self.core.tracklist.previous_track(tl_track), on_error_step=-1) @@ -263,7 +281,11 @@ class PlaybackController(object): backend = self._get_backend() if backend and backend.playback.resume().get(): self.state = PlaybackState.PLAYING + # TODO: trigger via gst messages self._trigger_track_playback_resumed() + # TODO: switch to: + # backend.resume() + # wait for state change? def seek(self, time_position): """ From d962277bc9fa937d6965964e89fd45987a5f25a0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Jan 2014 23:27:30 +0100 Subject: [PATCH 004/495] audio: Add stream_changed event plus tests --- mopidy/audio/actor.py | 25 +++++++++++++++++++++++-- mopidy/audio/listener.py | 10 ++++++++++ tests/audio/test_actor.py | 13 +++++++++++++ tests/audio/test_listener.py | 3 +++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index f2003ca0..44794201 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -317,7 +317,12 @@ class Audio(pykka.ThreadingActor): str(error).decode('utf-8'), debug.decode('utf-8') or 'None') elif message.type == gst.MESSAGE_ELEMENT: if message.structure.has_name('playbin2-stream-changed'): - logger.debug('Playback of new stream started') + self._on_stream_changed(message) + + def _on_stream_changed(self, message): + uri = message.structure['uri'] + logger.debug('Triggering event: stream_changed(uri=%s)', uri) + AudioListener.send('stream_changed', uri=uri) def _on_playbin_state_changed(self, old_state, new_state, pending_state): if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: @@ -349,7 +354,7 @@ class Audio(pykka.ThreadingActor): 'state_changed', old_state=old_state, new_state=new_state) def _on_end_of_stream(self): - logger.debug('Triggering reached_end_of_stream event') + logger.debug('Triggering event: reached_end_of_stream event') AudioListener.send('reached_end_of_stream') def set_uri(self, uri): @@ -476,6 +481,22 @@ class Audio(pykka.ThreadingActor): """ return self._set_state(gst.STATE_NULL) + def wait_for_state_change(self): + """Block until any pending state changes are complete. + + Should only be used by test. + """ + self._playbin.get_state() + + def process_messages(self): + """Manually process messages from bus. + + Should only be used by test. + """ + bus = self._playbin.get_bus() + while bus.have_pending(): + self._on_message(bus, bus.pop()) + def _set_state(self, state): """ Internal method for setting the raw GStreamer state. diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 537a81dd..d2690031 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -27,6 +27,16 @@ class AudioListener(listener.Listener): """ pass + def stream_changed(self, uri): + """ + Called whenever the end of the audio stream changes. + + *MAY* be implemented by actor. + + :param string uri: URI the stream has started playing. + """ + pass + def state_changed(self, old_state, new_state): """ Called after the playback state have changed. diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 3f7e56ce..b0ee4429 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import mock import unittest import pygst @@ -114,6 +115,18 @@ class AudioTest(unittest.TestCase): def test_invalid_output_raises_error(self): pass # TODO + @mock.patch.object(audio.AudioListener, 'send') + def test_stream_changed_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.start_playback() + + self.audio.wait_for_state_change() + self.audio.process_messages().get() + + call = mock.call('stream_changed', uri=self.song_uri) + self.assertIn(call, send_mock.call_args_list) + class AudioStateTest(unittest.TestCase): def setUp(self): diff --git a/tests/audio/test_listener.py b/tests/audio/test_listener.py index 08286cf9..c579fd55 100644 --- a/tests/audio/test_listener.py +++ b/tests/audio/test_listener.py @@ -24,3 +24,6 @@ class AudioListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_state_changed(self): self.listener.state_changed(None, None) + + def test_listener_has_default_impl_for_stream_changed(self): + self.listener.stream_changed(None) From a4315251ca8582a725ff1e86c1875d4155e6f01e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Jan 2014 23:30:12 +0100 Subject: [PATCH 005/495] audio: Slight test refactor to not hide what is going on --- tests/audio/test_actor.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index b0ee4429..598ff3d1 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -35,25 +35,25 @@ class AudioTest(unittest.TestCase): def tearDown(self): pykka.ActorRegistry.stop_all() - def prepare_uri(self, uri): - self.audio.prepare_change() - self.audio.set_uri(uri) - def test_start_playback_existing_file(self): - self.prepare_uri(self.song_uri) + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) self.assertTrue(self.audio.start_playback().get()) def test_start_playback_non_existing_file(self): - self.prepare_uri(self.song_uri + 'bogus') + self.audio.prepare_change() + self.audio.set_uri(self.song_uri + 'bogus') self.assertFalse(self.audio.start_playback().get()) def test_pause_playback_while_playing(self): - self.prepare_uri(self.song_uri) + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) self.audio.start_playback() self.assertTrue(self.audio.pause_playback().get()) def test_stop_playback_while_playing(self): - self.prepare_uri(self.song_uri) + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) self.audio.start_playback() self.assertTrue(self.audio.stop_playback().get()) From 6490d5bd2dbd3f949f0c84c30846056e0814e99b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 27 Jan 2014 22:34:42 +0100 Subject: [PATCH 006/495] audio: Add more tests for audio events --- mopidy/audio/actor.py | 17 +++-- tests/audio/test_actor.py | 147 +++++++++++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 9 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 44794201..cbec0b4c 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -352,6 +352,8 @@ class Audio(pykka.ThreadingActor): old_state, new_state) AudioListener.send( 'state_changed', old_state=old_state, new_state=new_state) + if new_state == PlaybackState.STOPPED: + AudioListener.send('stream_changed', uri=None) def _on_end_of_stream(self): logger.debug('Triggering event: reached_end_of_stream event') @@ -484,18 +486,21 @@ class Audio(pykka.ThreadingActor): def wait_for_state_change(self): """Block until any pending state changes are complete. - Should only be used by test. + Should only be used by tests. """ self._playbin.get_state() - def process_messages(self): - """Manually process messages from bus. + def enable_sync_handler(self): + """Enable manual processing of messages from bus. - Should only be used by test. + Should only be used by tests. """ + def sync_handler(bus, message): + self._on_message(bus, message) + return gst.BUS_DROP + bus = self._playbin.get_bus() - while bus.have_pending(): - self._on_message(bus, bus.pop()) + bus.set_sync_handler(sync_handler) def _set_state(self, state): """ diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 598ff3d1..8c956ab3 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import mock +import threading import unittest import pygst @@ -13,6 +14,7 @@ gobject.threads_init() import pykka from mopidy import audio +from mopidy.audio.constants import PlaybackState from mopidy.utils.path import path_to_uri from tests import path_to_data_dir @@ -115,18 +117,157 @@ class AudioTest(unittest.TestCase): def test_invalid_output_raises_error(self): pass # TODO - @mock.patch.object(audio.AudioListener, 'send') - def test_stream_changed_event(self, send_mock): + +@mock.patch.object(audio.AudioListener, 'send') +class AudioEventTest(unittest.TestCase): + def setUp(self): + config = { + 'audio': { + 'mixer': 'fakemixer track_max_volume=65536', + 'mixer_track': None, + 'mixer_volume': None, + 'output': 'fakesink', + 'visualizer': None, + } + } + self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) + self.audio = audio.Audio.start(config=config).proxy() + self.audio.enable_sync_handler().get() + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + # TODO: test wihtout uri set, with bad uri and gapless... + + def test_state_change_stopped_to_playing_event(self, send_mock): self.audio.prepare_change() self.audio.set_uri(self.song_uri) self.audio.start_playback() + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.STOPPED, + new_state=PlaybackState.PLAYING) + self.assertIn(call, send_mock.call_args_list) + + def test_state_change_stopped_to_paused_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.pause_playback() + + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.STOPPED, + new_state=PlaybackState.PAUSED) + self.assertIn(call, send_mock.call_args_list) + + def test_state_change_paused_to_playing_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.pause_playback() self.audio.wait_for_state_change() - self.audio.process_messages().get() + self.audio.start_playback() + + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.PAUSED, + new_state=PlaybackState.PLAYING) + self.assertIn(call, send_mock.call_args_list) + + def test_state_change_paused_to_stopped_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.pause_playback() + self.audio.wait_for_state_change() + self.audio.stop_playback() + + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.PAUSED, + new_state=PlaybackState.STOPPED) + self.assertIn(call, send_mock.call_args_list) + + def test_state_change_playing_to_paused_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.start_playback() + self.audio.wait_for_state_change() + self.audio.pause_playback() + + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.PLAYING, + new_state=PlaybackState.PAUSED) + self.assertIn(call, send_mock.call_args_list) + + def test_state_change_playing_to_stopped_event(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.start_playback() + self.audio.wait_for_state_change() + self.audio.stop_playback() + + self.audio.wait_for_state_change().get() + call = mock.call('state_changed', old_state=PlaybackState.PLAYING, + new_state=PlaybackState.STOPPED) + self.assertIn(call, send_mock.call_args_list) + + def test_stream_changed_event_on_playing(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.start_playback() + + # Since we are going from stopped to playing, the state change is + # enough to ensure the stream changed. + self.audio.wait_for_state_change().get() call = mock.call('stream_changed', uri=self.song_uri) self.assertIn(call, send_mock.call_args_list) + def test_stream_changed_event_on_paused_to_stopped(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.pause_playback() + self.audio.wait_for_state_change() + self.audio.stop_playback() + + self.audio.wait_for_state_change().get() + + call = mock.call('stream_changed', uri=None) + self.assertIn(call, send_mock.call_args_list) + + # Unlike the other events, having the state changed done is not + # enough to ensure our event is called. So we setup a threading + # event that we can wait for with a timeout while the track playback + # completes. + + def test_stream_changed_event_on_paused(self, send_mock): + event = threading.Event() + + def send(name, **kwargs): + if name == 'stream_changed': + event.set() + send_mock.side_effect = send + + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.pause_playback().get() + self.audio.wait_for_state_change().get() + + if not event.wait(timeout=5.0): + self.fail('Stream changed not reached within deadline') + + def test_reached_end_of_stream_event(self, send_mock): + event = threading.Event() + + def send(name, **kwargs): + if name == 'reached_end_of_stream': + event.set() + send_mock.side_effect = send + + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.start_playback() + self.audio.wait_for_state_change().get() + + if not event.wait(timeout=5.0): + self.fail('End of stream not reached within deadline') + class AudioStateTest(unittest.TestCase): def setUp(self): From 99d581f7fc12e1db9633d97d849ccb39d940d44b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 27 Jan 2014 23:04:15 +0100 Subject: [PATCH 007/495] audio: Add position_changed event to know when seeks happen --- mopidy/audio/actor.py | 9 ++++++ mopidy/audio/listener.py | 10 +++++++ tests/audio/test_actor.py | 56 ++++++++++++++++++++++++++++++++++++ tests/audio/test_listener.py | 3 ++ 4 files changed, 78 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index cbec0b4c..5fc84411 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -180,6 +180,8 @@ class Audio(pykka.ThreadingActor): queue.set_property('max-size-time', 5 * gst.SECOND) queue.set_property('min-threshold-time', 3 * gst.SECOND) + queue.get_pad('src').add_event_probe(self._on_pad_event) + output.add(user_output) output.add(queue) @@ -294,6 +296,13 @@ class Audio(pykka.ThreadingActor): self._disconnect(bus, 'message') bus.remove_signal_watch() + def _on_pad_event(self, pad, event): + if event.type == gst.EVENT_NEWSEGMENT: + # update, rate, format, start, stop, position + position = event.parse_new_segment()[5] // gst.MSECOND + AudioListener.send('position_changed', position=position) + return True + def _on_message(self, bus, message): if (message.type == gst.MESSAGE_STATE_CHANGED and message.src == self._playbin): diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index d2690031..5b33ffe6 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -37,6 +37,16 @@ class AudioListener(listener.Listener): """ pass + def position_changed(self, position_changed): + """ + Called whenever the position of the stream changes. + + *MAY* be implemented by actor. + + :param int position: Position in milliseconds. + """ + pass + def state_changed(self, old_state, new_state): """ Called after the playback state have changed. diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 8c956ab3..ad88b92a 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -231,6 +231,62 @@ class AudioEventTest(unittest.TestCase): call = mock.call('stream_changed', uri=None) self.assertIn(call, send_mock.call_args_list) + def test_position_changed_on_pause(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.pause_playback() + self.audio.wait_for_state_change() + + self.audio.wait_for_state_change().get() + + call = mock.call('position_changed', position=0) + self.assertIn(call, send_mock.call_args_list) + + def test_position_changed_on_play(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.start_playback() + self.audio.wait_for_state_change() + + self.audio.wait_for_state_change().get() + + call = mock.call('position_changed', position=0) + self.assertIn(call, send_mock.call_args_list) + + def test_position_changed_on_seek(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.set_position(2000) + + self.audio.wait_for_state_change().get() + + call = mock.call('position_changed', position=0) + self.assertNotIn(call, send_mock.call_args_list) + + def test_position_changed_on_seek_after_play(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.start_playback() + self.audio.wait_for_state_change() + self.audio.set_position(2000) + + self.audio.wait_for_state_change().get() + + call = mock.call('position_changed', position=2000) + self.assertIn(call, send_mock.call_args_list) + + def test_position_changed_on_seek_after_pause(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.pause_playback() + self.audio.wait_for_state_change() + self.audio.set_position(2000) + + self.audio.wait_for_state_change().get() + + call = mock.call('position_changed', position=2000) + self.assertIn(call, send_mock.call_args_list) + # Unlike the other events, having the state changed done is not # enough to ensure our event is called. So we setup a threading # event that we can wait for with a timeout while the track playback diff --git a/tests/audio/test_listener.py b/tests/audio/test_listener.py index c579fd55..84f5b59e 100644 --- a/tests/audio/test_listener.py +++ b/tests/audio/test_listener.py @@ -27,3 +27,6 @@ class AudioListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_stream_changed(self): self.listener.stream_changed(None) + + def test_listener_has_default_impl_for_position_changed(self): + self.listener.position_changed(None) From 0d18bdea79fb36c7edebed1c3650f5efc360310a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 27 Jan 2014 23:43:11 +0100 Subject: [PATCH 008/495] audio: Add about to finish callback support --- mopidy/audio/actor.py | 30 ++++++++++++++++++++++-------- tests/audio/test_actor.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 5fc84411..64874938 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -58,6 +58,7 @@ class Audio(pykka.ThreadingActor): self._playbin = None self._signal_ids = {} # {(element, event): signal_id} + self._about_to_finish_callback = None self._mixer = None self._mixer_track = None @@ -108,15 +109,15 @@ class Audio(pykka.ThreadingActor): def _on_about_to_finish(self, element): source, self._appsrc = self._appsrc, None - if source is None: - return - self._appsrc_caps = None + if source is not None: + self._appsrc_caps = None + self._disconnect(source, 'need-data') + self._disconnect(source, 'enough-data') + self._disconnect(source, 'seek-data') - self._disconnect(source, 'need-data') - self._disconnect(source, 'enough-data') - self._disconnect(source, 'seek-data') - - logger.debug('Ready to switch to new stream') + if self._about_to_finish_callback: + logger.debug('Calling about to finish callback.') + self._about_to_finish_callback() def _on_new_source(self, element, pad): uri = element.get_property('uri') @@ -432,6 +433,19 @@ class Audio(pykka.ThreadingActor): # TODO: replace this with emit_data(None)? self._playbin.get_property('source').emit('end-of-stream') + def set_about_to_finish_callback(self, callback): + """ + Configure audio to use an about to finish callback. + + This should be used to achieve gapless playback. For this to work the + callback *MUST* call :meth:`set_uri` with the new URI to play and + block until this call has been made. :meth:`prepare_change` is not + needed before :meth:`set_uri` in this one special case. + + :param callable callback: Callback to run when we need the next URI. + """ + self._about_to_finish_callback = callback + def get_position(self): """ Get position in milliseconds. diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index ad88b92a..756c4d62 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -324,6 +324,45 @@ class AudioEventTest(unittest.TestCase): if not event.wait(timeout=5.0): self.fail('End of stream not reached within deadline') + # Make sure that gapless really works: + + def test_gapless(self, send_mock): + song2_uri = path_to_uri(path_to_data_dir('song2.wav')) + + uris = [song2_uri] + events = [] + done = threading.Event() + + def callback(): + if uris: + self.audio.set_uri(uris.pop()).get() + + def send(name, **kwargs): + events.append((name, kwargs)) + if name == 'reached_end_of_stream': + done.set() + + send_mock.side_effect = send + self.audio.set_about_to_finish_callback(callback).get() + + self.audio.prepare_change() + self.audio.set_uri(self.song_uri) + self.audio.start_playback() + self.audio.wait_for_state_change().get() + + if not done.wait(timeout=5.0): + self.fail('EOS not received') + + excepted = [ + ('position_changed', {'position': 0}), + ('stream_changed', {'uri': self.song_uri}), + ('state_changed', {'old_state': PlaybackState.STOPPED, + 'new_state': PlaybackState.PLAYING}), + ('position_changed', {'position': 0}), + ('stream_changed', {'uri': song2_uri}), + ('reached_end_of_stream', {})] + self.assertEqual(excepted, events) + class AudioStateTest(unittest.TestCase): def setUp(self): From a660524113e87a21b081fd402eef60dbade74aff Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 Jan 2014 00:03:57 +0100 Subject: [PATCH 009/495] audio: Debug log position_changed event triggering --- mopidy/audio/actor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 64874938..41206c75 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -301,6 +301,8 @@ class Audio(pykka.ThreadingActor): if event.type == gst.EVENT_NEWSEGMENT: # update, rate, format, start, stop, position position = event.parse_new_segment()[5] // gst.MSECOND + logger.debug('Triggering event: position_changed(position=%s)', + position) AudioListener.send('position_changed', position=position) return True From d1b91117b43fbcaa6d67e8589bb83f14e52c50c0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 Jan 2014 21:34:31 +0100 Subject: [PATCH 010/495] audio: Update dummy audio used for testing --- mopidy/audio/dummy.py | 31 +++++++++++++++++++++++++++---- tests/audio/test_actor.py | 1 + 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/mopidy/audio/dummy.py b/mopidy/audio/dummy.py index ad14390f..272fe346 100644 --- a/mopidy/audio/dummy.py +++ b/mopidy/audio/dummy.py @@ -17,12 +17,12 @@ class DummyAudio(pykka.ThreadingActor): super(DummyAudio, self).__init__() self.state = PlaybackState.STOPPED self._position = 0 - - def set_on_end_of_track(self, callback): - pass + self._callback = None + self._uri = None def set_uri(self, uri): - pass + assert self._uri is None, 'prepare change not called before set' + self._uri = uri def set_appsrc(self, *args, **kwargs): pass @@ -38,6 +38,7 @@ class DummyAudio(pykka.ThreadingActor): def set_position(self, position): self._position = position + AudioListener.send('position_changed', position=position) return True def start_playback(self): @@ -47,6 +48,7 @@ class DummyAudio(pykka.ThreadingActor): return self._change_state(PlaybackState.PAUSED) def prepare_change(self): + self._uri = None return True def stop_playback(self): @@ -61,8 +63,29 @@ class DummyAudio(pykka.ThreadingActor): def set_metadata(self, track): pass + def set_about_to_finish_callback(self, callback): + self._callback = callback + def _change_state(self, new_state): + if not self._uri: + return False + + if self.state == PlaybackState.STOPPED and self._uri: + AudioListener.send('stream_changed', uri=self._uri) + old_state, self.state = self.state, new_state AudioListener.send( 'state_changed', old_state=old_state, new_state=new_state) + return True + + def trigger_fake_end_of_stream(self): + AudioListener.send('reached_end_of_stream') + + def trigger_fake_about_to_finish(self): + if not self._callback: + return + self.prepare_change() + self._callback() + if not self._uri: + self.trigger_fake_end_of_stream() diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 756c4d62..55b79a97 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -138,6 +138,7 @@ class AudioEventTest(unittest.TestCase): pykka.ActorRegistry.stop_all() # TODO: test wihtout uri set, with bad uri and gapless... + # TODO: playing->playing triggered by seek should be removed def test_state_change_stopped_to_playing_event(self, send_mock): self.audio.prepare_change() From dd98ad83535da97649b1fcf5d0921d8b006a3431 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 28 Jan 2014 23:19:33 +0100 Subject: [PATCH 011/495] audio: Update tests and dummy audio actor to pass --- mopidy/audio/dummy.py | 47 +++++--- tests/audio/test_actor.py | 218 ++++++++++++++++++++++---------------- 2 files changed, 160 insertions(+), 105 deletions(-) diff --git a/mopidy/audio/dummy.py b/mopidy/audio/dummy.py index 272fe346..ee7e73b7 100644 --- a/mopidy/audio/dummy.py +++ b/mopidy/audio/dummy.py @@ -13,12 +13,14 @@ from .listener import AudioListener class DummyAudio(pykka.ThreadingActor): - def __init__(self): + def __init__(self, config=None): super(DummyAudio, self).__init__() self.state = PlaybackState.STOPPED + self._volume = 0 self._position = 0 self._callback = None self._uri = None + self._state_change_result = True def set_uri(self, uri): assert self._uri is None, 'prepare change not called before set' @@ -55,10 +57,11 @@ class DummyAudio(pykka.ThreadingActor): return self._change_state(PlaybackState.STOPPED) def get_volume(self): - return 0 + return self._volume def set_volume(self, volume): - pass + self._volume = volume + return True def set_metadata(self, track): pass @@ -66,26 +69,44 @@ class DummyAudio(pykka.ThreadingActor): def set_about_to_finish_callback(self, callback): self._callback = callback + def enable_sync_handler(self): + pass + + def wait_for_state_change(self): + pass + def _change_state(self, new_state): if not self._uri: return False if self.state == PlaybackState.STOPPED and self._uri: + AudioListener.send('position_changed', position=0) + AudioListener.send('stream_changed', uri=self._uri) + + if new_state == PlaybackState.STOPPED: + self._uri = None AudioListener.send('stream_changed', uri=self._uri) old_state, self.state = self.state, new_state AudioListener.send( 'state_changed', old_state=old_state, new_state=new_state) - return True + return self._state_change_result - def trigger_fake_end_of_stream(self): - AudioListener.send('reached_end_of_stream') + def trigger_fake_playback_failure(self): + self._state_change_result = False - def trigger_fake_about_to_finish(self): - if not self._callback: - return - self.prepare_change() - self._callback() - if not self._uri: - self.trigger_fake_end_of_stream() + def get_about_to_finish_callback(self): + # This needs to be called from outside the actor or we lock up. + def wrapper(): + if self._callback: + self.prepare_change() + self._callback() + + if not self._uri or not self._callback: + AudioListener.send('reached_end_of_stream') + else: + AudioListener.send('position_changed', position=0) + AudioListener.send('stream_changed', uri=self._uri) + + return wrapper diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 55b79a97..e5272efc 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -14,48 +14,82 @@ gobject.threads_init() import pykka from mopidy import audio +from mopidy.audio import dummy as dummy_audio from mopidy.audio.constants import PlaybackState from mopidy.utils.path import path_to_uri from tests import path_to_data_dir +""" +We want to make sure both our real audio class and the fake one behave +correctly. So each test is first run against the real class, then repeated +against our dummy. +""" -class AudioTest(unittest.TestCase): - def setUp(self): - config = { - 'audio': { - 'mixer': 'fakemixer track_max_volume=65536', - 'mixer_track': None, - 'mixer_volume': None, - 'output': 'fakesink', - 'visualizer': None, - } + +class BaseTest(unittest.TestCase): + config = { + 'audio': { + 'mixer': 'fakemixer track_max_volume=65536', + 'mixer_track': None, + 'mixer_volume': None, + 'output': 'fakesink', + 'visualizer': None, } - self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.audio = audio.Audio.start(config=config).proxy() + } + + uris = [path_to_uri(path_to_data_dir('song1.wav')), + path_to_uri(path_to_data_dir('song2.wav'))] + + audio_class = audio.Audio + + def setUp(self): + self.audio = self.audio_class.start(config=self.config).proxy() def tearDown(self): pykka.ActorRegistry.stop_all() + def possibly_trigger_fake_playback_error(self): + pass + + def possibly_trigger_fake_about_to_finish(self): + pass + + +class DummyMixin(object): + audio_class = dummy_audio.DummyAudio + + def possibly_trigger_fake_playback_error(self): + self.audio.trigger_fake_playback_failure() + + def possibly_trigger_fake_about_to_finish(self): + callback = self.audio.get_about_to_finish_callback().get() + if callback: + callback() + + +class AudioTest(BaseTest): def test_start_playback_existing_file(self): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.assertTrue(self.audio.start_playback().get()) def test_start_playback_non_existing_file(self): + self.possibly_trigger_fake_playback_error() + self.audio.prepare_change() - self.audio.set_uri(self.song_uri + 'bogus') + self.audio.set_uri(self.uris[0] + 'bogus') self.assertFalse(self.audio.start_playback().get()) def test_pause_playback_while_playing(self): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.assertTrue(self.audio.pause_playback().get()) def test_stop_playback_while_playing(self): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.assertTrue(self.audio.stop_playback().get()) @@ -67,40 +101,6 @@ class AudioTest(unittest.TestCase): def test_end_of_data_stream(self): pass # TODO - def test_set_volume(self): - for value in range(0, 101): - self.assertTrue(self.audio.set_volume(value).get()) - self.assertEqual(value, self.audio.get_volume().get()) - - def test_set_volume_with_mixer_max_below_100(self): - config = { - 'audio': { - 'mixer': 'fakemixer track_max_volume=40', - 'mixer_track': None, - 'mixer_volume': None, - 'output': 'fakesink', - 'visualizer': None, - } - } - self.audio = audio.Audio.start(config=config).proxy() - - for value in range(0, 101): - self.assertTrue(self.audio.set_volume(value).get()) - self.assertEqual(value, self.audio.get_volume().get()) - - def test_set_volume_with_mixer_min_equal_max(self): - config = { - 'audio': { - 'mixer': 'fakemixer track_max_volume=0', - 'mixer_track': None, - 'mixer_volume': None, - 'output': 'fakesink', - 'visualizer': None, - } - } - self.audio = audio.Audio.start(config=config).proxy() - self.assertEqual(0, self.audio.get_volume().get()) - @unittest.SkipTest def test_set_mute(self): pass # TODO Probably needs a fakemixer with a mixer track @@ -118,31 +118,22 @@ class AudioTest(unittest.TestCase): pass # TODO -@mock.patch.object(audio.AudioListener, 'send') -class AudioEventTest(unittest.TestCase): - def setUp(self): - config = { - 'audio': { - 'mixer': 'fakemixer track_max_volume=65536', - 'mixer_track': None, - 'mixer_volume': None, - 'output': 'fakesink', - 'visualizer': None, - } - } - self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.audio = audio.Audio.start(config=config).proxy() - self.audio.enable_sync_handler().get() +class AudioDummyTest(DummyMixin, AudioTest): + pass - def tearDown(self): - pykka.ActorRegistry.stop_all() + +@mock.patch.object(audio.AudioListener, 'send') +class AudioEventTest(BaseTest): + def setUp(self): + super(AudioEventTest, self).setUp() + self.audio.enable_sync_handler().get() # TODO: test wihtout uri set, with bad uri and gapless... # TODO: playing->playing triggered by seek should be removed def test_state_change_stopped_to_playing_event(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change().get() @@ -152,7 +143,7 @@ class AudioEventTest(unittest.TestCase): def test_state_change_stopped_to_paused_event(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change().get() @@ -162,7 +153,7 @@ class AudioEventTest(unittest.TestCase): def test_state_change_paused_to_playing_event(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change() self.audio.start_playback() @@ -174,7 +165,7 @@ class AudioEventTest(unittest.TestCase): def test_state_change_paused_to_stopped_event(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change() self.audio.stop_playback() @@ -186,7 +177,7 @@ class AudioEventTest(unittest.TestCase): def test_state_change_playing_to_paused_event(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change() self.audio.pause_playback() @@ -198,7 +189,7 @@ class AudioEventTest(unittest.TestCase): def test_state_change_playing_to_stopped_event(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change() self.audio.stop_playback() @@ -210,19 +201,19 @@ class AudioEventTest(unittest.TestCase): def test_stream_changed_event_on_playing(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.start_playback() # Since we are going from stopped to playing, the state change is # enough to ensure the stream changed. self.audio.wait_for_state_change().get() - call = mock.call('stream_changed', uri=self.song_uri) + call = mock.call('stream_changed', uri=self.uris[0]) self.assertIn(call, send_mock.call_args_list) def test_stream_changed_event_on_paused_to_stopped(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change() self.audio.stop_playback() @@ -234,7 +225,7 @@ class AudioEventTest(unittest.TestCase): def test_position_changed_on_pause(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change() @@ -245,7 +236,7 @@ class AudioEventTest(unittest.TestCase): def test_position_changed_on_play(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change() @@ -256,7 +247,7 @@ class AudioEventTest(unittest.TestCase): def test_position_changed_on_seek(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.set_position(2000) self.audio.wait_for_state_change().get() @@ -266,7 +257,7 @@ class AudioEventTest(unittest.TestCase): def test_position_changed_on_seek_after_play(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change() self.audio.set_position(2000) @@ -278,7 +269,7 @@ class AudioEventTest(unittest.TestCase): def test_position_changed_on_seek_after_pause(self, send_mock): self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.pause_playback() self.audio.wait_for_state_change() self.audio.set_position(2000) @@ -302,11 +293,11 @@ class AudioEventTest(unittest.TestCase): send_mock.side_effect = send self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.pause_playback().get() self.audio.wait_for_state_change().get() - if not event.wait(timeout=5.0): + if not event.wait(timeout=1.0): self.fail('Stream changed not reached within deadline') def test_reached_end_of_stream_event(self, send_mock): @@ -318,19 +309,18 @@ class AudioEventTest(unittest.TestCase): send_mock.side_effect = send self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.start_playback() self.audio.wait_for_state_change().get() - if not event.wait(timeout=5.0): + self.possibly_trigger_fake_about_to_finish() + if not event.wait(timeout=1.0): self.fail('End of stream not reached within deadline') # Make sure that gapless really works: def test_gapless(self, send_mock): - song2_uri = path_to_uri(path_to_data_dir('song2.wav')) - - uris = [song2_uri] + uris = self.uris[1:] events = [] done = threading.Event() @@ -347,24 +337,68 @@ class AudioEventTest(unittest.TestCase): self.audio.set_about_to_finish_callback(callback).get() self.audio.prepare_change() - self.audio.set_uri(self.song_uri) + self.audio.set_uri(self.uris[0]) self.audio.start_playback() + + self.possibly_trigger_fake_about_to_finish() self.audio.wait_for_state_change().get() - if not done.wait(timeout=5.0): + self.possibly_trigger_fake_about_to_finish() + if not done.wait(timeout=1.0): self.fail('EOS not received') excepted = [ ('position_changed', {'position': 0}), - ('stream_changed', {'uri': self.song_uri}), + ('stream_changed', {'uri': self.uris[0]}), ('state_changed', {'old_state': PlaybackState.STOPPED, 'new_state': PlaybackState.PLAYING}), ('position_changed', {'position': 0}), - ('stream_changed', {'uri': song2_uri}), + ('stream_changed', {'uri': self.uris[1]}), ('reached_end_of_stream', {})] self.assertEqual(excepted, events) +class AudioDummyEventTest(DummyMixin, AudioEventTest): + pass + + +# TODO: this is really a mixer scaling test, has nothing to do with audio +class MixerTest(BaseTest): + def test_set_volume(self): + for value in range(0, 101): + self.assertTrue(self.audio.set_volume(value).get()) + self.assertEqual(value, self.audio.get_volume().get()) + + def test_set_volume_with_mixer_max_below_100(self): + config = { + 'audio': { + 'mixer': 'fakemixer track_max_volume=40', + 'mixer_track': None, + 'mixer_volume': None, + 'output': 'fakesink', + 'visualizer': None, + } + } + self.audio = self.audio_class.start(config=config).proxy() + + for value in range(0, 101): + self.assertTrue(self.audio.set_volume(value).get()) + self.assertEqual(value, self.audio.get_volume().get()) + + def test_set_volume_with_mixer_min_equal_max(self): + config = { + 'audio': { + 'mixer': 'fakemixer track_max_volume=0', + 'mixer_track': None, + 'mixer_volume': None, + 'output': 'fakesink', + 'visualizer': None, + } + } + self.audio = self.audio_class.start(config=config).proxy() + self.assertEqual(0, self.audio.get_volume().get()) + + class AudioStateTest(unittest.TestCase): def setUp(self): self.audio = audio.Audio(config=None) From e4932c05b107171a06b27600d7b1f310e6b35d5f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 30 Jan 2014 22:21:59 +0100 Subject: [PATCH 012/495] audio: Add more TODOs --- tests/audio/test_actor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index e5272efc..bda38164 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -130,6 +130,8 @@ class AudioEventTest(BaseTest): # TODO: test wihtout uri set, with bad uri and gapless... # TODO: playing->playing triggered by seek should be removed + # TODO: codify expected state after EOS + # TODO: consider returning a future or a threading event? def test_state_change_stopped_to_playing_event(self, send_mock): self.audio.prepare_change() From e9289ca554fe0e9f755e8156175f76dc001bd536 Mon Sep 17 00:00:00 2001 From: Ignasi Fosch Date: Sun, 27 Jul 2014 16:34:28 +0200 Subject: [PATCH 013/495] Checks for musicbrainz album ID and sets images to a list containing the corresponding URL --- mopidy/audio/scan.py | 12 ++++++++++++ mopidy/local/commands.py | 3 ++- tests/audio/test_scan.py | 14 +++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 6c23e954..2f6ac4c3 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -150,6 +150,18 @@ def _date(tags): return None +def add_musicbrainz_cover_art(track): + if track.album is not None and track.album.musicbrainz_id is not None: + base = "http://coverartarchive.org/release" + images = frozenset( + ["{}/{}/front".format( + base, + track.album.musicbrainz_id)]) + album = track.album.copy(images=images) + track = track.copy(album=album) + return track + + def audio_data_to_track(data): """Convert taglist data + our extras to a track.""" tags = data['tags'] diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 1e7839a5..f2a7ec24 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -118,7 +118,8 @@ class ScanCommand(commands.Command): relpath = translator.local_track_uri_to_path(uri, media_dir) file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) data = scanner.scan(file_uri) - track = scan.audio_data_to_track(data).copy(uri=uri) + track = scan.add_musicbrainz_cover_art( + scan.audio_data_to_track(data).copy(uri=uri)).copy(uri=uri) library.add(track) logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 26caa422..1e352991 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -71,6 +71,11 @@ class TranslatorTest(unittest.TestCase): actual = scan.audio_data_to_track(self.data) self.assertEqual(expected, actual) + def check_local(self, expected): + actual = scan.add_musicbrainz_cover_art( + scan.audio_data_to_track(self.data)) + self.assertEqual(expected, actual) + def test_track(self): self.check(self.track) @@ -197,13 +202,20 @@ class TranslatorTest(unittest.TestCase): def test_missing_album_musicbrainz_id(self): del self.data['tags']['musicbrainz-albumid'] - album = self.track.album.copy(musicbrainz_id=None) + album = self.track.album.copy(musicbrainz_id=None, + images=[]) self.check(self.track.copy(album=album)) def test_multiple_album_musicbrainz_id(self): self.data['tags']['musicbrainz-albumid'].append('id') self.check(self.track) + def test_album_musicbrainz_id_cover(self): + album = self.track.album.copy( + images=frozenset( + ['http://coverartarchive.org/release/albumid/front'])) + self.check_local(self.track.copy(album=album)) + def test_missing_album_num_tracks(self): del self.data['tags']['track-count'] album = self.track.album.copy(num_tracks=None) From 13073362f4d6ae05c59104816d1ec281824577bb Mon Sep 17 00:00:00 2001 From: Ignasi Fosch Date: Sun, 27 Jul 2014 17:52:14 +0200 Subject: [PATCH 014/495] Correct if .. is not None --- mopidy/audio/scan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 2f6ac4c3..53f00ac0 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -151,7 +151,7 @@ def _date(tags): def add_musicbrainz_cover_art(track): - if track.album is not None and track.album.musicbrainz_id is not None: + if track.album and track.album.musicbrainz_id: base = "http://coverartarchive.org/release" images = frozenset( ["{}/{}/front".format( From acef38a6c75bb46324d4a49b57552f2320f02694 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 28 Jul 2014 23:41:17 +0200 Subject: [PATCH 015/495] tox: Fix Tornado version mismatch in env name and deps --- tox.ini | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tox.ini b/tox.ini index 93447015..ed6f0271 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = tornado2.3, tornado3.2, py27, docs, flake8 +envlist = py27, py27-tornado23, py27-tornado31, docs, flake8 [testenv] sitepackages = true @@ -9,14 +9,16 @@ deps = mock nose -[testenv:tornado2.3] +[testenv:py27-tornado23] commands = nosetests -v tests/http -deps = {[testenv]deps} +deps = + {[testenv]deps} tornado==2.3 -[testenv:tornado3.2] +[testenv:py27-tornado31] commands = nosetests -v tests/http -deps = {[testenv]deps} +deps = + {[testenv]deps} tornado==3.1 [testenv:docs] From bf752859dac7d4c6fe20b72fa227653df0cd5967 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 29 Jul 2014 23:12:53 +0200 Subject: [PATCH 016/495] flake8: Fix duplicate import and import order in audio actor test --- tests/audio/test_actor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index ae1a7991..b27d9855 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -import mock import threading import unittest From e950cf5501bf82787e202024774a4e5f6f85ed27 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 29 Jul 2014 23:15:05 +0200 Subject: [PATCH 017/495] travis: Update tox env names --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 77a8d8a3..8e14280f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,8 @@ python: env: - TOX_ENV=py27 - - TOX_ENV=tornado2.3 - - TOX_ENV=tornado3.2 + - TOX_ENV=py27-tornado23 + - TOX_ENV=py27-tornado31 - TOX_ENV=docs - TOX_ENV=flake8 From d9efb1f877e07fb232cb8012575aef4d4c544f47 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 29 Jul 2014 23:34:21 +0200 Subject: [PATCH 018/495] docs: Add v0.20 changelog --- docs/changelog.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ac8084d3..486ebf8d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,15 @@ Changelog This changelog is used to track all major changes to Mopidy. +v0.20.0 (UNRELEASED) +==================== + +**Local backend** + +- Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: + :issue:`697`, PR: :issue:`802`) + + v0.19.3 (UNRELEASED) ==================== From 29019d94af42e81b478dd283a70c809f78e5f650 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 29 Jul 2014 23:45:22 +0200 Subject: [PATCH 019/495] audio: Link to context of why we demote jack sinks --- mopidy/audio/actor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 5b4362a3..0d90394d 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -98,6 +98,7 @@ class Audio(pykka.ThreadingActor): element.disconnect(signal_id) def _setup_preferences(self): + # Fix for https://github.com/mopidy/mopidy/issues/604 registry = gst.registry_get_default() jacksink = registry.find_feature( 'jackaudiosink', gst.TYPE_ELEMENT_FACTORY) From 0440703abbe8a96eb45a1795fdca2482f7998b9d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 29 Jul 2014 23:47:23 +0200 Subject: [PATCH 020/495] doc: Add changelog entry for jack sink fix --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 486ebf8d..b2d83144 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,7 +12,10 @@ v0.20.0 (UNRELEASED) - Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: :issue:`697`, PR: :issue:`802`) + +**Audio** +- Tell GStreamer to not pick jack sink on raspis (Fixes: :issue:`604`) v0.19.3 (UNRELEASED) ==================== From 614dc93ad85e58be563f3cb7cfb4ffa3c951b202 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 29 Jul 2014 23:46:16 +0200 Subject: [PATCH 021/495] docs: Update authors --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 7a20f492..e36d953d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -41,3 +41,4 @@ - Thomas Scholtes - Sam Willcocks - Ignasi Fosch +- Arjun Naik From a470e0c9147d528fa09a1fedccfe0f9f507bc433 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 29 Jul 2014 23:41:59 +0200 Subject: [PATCH 022/495] docs: Add Mopidy-Banshee --- docs/ext/backends.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 1b0bf112..c4074a12 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -11,6 +11,15 @@ This list is moderated and updated on a regular basis. If you want your package to show up here, follow the :ref:`guide on creating extensions `. +Mopidy-Banshee +============== + +https://github.com/tamland/mopidy-banshee + +Provides a backend for playing music from the `Banshee `_ +music player's music library. + + Mopidy-Beets ============ From c51988546da16fdb1017c7411fd26b70f783e4d4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 29 Jul 2014 23:43:57 +0200 Subject: [PATCH 023/495] docs: Add Mopidy-Bassdrive --- docs/ext/backends.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index c4074a12..06f6244c 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -20,6 +20,15 @@ Provides a backend for playing music from the `Banshee `_ music player's music library. +Mopidy-Bassdrive +================ + +https://github.com/felixb/mopidy-Bassdrive + +Provides a backend for playing radio streams from `BassDrive +`_. + + Mopidy-Beets ============ From 2a28128c37732f07e6ddaa1cd712949cbad49245 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 30 Jul 2014 00:24:28 +0200 Subject: [PATCH 024/495] docs: Add Mopidy-LeftAsRain --- docs/ext/backends.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 06f6244c..2b516f55 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -65,6 +65,15 @@ Extension for playing music and audio from the `Internet Archive `_. +Mopidy-LeftAsRain +================= + +https://github.com/naglis/mopidy-leftasrain + +Extension for playing music from the `leftasrain.com +`_ music blog. + + Mopidy-Local ============ From 27d4c89ae98c8cf64fc3da355050c180d35ac232 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 30 Jul 2014 00:27:26 +0200 Subject: [PATCH 025/495] docs: Add Mopidy-Touchscreen --- docs/ext/frontends.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ext/frontends.rst b/docs/ext/frontends.rst index 481ac510..1010b1f6 100644 --- a/docs/ext/frontends.rst +++ b/docs/ext/frontends.rst @@ -47,3 +47,12 @@ Mopidy-Scrobbler https://github.com/mopidy/mopidy-scrobbler Extension for scrobbling played tracks to Last.fm. + + +Mopidy-Touchscreen +================== + +https://github.com/9and3r/mopidy-touchscreen + +Extension for displaying track info and controlling Mopidy from a touch screen +using `PyGame `_/SDL. From 514d83636ab00e8dbd1c451e4b265f4fdfac005d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 2 Aug 2014 00:22:38 +0200 Subject: [PATCH 026/495] docs: Add PlaylistsProvider docs --- mopidy/backend/__init__.py | 44 +++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/mopidy/backend/__init__.py b/mopidy/backend/__init__.py index 317cf762..845eac11 100644 --- a/mopidy/backend/__init__.py +++ b/mopidy/backend/__init__.py @@ -222,6 +222,10 @@ class PlaybackProvider(object): class PlaylistsProvider(object): """ + A playlist provider exposes a collection of playlists, methods to + create/change/delete playlists in this collection, and lookup of any + playlist the backend knows about. + :param backend: backend the controller is a part of :type backend: :class:`mopidy.backend.Backend` instance """ @@ -232,6 +236,11 @@ class PlaylistsProvider(object): self.backend = backend self._playlists = [] + # TODO Replace playlists property with a get_playlists() method which + # returns playlist Ref's instead of the gigantic data structures we + # currently make available. lookup() should be used for getting full + # playlists with all details. + @property def playlists(self): """ @@ -247,31 +256,47 @@ class PlaylistsProvider(object): def create(self, name): """ - See :meth:`mopidy.core.PlaylistsController.create`. + Create a new empty playlist with the given name. + + Returns a new playlist with the given name and an URI. *MUST be implemented by subclass.* + + :param name: name of the new playlist + :type name: string + :rtype: :class:`mopidy.models.Playlist` """ raise NotImplementedError def delete(self, uri): """ - See :meth:`mopidy.core.PlaylistsController.delete`. + Delete playlist identified by the URI. *MUST be implemented by subclass.* + + :param uri: URI of the playlist to delete + :type uri: string """ raise NotImplementedError def lookup(self, uri): """ - See :meth:`mopidy.core.PlaylistsController.lookup`. + Lookup playlist with given URI in both the set of playlists and in any + other playlist source. + + Returns the playlists or :class:`None` if not found. *MUST be implemented by subclass.* + + :param uri: playlist URI + :type uri: string + :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ raise NotImplementedError def refresh(self): """ - See :meth:`mopidy.core.PlaylistsController.refresh`. + Refresh the playlists in :attr:`playlists`. *MUST be implemented by subclass.* """ @@ -279,9 +304,18 @@ class PlaylistsProvider(object): def save(self, playlist): """ - See :meth:`mopidy.core.PlaylistsController.save`. + Save the given playlist. + + The playlist must have an ``uri`` attribute set. To create a new + playlist with an URI, use :meth:`create`. + + Returns the saved playlist or :class:`None` on failure. *MUST be implemented by subclass.* + + :param playlist: the playlist to save + :type playlist: :class:`mopidy.models.Playlist` + :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ raise NotImplementedError From 5d1dd1a35569f51a23296b1d286379a2cf92b850 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 2 Aug 2014 20:45:55 +0200 Subject: [PATCH 027/495] review-fixes: Mostly typos etc. --- mopidy/audio/actor.py | 9 +++++---- mopidy/audio/listener.py | 2 +- tests/audio/test_actor.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 08552b33..7dc9c2ba 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -45,7 +45,7 @@ PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7) PLAYBIN_VIS_FLAGS = PLAYBIN_FLAGS | (1 << 3) -# TODO: split out mixer as these are to intertwined right now +# TODO: split out mixer as these are too intertwined right now class Audio(pykka.ThreadingActor): """ Audio output through `GStreamer `_. @@ -89,6 +89,7 @@ class Audio(pykka.ThreadingActor): self._teardown_mixer() self._teardown_playbin() + # TODO: split out signal tracking helper class. def _connect(self, element, event, *args): """Helper to keep track of signal ids based on element+event""" self._signal_ids[(element, event)] = element.connect(event, *args) @@ -201,7 +202,7 @@ class Audio(pykka.ThreadingActor): output = gst.Bin('output') - # Queue element to buy use time between the about to finish event and + # Queue element to buy us time between the about to finish event and # the actual switch, i.e. about to switch can block for longer thanks # to this queue. # TODO: make the min-max values a setting? @@ -337,7 +338,7 @@ class Audio(pykka.ThreadingActor): logger.debug('Buffer %d%% full', percent) def _on_end_of_stream(self): - logger.debug('Triggering event: reached_end_of_stream event') + logger.debug('Audio event: reached_end_of_stream') AudioListener.send('reached_end_of_stream') def _on_error(self, error, debug): @@ -421,7 +422,7 @@ class Audio(pykka.ThreadingActor): def set_about_to_finish_callback(self, callback): """ - Configure audio to use an about to finish callback. + Configure audio to use an about-to-finish callback. This should be used to achieve gapless playback. For this to work the callback *MUST* call :meth:`set_uri` with the new URI to play and diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 11613149..b272d15a 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -29,7 +29,7 @@ class AudioListener(listener.Listener): def stream_changed(self, uri): """ - Called whenever the end of the audio stream changes. + Called whenever the audio stream changes. *MAY* be implemented by actor. diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index b27d9855..2426f54e 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -141,7 +141,7 @@ class AudioEventTest(BaseTest): super(AudioEventTest, self).setUp() self.audio.enable_sync_handler().get() - # TODO: test wihtout uri set, with bad uri and gapless... + # TODO: test without uri set, with bad uri and gapless... # TODO: playing->playing triggered by seek should be removed # TODO: codify expected state after EOS # TODO: consider returning a future or a threading event? From 80f5c9158d485099fac9693d7fbad74a29578a09 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 11:43:28 +0200 Subject: [PATCH 028/495] zeroconf: Fix intermittent dbus/avahi exception This fixes an issue where I sometimes would get an error from dbus 'Unable to guess signature from an empty list'. After some digging and checking the avahi dbus specs I found they expect the text list to have a signature of 'aay' (an array of arrays containing bytes). So instead of using python lists we now use a 'typed' dbus array. It is not clear to me why this is a heisenbug, but this fix does seem to make it go away. --- mopidy/zeroconf.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/mopidy/zeroconf.py b/mopidy/zeroconf.py index 0f991ba3..9f726957 100644 --- a/mopidy/zeroconf.py +++ b/mopidy/zeroconf.py @@ -23,8 +23,11 @@ def _is_loopback_address(host): host == '::1') -def _convert_text_to_dbus_bytes(text): - return [dbus.Byte(ord(c)) for c in text] +def _convert_text_list_to_dbus_format(text_list): + array = dbus.Array(signature='ay') + for text in text_list: + array.append([dbus.Byte(ord(c)) for c in text]) + return array class Zeroconf(object): @@ -91,11 +94,11 @@ class Zeroconf(object): 'org.freedesktop.Avahi', server.EntryGroupNew()), 'org.freedesktop.Avahi.EntryGroup') - text = [_convert_text_to_dbus_bytes(t) for t in self.text] self.group.AddService( _AVAHI_IF_UNSPEC, _AVAHI_PROTO_UNSPEC, dbus.UInt32(_AVAHI_PUBLISHFLAGS_NONE), self.name, self.stype, - self.domain, self.host, dbus.UInt16(self.port), text) + self.domain, self.host, dbus.UInt16(self.port), + _convert_text_list_to_dbus_format(self.text)) self.group.Commit() logger.debug('%s: Published', self) From 1e0569abb6e482366b6012e05604826a4df35c12 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 3 Aug 2014 23:23:34 +0200 Subject: [PATCH 029/495] docs: Use text from web site and readme --- docs/index.rst | 67 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index aedc0fb0..bf259b08 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,25 +2,64 @@ Mopidy ****** -Mopidy is a music server which can play music both from multiple sources, like -your :ref:`local hard drive `, :ref:`radio streams `, -and from Spotify and SoundCloud. Searches combines results from all music -sources, and you can mix tracks from all sources in your play queue. Your -playlists from Spotify or SoundCloud are also available for use. +Mopidy is an extensible music server written in Python. -To control your Mopidy music server, you can use one of Mopidy's :ref:`web -clients `, the :ref:`Ubuntu Sound Menu `, any -device on the same network which can control :ref:`UPnP MediaRenderers -`, or any :ref:`MPD client `. MPD clients are -available for many platforms, including Windows, OS X, Linux, Android and iOS. +Mopidy plays music from local disk, Spotify, SoundCloud, Google Play Music, and +more. You edit the playlist from any phone, tablet, or computer using a range +of MPD and web clients. + +**Stream music from the cloud** + +Vanilla Mopidy only plays music from your :ref:`local disk ` and +:ref:`radio streams `. Through :ref:`extensions `, +Mopidy can play music from cloud services like Spotify, SoundCloud, and Google +Play Music. With Mopidy's extension support, backends for new music sources can +be easily added. + +**Mopidy is just a server** + +Mopidy is a Python application that runs in a terminal or in the background on +Linux computers or Macs that have network connectivity and audio output. Out of +the box, Mopidy is an :ref:`MPD ` and :ref:`HTTP ` server. +Additional frontends for controlling Mopidy can be installed from extensions. + +**Everybody use their favorite client** + +You and the people around you can all connect their favorite :ref:`MPD +` or :ref:`web client ` to the Mopidy server to +search for music and manage the playlist together. With a browser or MPD +client, which is available for all popular operating systems, you can control +the music from any phone, tablet, or computer. + +**Mopidy on Raspberry Pi** + +The Raspberry Pi is a popular device to run Mopidy on, either using Raspbian or +Arch Linux. It is quite slow, but it is very affordable. In fact, the +Kickstarter funded Gramofon: Modern Cloud Jukebox project used Mopidy on a +Raspberry Pi to prototype the Gramofon device. Mopidy is also a major building +block in the Pi Musicbox integrated audio jukebox system for Raspberry Pi. + +**Mopidy is hackable** + +Mopidy's extension support and Python, JSON-RPC, and JavaScript APIs makes +Mopidy perfect for building your own hacks. In one project, a Raspberry Pi was +embedded in an old cassette player. The buttons and volume control are wired up +with GPIO on the Raspberry Pi, and is used to control playback through a custom +Mopidy extension. The cassettes have NFC tags used to select playlists from +Spotify. + +**Getting started** To get started with Mopidy, start by reading :ref:`installation`. +**Getting help** + If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net -`_ and also have a `mailing list at Google Groups -`_. If you stumble -into a bug or got a feature request, please create an issue in the `issue -tracker `_. The `source code +`_ (with `searchable logs +`_) and also have a `mailing list at Google +Groups `_. If you +stumble into a bug or got a feature request, please create an issue in the +`issue tracker `_. The `source code `_ may also be of help. If you want to stay up to date on Mopidy developments, you can follow `@mopidy `_ on Twitter. From 5dd2be5ec94c8691eb1051e9e78c283a2e09e764 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 3 Aug 2014 23:30:47 +0200 Subject: [PATCH 030/495] docs: Add more links into the docs --- docs/index.rst | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index bf259b08..71e8dee7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,7 +21,8 @@ be easily added. Mopidy is a Python application that runs in a terminal or in the background on Linux computers or Macs that have network connectivity and audio output. Out of the box, Mopidy is an :ref:`MPD ` and :ref:`HTTP ` server. -Additional frontends for controlling Mopidy can be installed from extensions. +:ref:`Additional frontends ` for controlling Mopidy can be +installed from extensions. **Everybody use their favorite client** @@ -33,20 +34,21 @@ the music from any phone, tablet, or computer. **Mopidy on Raspberry Pi** -The Raspberry Pi is a popular device to run Mopidy on, either using Raspbian or -Arch Linux. It is quite slow, but it is very affordable. In fact, the -Kickstarter funded Gramofon: Modern Cloud Jukebox project used Mopidy on a -Raspberry Pi to prototype the Gramofon device. Mopidy is also a major building -block in the Pi Musicbox integrated audio jukebox system for Raspberry Pi. +The :ref:`Raspberry Pi ` is a popular device to run +Mopidy on, either using Raspbian or Arch Linux. It is quite slow, but it is +very affordable. In fact, the Kickstarter funded Gramofon: Modern Cloud Jukebox +project used Mopidy on a Raspberry Pi to prototype the Gramofon device. Mopidy +is also a major building block in the Pi Musicbox integrated audio jukebox +system for Raspberry Pi. **Mopidy is hackable** -Mopidy's extension support and Python, JSON-RPC, and JavaScript APIs makes -Mopidy perfect for building your own hacks. In one project, a Raspberry Pi was -embedded in an old cassette player. The buttons and volume control are wired up -with GPIO on the Raspberry Pi, and is used to control playback through a custom -Mopidy extension. The cassettes have NFC tags used to select playlists from -Spotify. +Mopidy's extension support and :ref:`Python `, :ref:`JSON-RPC +`, and :ref:`JavaScript APIs ` makes Mopidy perfect for +building your own hacks. In one project, a Raspberry Pi was embedded in an old +cassette player. The buttons and volume control are wired up with GPIO on the +Raspberry Pi, and is used to control playback through a custom Mopidy +extension. The cassettes have NFC tags used to select playlists from Spotify. **Getting started** From 531b312acefe7379cbdf0f7845c0ee0b4db2b633 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 00:36:54 +0200 Subject: [PATCH 031/495] audio: Split out the signals tracking --- mopidy/audio/actor.py | 68 ++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 7dc9c2ba..71697664 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -45,6 +45,20 @@ PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7) PLAYBIN_VIS_FLAGS = PLAYBIN_FLAGS | (1 << 3) +class _Signals(object): + def __init__(self): + self._ids = {} + + def connect(self, element, event, *args): + assert (element, event) not in self._ids + self._ids[(element, event)] = element.connect(event, *args) + + def disconnect(self, element, event): + signal_id = self._ids.pop((element, event), None) + if signal_id is not None: + element.disconnect(signal_id) + + # TODO: split out mixer as these are too intertwined right now class Audio(pykka.ThreadingActor): """ @@ -63,7 +77,7 @@ class Audio(pykka.ThreadingActor): self._buffering = False self._playbin = None - self._signal_ids = {} # {(element, event): signal_id} + self._signals = _Signals() self._about_to_finish_callback = None self._appsrc = None @@ -89,17 +103,6 @@ class Audio(pykka.ThreadingActor): self._teardown_mixer() self._teardown_playbin() - # TODO: split out signal tracking helper class. - def _connect(self, element, event, *args): - """Helper to keep track of signal ids based on element+event""" - self._signal_ids[(element, event)] = element.connect(event, *args) - - def _disconnect(self, element, event): - """Helper to disconnect signals created with _connect helper.""" - signal_id = self._signal_ids.pop((element, event), None) - if signal_id is not None: - element.disconnect(signal_id) - def _setup_preferences(self): # Fix for https://github.com/mopidy/mopidy/issues/604 registry = gst.registry_get_default() @@ -115,9 +118,11 @@ class Audio(pykka.ThreadingActor): playbin.set_property('buffer-size', 2*1024*1024) playbin.set_property('buffer-duration', 2*gst.SECOND) - self._connect(playbin, 'about-to-finish', self._on_about_to_finish) - self._connect(playbin, 'notify::source', self._on_new_source) - self._connect(playbin, 'source-setup', self._on_source_setup) + # TODO: on new source and source setup are dupes... + self._signals.connect(playbin, 'notify::source', self._on_new_source) + self._signals.connect(playbin, 'source-setup', self._on_source_setup) + self._signals.connect( + playbin, 'about-to-finish', self._on_about_to_finish) self._playbin = playbin @@ -125,9 +130,9 @@ class Audio(pykka.ThreadingActor): source, self._appsrc = self._appsrc, None if source is not None: self._appsrc_caps = None - self._disconnect(source, 'need-data') - self._disconnect(source, 'enough-data') - self._disconnect(source, 'seek-data') + self._signals.disconnect(source, 'need-data') + self._signals.disconnect(source, 'enough-data') + self._signals.disconnect(source, 'seek-data') if self._about_to_finish_callback: logger.debug('Calling about to finish callback.') @@ -145,9 +150,10 @@ class Audio(pykka.ThreadingActor): source.set_property('max-bytes', 1 * MB) source.set_property('min-percent', 50) - self._connect(source, 'need-data', self._appsrc_on_need_data) - self._connect(source, 'enough-data', self._appsrc_on_enough_data) - self._connect(source, 'seek-data', self._appsrc_on_seek_data) + self._signals.connect(source, 'need-data', self._appsrc_on_need_data) + self._signals.connect(source, 'seek-data', self._appsrc_on_seek_data) + self._signals.connect( + source, 'enough-data', self._appsrc_on_enough_data) self._appsrc = source @@ -185,9 +191,9 @@ class Audio(pykka.ThreadingActor): return True def _teardown_playbin(self): - self._disconnect(self._playbin, 'about-to-finish') - self._disconnect(self._playbin, 'notify::source') - self._disconnect(self._playbin, 'source-setup') + self._signals.disconnect(self._playbin, 'about-to-finish') + self._signals.disconnect(self._playbin, 'notify::source') + self._signals.disconnect(self._playbin, 'source-setup') self._playbin.set_state(gst.STATE_NULL) def _setup_output(self): @@ -228,8 +234,10 @@ class Audio(pykka.ThreadingActor): if self._config['audio']['mixer'] != 'software': return self._mixer.audio = self.actor_ref.proxy() - self._connect(self._playbin, 'notify::volume', self._on_mixer_change) - self._connect(self._playbin, 'notify::mute', self._on_mixer_change) + self._signals.connect( + self._playbin, 'notify::volume', self._on_mixer_change) + self._signals.connect( + self._playbin, 'notify::mute', self._on_mixer_change) # The Mopidy startup procedure will set the initial volume of a mixer, # but this happens before the audio actor is injected into the software @@ -245,8 +253,8 @@ class Audio(pykka.ThreadingActor): def _teardown_mixer(self): if self._config['audio']['mixer'] != 'software': return - self._disconnect(self._playbin, 'notify::volume') - self._disconnect(self._playbin, 'notify::mute') + self._signals.disconnect(self._playbin, 'notify::volume') + self._signals.disconnect(self._playbin, 'notify::mute') self._mixer.audio = None def _setup_visualizer(self): @@ -266,11 +274,11 @@ class Audio(pykka.ThreadingActor): def _setup_message_processor(self): bus = self._playbin.get_bus() bus.add_signal_watch() - self._connect(bus, 'message', self._on_message) + self._signals.connect(bus, 'message', self._on_message) def _teardown_message_processor(self): bus = self._playbin.get_bus() - self._disconnect(bus, 'message') + self._signals.disconnect(bus, 'message') bus.remove_signal_watch() def _on_pad_event(self, pad, event): From f0f19ebc2e25ea139061925fed1820fbc3d7b084 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 17:25:00 +0200 Subject: [PATCH 032/495] audio: Add docs to signal handler helper --- mopidy/audio/actor.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 71697664..24b34c81 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -46,20 +46,34 @@ PLAYBIN_VIS_FLAGS = PLAYBIN_FLAGS | (1 << 3) class _Signals(object): + """Helper for tracking gobject signal registrations""" def __init__(self): self._ids = {} - def connect(self, element, event, *args): + def connect(self, element, event, func, *args): + """Connect a function + args to signal event on an element. + + Each event may only be handled by one callback in this implementation. + """ assert (element, event) not in self._ids - self._ids[(element, event)] = element.connect(event, *args) + self._ids[(element, event)] = element.connect(event, func, *args) def disconnect(self, element, event): + """Disconnect whatever handler we have for and element+event pair. + + Does nothing it the handler has already been removed. + """ signal_id = self._ids.pop((element, event), None) if signal_id is not None: element.disconnect(signal_id) + def clear(self): + """Clear all registered signal handlers.""" + for element, event in self._ids.keys(): + element.disconnect(self._ids.pop((element, event))) -# TODO: split out mixer as these are too intertwined right now + +# TODO: create a player class which replaces the actors internals class Audio(pykka.ThreadingActor): """ Audio output through `GStreamer `_. From fb8b02cee9d59000db109f70993e0512df6470e8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 17:31:50 +0200 Subject: [PATCH 033/495] audio: Split out appsrc handling --- mopidy/audio/actor.py | 128 ++++++++++++++++++++++++------------------ 1 file changed, 72 insertions(+), 56 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 24b34c81..879a46c8 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -73,6 +73,68 @@ class _Signals(object): element.disconnect(self._ids.pop((element, event))) +class _Appsrc(object): + """Helper class for dealing with appsrc based playback.""" + def __init__(self): + self._signals = _Signals() + self.reset() + + def reset(self): + """Reset the helper. + + Should be called whenever the source changes and we are not setting up + a new appsrc. + """ + self.prepare(None, None, None, None) + + def prepare(self, caps, need_data, enough_data, seek_data): + """Store info we will need when the appsrc element gets installed.""" + self._signals.clear() + self._source = None + self._caps = caps + self._need_data_callback = need_data + self._seek_data_callback = seek_data + self._enough_data_callback = enough_data + + def configure(self, source): + """Configure the supplied source for use. + + Should be called whenever we get a new appsrc. + """ + source.set_property('caps', self._caps) + source.set_property('format', b'time') + source.set_property('stream-type', b'seekable') + source.set_property('max-bytes', 1 * MB) + source.set_property('min-percent', 50) + + if self._need_data_callback: + self._signals.connect(source, 'need-data', self._on_signal, + self._need_data_callback) + if self._seek_data_callback: + self._signals.connect(source, 'seek-data', self._on_signal, + self._seek_data_callback) + if self._enough_data_callback: + self._signals.connect(source, 'enough-data', self._on_signal, None, + self._enough_data_callback) + + self._source = source + + def push(self, buffer_): + return self._source.emit('push-buffer', buffer_) == gst.FLOW_OK + + def end_of_stream(self): + self._source.emit('end-of-stream') + + def _on_signal(self, element, clocktime, func): + # This shim is used to ensure we always return true, and also handles + # that not all the callbacks have a time argument. + if clocktime is None: + func() + else: + func(utils.clocktime_to_millisecond(clocktime)) + return True + + # TODO: create a player class which replaces the actors internals class Audio(pykka.ThreadingActor): """ @@ -91,14 +153,10 @@ class Audio(pykka.ThreadingActor): self._buffering = False self._playbin = None - self._signals = _Signals() self._about_to_finish_callback = None - self._appsrc = None - self._appsrc_caps = None - self._appsrc_need_data_callback = None - self._appsrc_enough_data_callback = None - self._appsrc_seek_data_callback = None + self._appsrc = _Appsrc() + self._signals = _Signals() def on_start(self): try: @@ -141,35 +199,17 @@ class Audio(pykka.ThreadingActor): self._playbin = playbin def _on_about_to_finish(self, element): - source, self._appsrc = self._appsrc, None - if source is not None: - self._appsrc_caps = None - self._signals.disconnect(source, 'need-data') - self._signals.disconnect(source, 'enough-data') - self._signals.disconnect(source, 'seek-data') - if self._about_to_finish_callback: logger.debug('Calling about to finish callback.') self._about_to_finish_callback() def _on_new_source(self, element, pad): - uri = element.get_property('uri') - if not uri or not uri.startswith('appsrc://'): - return - source = element.get_property('source') - source.set_property('caps', self._appsrc_caps) - source.set_property('format', b'time') - source.set_property('stream-type', b'seekable') - source.set_property('max-bytes', 1 * MB) - source.set_property('min-percent', 50) - self._signals.connect(source, 'need-data', self._appsrc_on_need_data) - self._signals.connect(source, 'seek-data', self._appsrc_on_seek_data) - self._signals.connect( - source, 'enough-data', self._appsrc_on_enough_data) - - self._appsrc = source + if source.get_factory().get_name() == 'appsrc': + self._appsrc.configure(source) + else: + self._appsrc.reset() def _on_source_setup(self, element, source): scheme = 'http' @@ -187,23 +227,6 @@ class Audio(pykka.ThreadingActor): source.set_property('proxy-id', self._config['proxy']['username']) source.set_property('proxy-pw', self._config['proxy']['password']) - def _appsrc_on_need_data(self, appsrc, gst_length_hint): - length_hint = utils.clocktime_to_millisecond(gst_length_hint) - if self._appsrc_need_data_callback is not None: - self._appsrc_need_data_callback(length_hint) - return True - - def _appsrc_on_enough_data(self, appsrc): - if self._appsrc_enough_data_callback is not None: - self._appsrc_enough_data_callback() - return True - - def _appsrc_on_seek_data(self, appsrc, gst_position): - position = utils.clocktime_to_millisecond(gst_position) - if self._appsrc_seek_data_callback is not None: - self._appsrc_seek_data_callback(position) - return True - def _teardown_playbin(self): self._signals.disconnect(self._playbin, 'about-to-finish') self._signals.disconnect(self._playbin, 'notify::source') @@ -407,12 +430,8 @@ class Audio(pykka.ThreadingActor): to continue playback :type seek_data: callable which takes time position in ms """ - if isinstance(caps, unicode): - caps = caps.encode('utf-8') - self._appsrc_caps = gst.Caps(caps) - self._appsrc_need_data_callback = need_data - self._appsrc_enough_data_callback = enough_data - self._appsrc_seek_data_callback = seek_data + self._appsrc.prepare( + gst.Caps(bytes(caps)), need_data, enough_data, seek_data) self._playbin.set_property('uri', 'appsrc://') def emit_data(self, buffer_): @@ -427,9 +446,7 @@ class Audio(pykka.ThreadingActor): :type buffer_: :class:`gst.Buffer` :rtype: boolean """ - if not self._appsrc: - return False - return self._appsrc.emit('push-buffer', buffer_) == gst.FLOW_OK + return self._appsrc.push(buffer_) def emit_end_of_stream(self): """ @@ -439,8 +456,7 @@ class Audio(pykka.ThreadingActor): We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. """ - # TODO: replace this with emit_data(None)? - self._playbin.get_property('source').emit('end-of-stream') + return self._appsrc.end_of_stream() def set_about_to_finish_callback(self, callback): """ From 88788fddfd7faff94e5a2b4ba2b3bbbb5aed3acf Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 18:39:10 +0200 Subject: [PATCH 034/495] audio: Use pbutils to provide usable plugin missing info --- mopidy/audio/actor.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 879a46c8..1d86b917 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -7,6 +7,7 @@ import gobject import pygst pygst.require('0.10') import gst # noqa +import gst.pbutils import pykka @@ -341,6 +342,8 @@ class Audio(pykka.ThreadingActor): elif msg.type == gst.MESSAGE_ELEMENT: if msg.structure.has_name('playbin2-stream-changed'): self._on_stream_changed(msg.structure['uri']) + elif gst.pbutils.is_missing_plugin_message(msg): + self._on_missing_plugin(msg) def _on_playbin_state_changed(self, old_state, new_state, pending_state): if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: @@ -401,6 +404,17 @@ class Audio(pykka.ThreadingActor): logger.debug('Triggering event: stream_changed(uri=%s)', uri) AudioListener.send('stream_changed', uri=uri) + def _on_missing_plugin(self, msg): + desc = gst.pbutils.missing_plugin_message_get_description(msg) + debug = gst.pbutils.missing_plugin_message_get_installer_detail(msg) + logger.warning('Could not find a %s to handle media.', desc) + if gst.pbutils.install_plugins_supported(): + logger.info('You might be able to fix this by running: ' + 'gst-installer "%s"', debug) + # TODO: store the missing plugins installer info in a file so we can + # can provide a 'mopidy install-missing-plugins' if the system has the + # required helper installed? + def set_uri(self, uri): """ Set URI of audio to be played. From b9879ef81e2005ad96e19193b12fbc439a2efe78 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 20:01:42 +0200 Subject: [PATCH 035/495] audio: Improve GStreamer logging This adds an extra mopidy.audio.gst logger and moves the GStreamer logging to it. Additionally this adds more logging so we can likely get by with just mopidy logs in more cases. --- mopidy/audio/actor.py | 80 +++++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 1d86b917..867382c5 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -19,6 +19,11 @@ from mopidy.utils import process logger = logging.getLogger(__name__) +# This logger is only meant for debug logging of low level gstreamer info such +# as callbacks, event, messages and direct interaction with GStreamer such as +# set_state on a pipeline. +gst_logger = logging.getLogger('mopidy.audio.gst') + playlists.register_typefinders() playlists.register_elements() @@ -200,12 +205,13 @@ class Audio(pykka.ThreadingActor): self._playbin = playbin def _on_about_to_finish(self, element): + gst_logger.debug('Got about-to-finish event.') if self._about_to_finish_callback: - logger.debug('Calling about to finish callback.') self._about_to_finish_callback() def _on_new_source(self, element, pad): source = element.get_property('source') + gst_logger.debug('Got notify::source event: element=%s', source) if source.get_factory().get_name() == 'appsrc': self._appsrc.configure(source) @@ -321,11 +327,14 @@ class Audio(pykka.ThreadingActor): def _on_pad_event(self, pad, event): if event.type == gst.EVENT_NEWSEGMENT: - # update, rate, format, start, stop, position - position = event.parse_new_segment()[5] // gst.MSECOND - logger.debug('Triggering event: position_changed(position=%s)', - position) - AudioListener.send('position_changed', position=position) + update, rate, format_, start, stop, pos = event.parse_new_segment() + gst_logger.debug('Got new-segment event: update=%s rate=%s ' + 'format=%s start=%s stop=%s position=%s', update, + rate, format_.value_name, start, stop, pos) + pos_ms = pos // gst.MSECOND + logger.debug('Triggering: position_changed(position=%s)', pos_ms) + AudioListener.send('position_changed', position=pos_ms) + return True def _on_message(self, bus, msg): @@ -339,6 +348,8 @@ class Audio(pykka.ThreadingActor): self._on_error(*msg.parse_error()) elif msg.type == gst.MESSAGE_WARNING: self._on_warning(*msg.parse_warning()) + elif msg.type == gst.MESSAGE_ASYNC_DONE: + gst_logger.debug('Got async-done message.') elif msg.type == gst.MESSAGE_ELEMENT: if msg.structure.has_name('playbin2-stream-changed'): self._on_stream_changed(msg.structure['uri']) @@ -346,6 +357,10 @@ class Audio(pykka.ThreadingActor): self._on_missing_plugin(msg) def _on_playbin_state_changed(self, old_state, new_state, pending_state): + gst_logger.debug('Got state-changed message: old=%s new=%s pending=%s', + old_state.value_name, new_state.value_name, + pending_state.value_name) + if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: # XXX: We're not called on the last state change when going down to # NULL, so we rewrite the second to last call to get the expected @@ -366,15 +381,17 @@ class Audio(pykka.ThreadingActor): if target_state == new_state: target_state = None - logger.debug( - 'Triggering event: state_changed(old_state=%s, new_state=%s, ' - 'target_state=%s)', old_state, new_state, target_state) + logger.debug('Triggering: state_changed(old_state=%s, new_state=%s, ' + 'target_state=%s)', old_state, new_state, target_state) AudioListener.send('state_changed', old_state=old_state, new_state=new_state, target_state=target_state) if new_state == PlaybackState.STOPPED: + logger.debug('Triggering: stream_changed(uri=None)') AudioListener.send('stream_changed', uri=None) def _on_buffering(self, percent): + gst_logger.debug('Got buffering message: percent=%d%%', percent) + if percent < 10 and not self._buffering: self._playbin.set_state(gst.STATE_PAUSED) self._buffering = True @@ -383,30 +400,33 @@ class Audio(pykka.ThreadingActor): if self._target_state == gst.STATE_PLAYING: self._playbin.set_state(gst.STATE_PLAYING) - logger.debug('Buffer %d%% full', percent) - def _on_end_of_stream(self): - logger.debug('Audio event: reached_end_of_stream') + gst_logger.debug('Got end-of-stream message.') + logger.debug('Triggering: reached_end_of_stream()') AudioListener.send('reached_end_of_stream') def _on_error(self, error, debug): - logger.error( - '%s Debug message: %s', - str(error).decode('utf-8'), debug.decode('utf-8') or 'None') + gst_logger.error(str(error).decode('utf-8')) + if debug: + gst_logger.debug(debug.decode('utf-8')) + # TODO: is this needed? self.stop_playback() def _on_warning(self, error, debug): - logger.warning( - '%s Debug message: %s', - str(error).decode('utf-8'), debug.decode('utf-8') or 'None') + gst_logger.warning(str(error).decode('utf-8')) + if debug: + gst_logger.debug(debug.decode('utf-8')) def _on_stream_changed(self, uri): - logger.debug('Triggering event: stream_changed(uri=%s)', uri) + gst_logger.debug('Got stream-changed message: uri:%s', uri) + logger.debug('Triggering: stream_changed(uri=%s)', uri) AudioListener.send('stream_changed', uri=uri) def _on_missing_plugin(self, msg): desc = gst.pbutils.missing_plugin_message_get_description(msg) debug = gst.pbutils.missing_plugin_message_get_installer_detail(msg) + + gst_logger.debug('Got missing-plugin message: description:%s', desc) logger.warning('Could not find a %s to handle media.', desc) if gst.pbutils.install_plugins_supported(): logger.info('You might be able to fix this by running: ' @@ -470,7 +490,8 @@ class Audio(pykka.ThreadingActor): We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. """ - return self._appsrc.end_of_stream() + self._appsrc.end_of_stream() + gst_logger.debug('Sent appsrc end-of-stream event.') def set_about_to_finish_callback(self, callback): """ @@ -507,8 +528,10 @@ class Audio(pykka.ThreadingActor): :rtype: :class:`True` if successful, else :class:`False` """ gst_position = utils.millisecond_to_clocktime(position) - return self._playbin.seek_simple( + result = self._playbin.seek_simple( gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, gst_position) + gst_logger.debug('Sent flushing seek: position=%s', gst_position) + return result def start_playback(self): """ @@ -588,18 +611,16 @@ class Audio(pykka.ThreadingActor): """ self._target_state = state result = self._playbin.set_state(state) + gst_logger.debug('State change to %s: result=%s', state.value_name, + result.value_name) + if result == gst.STATE_CHANGE_FAILURE: logger.warning( 'Setting GStreamer state to %s failed', state.value_name) return False - elif result == gst.STATE_CHANGE_ASYNC: - logger.debug( - 'Setting GStreamer state to %s is async', state.value_name) - return True - else: - logger.debug( - 'Setting GStreamer state to %s is OK', state.value_name) - return True + # TODO: at this point we could already emit stopped event instead + # of faking it in the message handling when result=OK + return True def get_volume(self): """ @@ -678,3 +699,4 @@ class Audio(pykka.ThreadingActor): event = gst.event_new_tag(taglist) self._playbin.send_event(event) + gst_logger.debug('Sent tag event: track=%s', track.uri) From d650c0ba140c04c30affaedcf3a80cb4d651a31d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 21:46:37 +0200 Subject: [PATCH 036/495] audio: Split out ouput handling from audio actor This also lays some basic ground work for handling multiple outputs. --- mopidy/audio/actor.py | 89 ++++++++++++++++++++++++++-------------- mopidy/exceptions.py | 4 ++ tests/test_exceptions.py | 4 ++ 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 867382c5..97660f1d 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -11,6 +11,7 @@ import gst.pbutils import pykka +from mopidy import exceptions from mopidy.audio import playlists, utils from mopidy.audio.constants import PlaybackState from mopidy.audio.listener import AudioListener @@ -141,6 +142,56 @@ class _Appsrc(object): return True +class _Outputs(gst.Bin): + def __init__(self): + gst.Bin.__init__(self) + + self._tee = gst.element_factory_make('tee') + self.add(self._tee) + + # Queue element to buy us time between the about to finish event and + # the actual switch, i.e. about to switch can block for longer thanks + # to this queue. + # TODO: make the min-max values a setting? + # TODO: move out of this class? + queue = gst.element_factory_make('queue') + queue.set_property('max-size-buffers', 0) + queue.set_property('max-size-bytes', 0) + queue.set_property('max-size-time', 5 * gst.SECOND) + queue.set_property('min-threshold-time', 3 * gst.SECOND) + self.add(queue) + + queue.link(self._tee) + + ghost_pad = gst.GhostPad('sink', queue.get_pad('sink')) + self.add_pad(ghost_pad) + + # Add an always connected fakesink so the tee doesn't fail. + # XXX disabled for now as we get one stream changed per sink... + # self._add(gst.element_factory_make('fakesink')) + + def add_output(self, description): + # XXX This only works for pipelines not in use until #790 gets done. + try: + output = gst.parse_bin_from_description( + description, ghost_unconnected_pads=True) + except gobject.GError as ex: + logger.error( + 'Failed to create audio output "%s": %s', description, ex) + raise exceptions.AudioException(bytes(ex)) + + self._add(output) + logger.info('Audio output set to "%s"', description) + + def _add(self, element): + # All tee branches need a queue in front of them. + queue = gst.element_factory_make('queue') + self.add(element) + self.add(queue) + queue.link(element) + self._tee.link(queue) + + # TODO: create a player class which replaces the actors internals class Audio(pykka.ThreadingActor): """ @@ -159,6 +210,7 @@ class Audio(pykka.ThreadingActor): self._buffering = False self._playbin = None + self._outputs = None self._about_to_finish_callback = None self._appsrc = _Appsrc() @@ -241,38 +293,13 @@ class Audio(pykka.ThreadingActor): self._playbin.set_state(gst.STATE_NULL) def _setup_output(self): - output_desc = self._config['audio']['output'] + self._outputs = _Outputs() + self._outputs.get_pad('sink').add_event_probe(self._on_pad_event) try: - user_output = gst.parse_bin_from_description( - output_desc, ghost_unconnected_pads=True) - except gobject.GError as ex: - logger.error( - 'Failed to create audio output "%s": %s', output_desc, ex) - process.exit_process() - - output = gst.Bin('output') - - # Queue element to buy us time between the about to finish event and - # the actual switch, i.e. about to switch can block for longer thanks - # to this queue. - # TODO: make the min-max values a setting? - queue = gst.element_factory_make('queue') - queue.set_property('max-size-buffers', 0) - queue.set_property('max-size-bytes', 0) - queue.set_property('max-size-time', 5 * gst.SECOND) - queue.set_property('min-threshold-time', 3 * gst.SECOND) - - queue.get_pad('src').add_event_probe(self._on_pad_event) - - output.add(user_output) - output.add(queue) - - queue.link(user_output) - ghost_pad = gst.GhostPad('sink', queue.get_pad('sink')) - output.add_pad(ghost_pad) - - logger.info('Audio output set to "%s"', output_desc) - self._playbin.set_property('audio-sink', output) + self._outputs.add_output(self._config['audio']['output']) + except exceptions.AudioException: + process.exit_process() # TODO: move this up the chain + self._playbin.set_property('audio-sink', self._outputs) def _setup_mixer(self): if self._config['audio']['mixer'] != 'software': diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index bf9b6dd9..532f6853 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -34,3 +34,7 @@ class MixerError(MopidyException): class ScannerError(MopidyException): pass + + +class AudioException(MopidyException): + pass diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 47b3080d..19127aaa 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -31,3 +31,7 @@ class ExceptionsTest(unittest.TestCase): def test_scanner_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.ScannerError, exceptions.MopidyException)) + + def test_audio_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.AudioException, exceptions.MopidyException)) From 72ca1a74c388c70b4063603f689870eab8a21f69 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 22:16:48 +0200 Subject: [PATCH 037/495] audio: Unify source handlers notify::source and setup-source are the same, just that setup-source is a convenience wrapper. --- mopidy/audio/actor.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 97660f1d..ecc0bab4 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -248,8 +248,6 @@ class Audio(pykka.ThreadingActor): playbin.set_property('buffer-size', 2*1024*1024) playbin.set_property('buffer-duration', 2*gst.SECOND) - # TODO: on new source and source setup are dupes... - self._signals.connect(playbin, 'notify::source', self._on_new_source) self._signals.connect(playbin, 'source-setup', self._on_source_setup) self._signals.connect( playbin, 'about-to-finish', self._on_about_to_finish) @@ -261,16 +259,14 @@ class Audio(pykka.ThreadingActor): if self._about_to_finish_callback: self._about_to_finish_callback() - def _on_new_source(self, element, pad): - source = element.get_property('source') - gst_logger.debug('Got notify::source event: element=%s', source) + def _on_source_setup(self, element, source): + gst_logger.debug('Got source-setup: element=%s', source) if source.get_factory().get_name() == 'appsrc': self._appsrc.configure(source) else: self._appsrc.reset() - def _on_source_setup(self, element, source): scheme = 'http' hostname = self._config['proxy']['hostname'] port = 80 From 4bfc5e7a8077c5c6753561be0bbd073a1e4e86fb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 22:27:22 +0200 Subject: [PATCH 038/495] audio: Split out proxy setup --- mopidy/audio/actor.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index ecc0bab4..f854ff15 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -192,6 +192,20 @@ class _Outputs(gst.Bin): self._tee.link(queue) +def setup_proxy(element, config): + # TODO: reuse in scanner code + if not config.get('hostname'): + return + + proxy = "%s://%s:%d" % (config.get('scheme', 'http'), + config.get('hostname'), + config.get('port', 80)) + + element.set_property('proxy', proxy) + element.set_property('proxy-id', config.get('username')) + element.set_property('proxy-pw', config.get('password')) + + # TODO: create a player class which replaces the actors internals class Audio(pykka.ThreadingActor): """ @@ -267,20 +281,8 @@ class Audio(pykka.ThreadingActor): else: self._appsrc.reset() - scheme = 'http' - hostname = self._config['proxy']['hostname'] - port = 80 - - if hasattr(source.props, 'proxy') and hostname: - if self._config['proxy']['port']: - port = self._config['proxy']['port'] - if self._config['proxy']['scheme']: - scheme = self._config['proxy']['scheme'] - - proxy = "%s://%s:%d" % (scheme, hostname, port) - source.set_property('proxy', proxy) - source.set_property('proxy-id', self._config['proxy']['username']) - source.set_property('proxy-pw', self._config['proxy']['password']) + if hasattr(source.props, 'proxy'): + setup_proxy(source, self._config['proxy']) def _teardown_playbin(self): self._signals.disconnect(self._playbin, 'about-to-finish') From b8a0ca59cdb0bcbdac734bb242e5b81f952ffac0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 23:46:05 +0200 Subject: [PATCH 039/495] audio: Refactor softwaremixer and audio interactions This rips the mixer bits and pieces that have been hiding in the audio actor to it's own class. The software mixer now only knows about this and nothing else from audio. --- mopidy/audio/actor.py | 127 +++++++++++++++------------------- mopidy/softwaremixer/mixer.py | 50 +++++++------ 2 files changed, 86 insertions(+), 91 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index f854ff15..520ac41c 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -192,6 +192,53 @@ class _Outputs(gst.Bin): self._tee.link(queue) +class SoftwareMixer(object): + pykka_traversable = True + + def __init__(self, mixer): + self._mixer = mixer + self._element = None + self._last_volume = None + self._last_mute = None + self._signals = _Signals() + + def setup(self, element, mixer_ref): + self._element = element + + self._signals.connect(element, 'notify::volume', self._volume_changed) + self._signals.connect(element, 'notify::mute', self._mute_changed) + + self._mixer.setup(mixer_ref) + + def teardown(self): + self._signals.clear() + self._mixer.teardown() + + def get_volume(self): + return int(round(self._element.get_property('volume') * 100)) + + def set_volume(self, volume): + self._element.set_property('volume', volume / 100.0) + + def get_mute(self): + return self._element.get_property('mute') + + def set_mute(self, mute): + return self._element.set_property('mute', bool(mute)) + + def _volume_changed(self, element, property_): + old_volume, self._last_volume = self._last_volume, self.get_volume() + if old_volume != self._last_volume: + gst_logger.debug('Notify volume: %s', self._last_volume / 100.0) + self._mixer.trigger_volume_changed(self._last_volume) + + def _mute_changed(self, element, property_): + old_mute, self._last_mute = self._last_mute, self.get_mute() + if old_mute != self._last_mute: + gst_logger.debug('Notify mute: %s', self._last_mute) + self._mixer.trigger_mute_changed(self._last_mute) + + def setup_proxy(element, config): # TODO: reuse in scanner code if not config.get('hostname'): @@ -215,11 +262,13 @@ class Audio(pykka.ThreadingActor): #: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState` state = PlaybackState.STOPPED + #: The software mixing interface :class:`mopidy.audio.actor.SoftwareMixer` + mixer = None + def __init__(self, config, mixer): super(Audio, self).__init__() self._config = config - self._mixer = mixer self._target_state = gst.STATE_NULL self._buffering = False @@ -230,6 +279,9 @@ class Audio(pykka.ThreadingActor): self._appsrc = _Appsrc() self._signals = _Signals() + if mixer and self._config['audio']['mixer'] == 'software': + self.mixer = SoftwareMixer(mixer) + def on_start(self): try: self._setup_preferences() @@ -300,31 +352,12 @@ class Audio(pykka.ThreadingActor): self._playbin.set_property('audio-sink', self._outputs) def _setup_mixer(self): - if self._config['audio']['mixer'] != 'software': - return - self._mixer.audio = self.actor_ref.proxy() - self._signals.connect( - self._playbin, 'notify::volume', self._on_mixer_change) - self._signals.connect( - self._playbin, 'notify::mute', self._on_mixer_change) - - # The Mopidy startup procedure will set the initial volume of a mixer, - # but this happens before the audio actor is injected into the software - # mixer and has no effect. Thus, we need to set the initial volume - # again. - initial_volume = self._config['audio']['mixer_volume'] - if initial_volume is not None: - self._mixer.set_volume(initial_volume) - - def _on_mixer_change(self, element, gparamspec): - self._mixer.trigger_events_for_changed_values() + if self.mixer: + self.mixer.setup(self._playbin, self.actor_ref.proxy().mixer) def _teardown_mixer(self): - if self._config['audio']['mixer'] != 'software': - return - self._signals.disconnect(self._playbin, 'notify::volume') - self._signals.disconnect(self._playbin, 'notify::mute') - self._mixer.audio = None + if self.mixer: + self.mixer.teardown() def _setup_visualizer(self): visualizer_element = self._config['audio']['visualizer'] @@ -647,52 +680,6 @@ class Audio(pykka.ThreadingActor): # of faking it in the message handling when result=OK return True - def get_volume(self): - """ - Get volume level of the software mixer. - - Example values: - - 0: - Minimum volume. - 100: - Maximum volume. - - :rtype: int in range [0..100] - """ - return int(round(self._playbin.get_property('volume') * 100)) - - def set_volume(self, volume): - """ - Set volume level of the software mixer. - - :param volume: the volume in the range [0..100] - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - self._playbin.set_property('volume', volume / 100.0) - return True - - def get_mute(self): - """ - Get mute status of the software mixer. - - :rtype: :class:`True` if muted, :class:`False` if unmuted, - :class:`None` if no mixer is installed. - """ - return self._playbin.get_property('mute') - - def set_mute(self, mute): - """ - Mute or unmute of the software mixer. - - :param mute: Whether to mute the mixer or not. - :type mute: bool - :rtype: :class:`True` if successful, else :class:`False` - """ - self._playbin.set_property('mute', bool(mute)) - return True - def set_metadata(self, track): """ Set track metadata for currently playing song. diff --git a/mopidy/softwaremixer/mixer.py b/mopidy/softwaremixer/mixer.py index 0ebbfeb7..71d178f5 100644 --- a/mopidy/softwaremixer/mixer.py +++ b/mopidy/softwaremixer/mixer.py @@ -17,40 +17,48 @@ class SoftwareMixer(pykka.ThreadingActor, mixer.Mixer): def __init__(self, config): super(SoftwareMixer, self).__init__(config) - self.audio = None - self._last_volume = None - self._last_mute = None + self._audio_mixer = None + self._initial_volume = None + self._initial_mute = None + # TODO: shouldn't this be logged by thing that choose us? logger.info('Mixing using GStreamer software mixing') + def setup(self, mixer_ref): + self._audio_mixer = mixer_ref + + # The Mopidy startup procedure will set the initial volume of a + # mixer, but this happens before the audio actor is injected into the + # software mixer and has no effect. Thus, we need to set the initial + # volume again. + if self._initial_volume is not None: + self.set_volume(self._initial_volume) + if self._initial_mute is not None: + self.set_mute(self._initial_mute) + + def teardown(self): + self._audio_mixer = None + def get_volume(self): - if self.audio is None: + if self._audio_mixer is None: return None - return self.audio.get_volume().get() + return self._audio_mixer.get_volume().get() def set_volume(self, volume): - if self.audio is None: + if self._audio_mixer is None: + self._initial_volume = volume return False - self.audio.set_volume(volume) + self._audio_mixer.set_volume(volume) return True def get_mute(self): - if self.audio is None: + if self._audio_mixer is None: return None - return self.audio.get_mute().get() + return self._audio_mixer.get_mute().get() def set_mute(self, mute): - if self.audio is None: + if self._audio_mixer is None: + self._initial_mute = mute return False - self.audio.set_mute(mute) + self._audio_mixer.set_mute(mute) return True - - def trigger_events_for_changed_values(self): - old_volume, self._last_volume = self._last_volume, self.get_volume() - old_mute, self._last_mute = self._last_mute, self.get_mute() - - if old_volume != self._last_volume: - self.trigger_volume_changed(self._last_volume) - - if old_mute != self._last_mute: - self.trigger_mute_changed(self._last_mute) From 6b88b4f68539954974f02888403f29e650a43360 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 23:54:56 +0200 Subject: [PATCH 040/495] audio: Group playbin teardown/setup This makes it possible to see that we setup and teardown the same things. Also fixes disconnect on a signal we no longer listen to. --- mopidy/audio/actor.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 520ac41c..144a0b78 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -320,6 +320,11 @@ class Audio(pykka.ThreadingActor): self._playbin = playbin + def _teardown_playbin(self): + self._signals.disconnect(self._playbin, 'about-to-finish') + self._signals.disconnect(self._playbin, 'source-setup') + self._playbin.set_state(gst.STATE_NULL) + def _on_about_to_finish(self, element): gst_logger.debug('Got about-to-finish event.') if self._about_to_finish_callback: @@ -336,12 +341,6 @@ class Audio(pykka.ThreadingActor): if hasattr(source.props, 'proxy'): setup_proxy(source, self._config['proxy']) - def _teardown_playbin(self): - self._signals.disconnect(self._playbin, 'about-to-finish') - self._signals.disconnect(self._playbin, 'notify::source') - self._signals.disconnect(self._playbin, 'source-setup') - self._playbin.set_state(gst.STATE_NULL) - def _setup_output(self): self._outputs = _Outputs() self._outputs.get_pad('sink').add_event_probe(self._on_pad_event) From fd9100a5f330db4d98d32abe4d7dbb62330e21f2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 3 Aug 2014 23:56:07 +0200 Subject: [PATCH 041/495] audio: Annotate code with more TODOs and questions --- mopidy/audio/actor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 144a0b78..fb8a0306 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -80,6 +80,7 @@ class _Signals(object): element.disconnect(self._ids.pop((element, event))) +# TODO: expose this as a property on audio? class _Appsrc(object): """Helper class for dealing with appsrc based playback.""" def __init__(self): @@ -142,6 +143,7 @@ class _Appsrc(object): return True +# TODO: expose this as a property on audio when #790 gets further along. class _Outputs(gst.Bin): def __init__(self): gst.Bin.__init__(self) @@ -300,6 +302,7 @@ class Audio(pykka.ThreadingActor): self._teardown_playbin() def _setup_preferences(self): + # TODO: move out of audio actor? # Fix for https://github.com/mopidy/mopidy/issues/604 registry = gst.registry_get_default() jacksink = registry.find_feature( @@ -311,6 +314,7 @@ class Audio(pykka.ThreadingActor): playbin = gst.element_factory_make('playbin2') playbin.set_property('flags', PLAYBIN_FLAGS) + # TODO: turn into config values... playbin.set_property('buffer-size', 2*1024*1024) playbin.set_property('buffer-duration', 2*gst.SECOND) @@ -359,6 +363,7 @@ class Audio(pykka.ThreadingActor): self.mixer.teardown() def _setup_visualizer(self): + # TODO: kill visualizer_element = self._config['audio']['visualizer'] if not visualizer_element: return @@ -394,6 +399,7 @@ class Audio(pykka.ThreadingActor): return True + # TODO: consider splitting this out while we are at it. def _on_message(self, bus, msg): if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._playbin: self._on_playbin_state_changed(*msg.parse_state_changed()) @@ -573,6 +579,8 @@ class Audio(pykka.ThreadingActor): gst_position = self._playbin.query_position(gst.FORMAT_TIME)[0] return utils.clocktime_to_millisecond(gst_position) except gst.QueryError: + # TODO: take state into account for this and possibly also return + # None as the unknown value instead of zero? logger.debug('Position query failed') return 0 @@ -584,6 +592,7 @@ class Audio(pykka.ThreadingActor): :type position: int :rtype: :class:`True` if successful, else :class:`False` """ + # TODO: double check seek flags in use. gst_position = utils.millisecond_to_clocktime(position) result = self._playbin.seek_simple( gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, gst_position) From 9771eda23068f4581a30059ac597f1360d349dcd Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 5 Aug 2014 18:34:08 +0200 Subject: [PATCH 042/495] mpd: Test that "list album foo" responds correctly Previously, test_list_album_with_artist_name would only test that the command didn't fail. Now it also checks that the response is correct. That is, that the response contains albums. This makes the test detect the error reported in #817. --- tests/mpd/protocol/test_music_db.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 55ab75c5..e6712fef 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -880,7 +880,11 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_album_with_artist_name(self): + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(album=Album(name='foo'))]) + self.sendRequest('list "album" "anartist"') + self.assertInResponse('Album: foo') self.assertInResponse('OK') def test_list_album_with_artist_name_without_filter_value(self): From ec413126f1670ccf4f5853e6ee375239cdb374e6 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 5 Aug 2014 18:46:16 +0200 Subject: [PATCH 043/495] mpd: Fix list commands with 3 arguments (fixes #817) List commands with 3 arguments should return albums, not artists. --- mopidy/mpd/protocol/music_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index acddf1f4..a5757915 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -257,7 +257,7 @@ def list_(context, *args): if len(parameters) == 1: if field != 'album': raise exceptions.MpdArgError('should be "Album" for 3 arguments') - return _list_artist(context, {'artist': parameters}) + return _list_album(context, {'artist': parameters}) try: query = _query_from_mpd_search_parameters(parameters, _LIST_MAPPING) From 92fa75325d968bb271b8601447080c29bccbde5f Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 5 Aug 2014 19:44:50 +0200 Subject: [PATCH 044/495] docs: Add the mpd list command fix to the changelog --- docs/changelog.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2e1c655f..ca45b5ad 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,6 +14,16 @@ v0.20.0 (UNRELEASED) :issue:`697`, PR: :issue:`802`) +v0.19.4 (UNRELEASED) +==================== + +Bug fix release. + + - MPD frontend: Make the ``list`` command return albums when sending 3 + arguments. This was incorrectly returning artists after the MPD command + changes in 0.19.0. (Fixes: :issue:`817`) + + v0.19.3 (2014-08-03) ==================== From 26cfd24e113801b23a00bfb5d6706ee66e643e5b Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 5 Aug 2014 19:50:06 +0200 Subject: [PATCH 045/495] docs: Fix formatting --- docs/changelog.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ca45b5ad..6cfa3682 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,9 +19,9 @@ v0.19.4 (UNRELEASED) Bug fix release. - - MPD frontend: Make the ``list`` command return albums when sending 3 - arguments. This was incorrectly returning artists after the MPD command - changes in 0.19.0. (Fixes: :issue:`817`) +- MPD frontend: Make the ``list`` command return albums when sending 3 + arguments. This was incorrectly returning artists after the MPD command + changes in 0.19.0. (Fixes: :issue:`817`) v0.19.3 (2014-08-03) From 4c60c6b68c30d07fcc2958fc38407434928a379a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 5 Aug 2014 23:58:03 +0200 Subject: [PATCH 046/495] docs: Add note Debian package distro support (fixes #820) (cherry picked from commit 9b7bcd37b3d00134d22382b2a75881cb55cf7d37) --- docs/installation/debian.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index 26864986..f34eb255 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -14,6 +14,22 @@ instructions should work for you as well. If you're setting up a Raspberry Pi from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See :ref:`raspberrypi-installation`. +.. note:: + + The packages should work with: + + - Debian stable and testing, + - Raspbian stable and testing, + - Ubuntu 14.04 LTS and later. + + Some of the packages, including the core "mopidy" packages, does *not* work + on Ubuntu 12.04 LTS. + + This is just what we currently support, not a promise to continue to + support the same in the future. We *will* drop support for older + distributions when supporting those stops us from moving forward with the + project. + #. Add the archive's GPG key:: wget -q -O - https://apt.mopidy.com/mopidy.gpg | sudo apt-key add - From 06d7d650f870dfe55e02bc091f6f344df2762ef9 Mon Sep 17 00:00:00 2001 From: schinken Date: Wed, 6 Aug 2014 14:06:44 +0200 Subject: [PATCH 047/495] #818 Remove mopidy lux from documentation --- docs/clients/http.rst | 3 --- docs/ext/lux.png | Bin 359606 -> 0 bytes docs/ext/lux.rst | 10 ---------- docs/ext/web.rst | 3 --- 4 files changed, 16 deletions(-) delete mode 100644 docs/ext/lux.png delete mode 100644 docs/ext/lux.rst diff --git a/docs/clients/http.rst b/docs/clients/http.rst index bbd4d888..bd7a39b0 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -38,9 +38,6 @@ Also the web client used for Wouter's popular `Pi Musicbox -- https://github.com/woutervanwijk/Mopidy-MusicBox-Webclient -.. include:: /ext/lux.rst - - .. include:: /ext/moped.rst diff --git a/docs/ext/lux.png b/docs/ext/lux.png deleted file mode 100644 index ce496c163fae3cc939260c37ba9a1a5500bd26b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 359606 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

ad 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%0AS1pnU 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 A(i4W_pE1hHs< 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 KGHFr<9{&sGT2@&A diff --git a/docs/ext/lux.rst b/docs/ext/lux.rst deleted file mode 100644 index cdcc2d19..00000000 --- a/docs/ext/lux.rst +++ /dev/null @@ -1,10 +0,0 @@ -Mopidy-Lux -========== - -https://github.com/dz0ny/mopidy-lux - -A Mopidy web client made with AngularJS by Janez Troha. - -.. image:: /ext/lux.png - :width: 1275 - :height: 795 diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 57e2e1af..7df054cd 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -15,7 +15,4 @@ to show up here, follow the :ref:`guide on creating extensions `. .. include:: /ext/api_explorer.rst -.. include:: /ext/lux.rst - - .. include:: /ext/moped.rst From e1fda353517fd660e81e7f0b2faa7fccab6f97aa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 7 Aug 2014 11:36:25 +0200 Subject: [PATCH 048/495] config: Only log existing config files --- mopidy/config/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 3b63a1ab..ed8446dd 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -104,20 +104,24 @@ def format_initial(extensions): def _load(files, defaults, overrides): parser = configparser.RawConfigParser() - files = [path.expand_path(f) for f in files] - sources = ['builtin defaults'] + files + ['command line options'] - logger.info('Loading config from: %s', ', '.join(sources)) - # TODO: simply return path to config file for defaults so we can load it # all in the same way? + logger.info('Loading config from builtin defaults') for default in defaults: if isinstance(default, unicode): default = default.encode('utf-8') parser.readfp(io.BytesIO(default)) # Load config from a series of config files + files = [path.expand_path(f) for f in files] for filename in files: + if not os.path.exists(filename): + logger.debug( + 'Loading config from %s failed; it does not exist', filename) + continue + try: + logger.info('Loading config from %s', filename) with io.open(filename, 'rb') as filehandle: parser.readfp(filehandle) except configparser.MissingSectionHeaderError as e: @@ -140,6 +144,7 @@ def _load(files, defaults, overrides): for section in parser.sections(): raw_config[section] = dict(parser.items(section)) + logger.info('Loading config from command line options') for section, key, value in overrides: raw_config.setdefault(section, {})[key] = value From cb0387c46d7c65d141355ce716764f7b05ef3f3f Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 11 Aug 2014 01:31:25 +0200 Subject: [PATCH 049/495] log: Don't disable loggers when loading fileConfig The default when loading config for logging from a file is to disable existing loggers. Since some loggers are created before logging is set up, these loggers were disabled if logging/config_file is set. --- docs/changelog.rst | 4 ++++ mopidy/utils/log.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6cfa3682..386e9423 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,10 @@ v0.20.0 (UNRELEASED) - Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: :issue:`697`, PR: :issue:`802`) +**Logging** + +- Fix that some loggers would be disabled if logging/config_file was set. + v0.19.4 (UNRELEASED) ==================== diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 5d6d3635..c461b434 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -47,7 +47,8 @@ def setup_logging(config, verbosity_level, save_debug_log): if config['logging']['config_file']: # Logging config from file must be read before other handlers are # added. If not, the other handlers will have no effect. - logging.config.fileConfig(config['logging']['config_file']) + logging.config.fileConfig(config['logging']['config_file'], + disable_existing_loggers=False) setup_console_logging(config, verbosity_level) if save_debug_log: From 524043f6b7ee4c3120d6e7e00bad154e4ad82668 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 11 Aug 2014 12:48:56 +0200 Subject: [PATCH 050/495] docs: Use :confval:-syntax, add fixes for logging issue --- docs/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 386e9423..f100d909 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,7 +15,8 @@ v0.20.0 (UNRELEASED) **Logging** -- Fix that some loggers would be disabled if logging/config_file was set. +- Fix that some loggers would be disabled if :confval:`logging/config_file` was + set. (Fixes: :issue:`740`) v0.19.4 (UNRELEASED) From 101b2a9817ff477c188b7851c7121dab87fa7db9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 4 Aug 2014 21:41:25 +0200 Subject: [PATCH 051/495] audio: Make stream-changed correctly handle tee setup For the purposes of this event we consider the outputs sink the final element. If we don't do this we would get one event per branch, and we don't want to track when each of them actually switches any way. So just tracking when the tee/outputs bin gets the event is good enough for us. As part of this I've also added 'testoutput' as a special cased output value. This is now needed as outputs are always synced to the clock, making testing a lot less practical. --- mopidy/audio/actor.py | 37 ++++++++++++++++++++++++------------- tests/audio/test_actor.py | 12 +++++------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index fb8a0306..029913ac 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -155,7 +155,7 @@ class _Outputs(gst.Bin): # the actual switch, i.e. about to switch can block for longer thanks # to this queue. # TODO: make the min-max values a setting? - # TODO: move out of this class? + # TODO: this does not belong in this class. queue = gst.element_factory_make('queue') queue.set_property('max-size-buffers', 0) queue.set_property('max-size-bytes', 0) @@ -168,9 +168,11 @@ class _Outputs(gst.Bin): ghost_pad = gst.GhostPad('sink', queue.get_pad('sink')) self.add_pad(ghost_pad) - # Add an always connected fakesink so the tee doesn't fail. - # XXX disabled for now as we get one stream changed per sink... - # self._add(gst.element_factory_make('fakesink')) + # Add an always connected fakesink which respects the clock so the tee + # doesn't fail even if we don't have any outputs. + fakesink = gst.element_factory_make('fakesink') + fakesink.set_property('sync', True) + self._add(fakesink) def add_output(self, description): # XXX This only works for pipelines not in use until #790 gets done. @@ -346,12 +348,18 @@ class Audio(pykka.ThreadingActor): setup_proxy(source, self._config['proxy']) def _setup_output(self): - self._outputs = _Outputs() + # We don't want to test outputs for regular testing, so just instal + # an unsynced fakesink when someone asks for a testouput. + if self._config['audio']['output'] == 'testoutput': + self._outputs = gst.element_factory_make('fakesink') + else: + self._outputs = _Outputs() + try: + self._outputs.add_output(self._config['audio']['output']) + except exceptions.AudioException: + process.exit_process() # TODO: move this up the chain + self._outputs.get_pad('sink').add_event_probe(self._on_pad_event) - try: - self._outputs.add_output(self._config['audio']['output']) - except exceptions.AudioException: - process.exit_process() # TODO: move this up the chain self._playbin.set_property('audio-sink', self._outputs) def _setup_mixer(self): @@ -396,7 +404,12 @@ class Audio(pykka.ThreadingActor): pos_ms = pos // gst.MSECOND logger.debug('Triggering: position_changed(position=%s)', pos_ms) AudioListener.send('position_changed', position=pos_ms) - + elif event.type == gst.EVENT_SINK_MESSAGE: + # Handle stream changed messages when they reach our output bin. + # If we listen for it on the bus we get one per tee branch. + msg = event.parse_sink_message() + if msg.structure.has_name('playbin2-stream-changed'): + self._on_stream_changed(msg.structure['uri']) return True # TODO: consider splitting this out while we are at it. @@ -414,9 +427,7 @@ class Audio(pykka.ThreadingActor): elif msg.type == gst.MESSAGE_ASYNC_DONE: gst_logger.debug('Got async-done message.') elif msg.type == gst.MESSAGE_ELEMENT: - if msg.structure.has_name('playbin2-stream-changed'): - self._on_stream_changed(msg.structure['uri']) - elif gst.pbutils.is_missing_plugin_message(msg): + if gst.pbutils.is_missing_plugin_message(msg): self._on_missing_plugin(msg) def _on_playbin_state_changed(self, old_state, new_state, pending_state): diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 2426f54e..fc3321d2 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -21,11 +21,9 @@ from mopidy.utils.path import path_to_uri from tests import path_to_data_dir -""" -We want to make sure both our real audio class and the fake one behave -correctly. So each test is first run against the real class, then repeated -against our dummy. -""" +# We want to make sure both our real audio class and the fake one behave +# correctly. So each test is first run against the real class, then repeated +# against our dummy. class BaseTest(unittest.TestCase): @@ -34,7 +32,7 @@ class BaseTest(unittest.TestCase): 'mixer': 'fakemixer track_max_volume=65536', 'mixer_track': None, 'mixer_volume': None, - 'output': 'fakesink', + 'output': 'testoutput', 'visualizer': None, } } @@ -49,7 +47,7 @@ class BaseTest(unittest.TestCase): 'audio': { 'mixer': 'foomixer', 'mixer_volume': None, - 'output': 'fakesink', + 'output': 'testoutput', 'visualizer': None, }, 'proxy': { From ac5bf9af1795e9d8e33a42092340f4341a81815e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 4 Aug 2014 23:51:47 +0200 Subject: [PATCH 052/495] audio: Move most of event handling out of audio. Some of the signal handling still needs to be moved. --- mopidy/audio/actor.py | 313 +++++++++++++++++++++----------------- tests/audio/test_actor.py | 27 ++-- 2 files changed, 185 insertions(+), 155 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 029913ac..8d86858b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -51,6 +51,10 @@ MB = 1 << 20 PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7) PLAYBIN_VIS_FLAGS = PLAYBIN_FLAGS | (1 << 3) +# These are just to long to wrap nicely, so rename them locally. +_get_missing_description = gst.pbutils.missing_plugin_message_get_description +_get_missing_detail = gst.pbutils.missing_plugin_message_get_installer_detail + class _Signals(object): """Helper for tracking gobject signal registrations""" @@ -257,6 +261,153 @@ def setup_proxy(element, config): element.set_property('proxy-pw', config.get('password')) +class _Handler(object): + def __init__(self, audio): + self._audio = audio + self._element = None + self._pad = None + self._message_handler_id = None + self._event_handler_id = None + + def setup_message_handling(self, element): + self._element = element + bus = element.get_bus() + bus.add_signal_watch() + self._message_handler_id = bus.connect('message', self.on_message) + + def setup_event_handling(self, pad): + self._pad = pad + self._event_handler_id = pad.add_event_probe(self.on_event) + + def teardown(self): + bus = self._element.get_bus() + bus.remove_signal_watch() + bus.disconnect(self._message_handler_id) + self._pad.remove_event_probe(self._event_handler_id) + + self._message_handler_id = None + self._event_handler_id = None + + def on_message(self, bus, msg): + if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element: + self.on_playbin_state_changed(*msg.parse_state_changed()) + elif msg.type == gst.MESSAGE_BUFFERING: + self.on_buffering(msg.parse_buffering()) + elif msg.type == gst.MESSAGE_EOS: + self.on_end_of_stream() + elif msg.type == gst.MESSAGE_ERROR: + self.on_error(*msg.parse_error()) + elif msg.type == gst.MESSAGE_WARNING: + self.on_warning(*msg.parse_warning()) + elif msg.type == gst.MESSAGE_ASYNC_DONE: + self.on_async_done() + elif msg.type == gst.MESSAGE_ELEMENT: + if gst.pbutils.is_missing_plugin_message(msg): + self.on_missing_plugin(_get_missing_description(msg), + _get_missing_detail(msg)) + + def on_event(self, pad, event): + if event.type == gst.EVENT_NEWSEGMENT: + self.on_new_segment(*event.parse_new_segment()) + elif event.type == gst.EVENT_SINK_MESSAGE: + # Handle stream changed messages when they reach our output bin. + # If we listen for it on the bus we get one per tee branch. + msg = event.parse_sink_message() + if msg.structure.has_name('playbin2-stream-changed'): + self.on_stream_changed(msg.structure['uri']) + return True + + def on_playbin_state_changed(self, old_state, new_state, pending_state): + gst_logger.debug('Got state-changed message: old=%s new=%s pending=%s', + old_state.value_name, new_state.value_name, + pending_state.value_name) + + if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: + # XXX: We're not called on the last state change when going down to + # NULL, so we rewrite the second to last call to get the expected + # behavior. + new_state = gst.STATE_NULL + pending_state = gst.STATE_VOID_PENDING + + if pending_state != gst.STATE_VOID_PENDING: + return # Ignore intermediate state changes + + if new_state == gst.STATE_READY: + return # Ignore READY state as it's GStreamer specific + + new_state = _GST_STATE_MAPPING[new_state] + old_state, self._audio.state = self._audio.state, new_state + + target_state = _GST_STATE_MAPPING[self._audio._target_state] + if target_state == new_state: + target_state = None + + logger.debug('Triggering: state_changed(old_state=%s, new_state=%s, ' + 'target_state=%s)', old_state, new_state, target_state) + AudioListener.send('state_changed', old_state=old_state, + new_state=new_state, target_state=target_state) + if new_state == PlaybackState.STOPPED: + logger.debug('Triggering: stream_changed(uri=None)') + AudioListener.send('stream_changed', uri=None) + + def on_buffering(self, percent): + gst_logger.debug('Got buffering message: percent=%d%%', percent) + + if percent < 10 and not self._audio._buffering: + self._audio._playbin.set_state(gst.STATE_PAUSED) + self._audio._buffering = True + if percent == 100: + self._audio._buffering = False + if self._audio._target_state == gst.STATE_PLAYING: + self._audio._playbin.set_state(gst.STATE_PLAYING) + + def on_end_of_stream(self): + gst_logger.debug('Got end-of-stream message.') + logger.debug('Triggering: reached_end_of_stream()') + AudioListener.send('reached_end_of_stream') + + def on_error(self, error, debug): + gst_logger.error(str(error).decode('utf-8')) + if debug: + gst_logger.debug(debug.decode('utf-8')) + # TODO: is this needed? + self._audio.stop_playback() + + def on_warning(self, error, debug): + gst_logger.warning(str(error).decode('utf-8')) + if debug: + gst_logger.debug(debug.decode('utf-8')) + + def on_async_done(self): + gst_logger.debug('Got async-done.') + + def on_missing_plugin(self, msg): + desc = gst.pbutils.missing_plugin_message_get_description(msg) + debug = gst.pbutils.missing_plugin_message_get_installer_detail(msg) + + gst_logger.debug('Got missing-plugin message: description:%s', desc) + logger.warning('Could not find a %s to handle media.', desc) + if gst.pbutils.install_plugins_supported(): + logger.info('You might be able to fix this by running: ' + 'gst-installer "%s"', debug) + # TODO: store the missing plugins installer info in a file so we can + # can provide a 'mopidy install-missing-plugins' if the system has the + # required helper installed? + + def on_new_segment(self, update, rate, format_, start, stop, position): + gst_logger.debug('Got new-segment event: update=%s rate=%s format=%s' + 'start=%s stop=%s position=%s', update, rate, + format_.value_name, start, stop, position) + position_ms = position // gst.MSECOND + logger.debug('Triggering: position_changed(position=%s)', position_ms) + AudioListener.send('position_changed', position=position_ms) + + def on_stream_changed(self, uri): + gst_logger.debug('Got stream-changed message: uri:%s', uri) + logger.debug('Triggering: stream_changed(uri=%s)', uri) + AudioListener.send('stream_changed', uri=uri) + + # TODO: create a player class which replaces the actors internals class Audio(pykka.ThreadingActor): """ @@ -280,6 +431,7 @@ class Audio(pykka.ThreadingActor): self._outputs = None self._about_to_finish_callback = None + self._handler = _Handler(self) self._appsrc = _Appsrc() self._signals = _Signals() @@ -293,13 +445,11 @@ class Audio(pykka.ThreadingActor): self._setup_output() self._setup_mixer() self._setup_visualizer() - self._setup_message_processor() except gobject.GError as ex: logger.exception(ex) process.exit_process() def on_stop(self): - self._teardown_message_processor() self._teardown_mixer() self._teardown_playbin() @@ -321,32 +471,18 @@ class Audio(pykka.ThreadingActor): playbin.set_property('buffer-duration', 2*gst.SECOND) self._signals.connect(playbin, 'source-setup', self._on_source_setup) - self._signals.connect( - playbin, 'about-to-finish', self._on_about_to_finish) + self._signals.connect(playbin, 'about-to-finish', + self._on_about_to_finish) self._playbin = playbin + self._handler.setup_message_handling(playbin) def _teardown_playbin(self): + self._handler.teardown() self._signals.disconnect(self._playbin, 'about-to-finish') self._signals.disconnect(self._playbin, 'source-setup') self._playbin.set_state(gst.STATE_NULL) - def _on_about_to_finish(self, element): - gst_logger.debug('Got about-to-finish event.') - if self._about_to_finish_callback: - self._about_to_finish_callback() - - def _on_source_setup(self, element, source): - gst_logger.debug('Got source-setup: element=%s', source) - - if source.get_factory().get_name() == 'appsrc': - self._appsrc.configure(source) - else: - self._appsrc.reset() - - if hasattr(source.props, 'proxy'): - setup_proxy(source, self._config['proxy']) - def _setup_output(self): # We don't want to test outputs for regular testing, so just instal # an unsynced fakesink when someone asks for a testouput. @@ -359,7 +495,7 @@ class Audio(pykka.ThreadingActor): except exceptions.AudioException: process.exit_process() # TODO: move this up the chain - self._outputs.get_pad('sink').add_event_probe(self._on_pad_event) + self._handler.setup_event_handling(self._outputs.get_pad('sink')) self._playbin.set_property('audio-sink', self._outputs) def _setup_mixer(self): @@ -385,129 +521,22 @@ class Audio(pykka.ThreadingActor): 'Failed to create audio visualizer "%s": %s', visualizer_element, ex) - def _setup_message_processor(self): - bus = self._playbin.get_bus() - bus.add_signal_watch() - self._signals.connect(bus, 'message', self._on_message) + def _on_about_to_finish(self, element): + gst_logger.debug('Got about-to-finish event.') + if self._about_to_finish_callback: + logger.debug('Running about to finish callback.') + self._about_to_finish_callback() - def _teardown_message_processor(self): - bus = self._playbin.get_bus() - self._signals.disconnect(bus, 'message') - bus.remove_signal_watch() + def _on_source_setup(self, element, source): + gst_logger.debug('Got source-setup: element=%s', source) - def _on_pad_event(self, pad, event): - if event.type == gst.EVENT_NEWSEGMENT: - update, rate, format_, start, stop, pos = event.parse_new_segment() - gst_logger.debug('Got new-segment event: update=%s rate=%s ' - 'format=%s start=%s stop=%s position=%s', update, - rate, format_.value_name, start, stop, pos) - pos_ms = pos // gst.MSECOND - logger.debug('Triggering: position_changed(position=%s)', pos_ms) - AudioListener.send('position_changed', position=pos_ms) - elif event.type == gst.EVENT_SINK_MESSAGE: - # Handle stream changed messages when they reach our output bin. - # If we listen for it on the bus we get one per tee branch. - msg = event.parse_sink_message() - if msg.structure.has_name('playbin2-stream-changed'): - self._on_stream_changed(msg.structure['uri']) - return True + if source.get_factory().get_name() == 'appsrc': + self._appsrc.configure(source) + else: + self._appsrc.reset() - # TODO: consider splitting this out while we are at it. - def _on_message(self, bus, msg): - if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._playbin: - self._on_playbin_state_changed(*msg.parse_state_changed()) - elif msg.type == gst.MESSAGE_BUFFERING: - self._on_buffering(msg.parse_buffering()) - elif msg.type == gst.MESSAGE_EOS: - self._on_end_of_stream() - elif msg.type == gst.MESSAGE_ERROR: - self._on_error(*msg.parse_error()) - elif msg.type == gst.MESSAGE_WARNING: - self._on_warning(*msg.parse_warning()) - elif msg.type == gst.MESSAGE_ASYNC_DONE: - gst_logger.debug('Got async-done message.') - elif msg.type == gst.MESSAGE_ELEMENT: - if gst.pbutils.is_missing_plugin_message(msg): - self._on_missing_plugin(msg) - - def _on_playbin_state_changed(self, old_state, new_state, pending_state): - gst_logger.debug('Got state-changed message: old=%s new=%s pending=%s', - old_state.value_name, new_state.value_name, - pending_state.value_name) - - if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: - # XXX: We're not called on the last state change when going down to - # NULL, so we rewrite the second to last call to get the expected - # behavior. - new_state = gst.STATE_NULL - pending_state = gst.STATE_VOID_PENDING - - if pending_state != gst.STATE_VOID_PENDING: - return # Ignore intermediate state changes - - if new_state == gst.STATE_READY: - return # Ignore READY state as it's GStreamer specific - - new_state = _GST_STATE_MAPPING[new_state] - old_state, self.state = self.state, new_state - - target_state = _GST_STATE_MAPPING[self._target_state] - if target_state == new_state: - target_state = None - - logger.debug('Triggering: state_changed(old_state=%s, new_state=%s, ' - 'target_state=%s)', old_state, new_state, target_state) - AudioListener.send('state_changed', old_state=old_state, - new_state=new_state, target_state=target_state) - if new_state == PlaybackState.STOPPED: - logger.debug('Triggering: stream_changed(uri=None)') - AudioListener.send('stream_changed', uri=None) - - def _on_buffering(self, percent): - gst_logger.debug('Got buffering message: percent=%d%%', percent) - - if percent < 10 and not self._buffering: - self._playbin.set_state(gst.STATE_PAUSED) - self._buffering = True - if percent == 100: - self._buffering = False - if self._target_state == gst.STATE_PLAYING: - self._playbin.set_state(gst.STATE_PLAYING) - - def _on_end_of_stream(self): - gst_logger.debug('Got end-of-stream message.') - logger.debug('Triggering: reached_end_of_stream()') - AudioListener.send('reached_end_of_stream') - - def _on_error(self, error, debug): - gst_logger.error(str(error).decode('utf-8')) - if debug: - gst_logger.debug(debug.decode('utf-8')) - # TODO: is this needed? - self.stop_playback() - - def _on_warning(self, error, debug): - gst_logger.warning(str(error).decode('utf-8')) - if debug: - gst_logger.debug(debug.decode('utf-8')) - - def _on_stream_changed(self, uri): - gst_logger.debug('Got stream-changed message: uri:%s', uri) - logger.debug('Triggering: stream_changed(uri=%s)', uri) - AudioListener.send('stream_changed', uri=uri) - - def _on_missing_plugin(self, msg): - desc = gst.pbutils.missing_plugin_message_get_description(msg) - debug = gst.pbutils.missing_plugin_message_get_installer_detail(msg) - - gst_logger.debug('Got missing-plugin message: description:%s', desc) - logger.warning('Could not find a %s to handle media.', desc) - if gst.pbutils.install_plugins_supported(): - logger.info('You might be able to fix this by running: ' - 'gst-installer "%s"', debug) - # TODO: store the missing plugins installer info in a file so we can - # can provide a 'mopidy install-missing-plugins' if the system has the - # required helper installed? + if hasattr(source.props, 'proxy'): + setup_proxy(source, self._config['proxy']) def set_uri(self, uri): """ @@ -659,7 +688,7 @@ class Audio(pykka.ThreadingActor): Should only be used by tests. """ def sync_handler(bus, message): - self._on_message(bus, message) + self._handler.on_message(bus, message) return gst.BUS_DROP bus = self._playbin.get_bus() diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index fc3321d2..8db7f61f 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -357,6 +357,7 @@ class AudioEventTest(BaseTest): self.audio.wait_for_state_change().get() self.possibly_trigger_fake_about_to_finish() + self.audio.wait_for_state_change().get() if not done.wait(timeout=1.0): self.fail('EOS not received') @@ -405,17 +406,17 @@ class AudioStateTest(unittest.TestCase): self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) def test_state_does_not_change_when_in_gst_ready_state(self): - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_NULL, gst.STATE_READY, gst.STATE_VOID_PENDING) self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) def test_state_changes_from_stopped_to_playing_on_play(self): - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_NULL, gst.STATE_READY, gst.STATE_PLAYING) - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_READY, gst.STATE_PAUSED, gst.STATE_PLAYING) - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_PAUSED, gst.STATE_PLAYING, gst.STATE_VOID_PENDING) self.assertEqual(audio.PlaybackState.PLAYING, self.audio.state) @@ -423,7 +424,7 @@ class AudioStateTest(unittest.TestCase): def test_state_changes_from_playing_to_paused_on_pause(self): self.audio.state = audio.PlaybackState.PLAYING - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_VOID_PENDING) self.assertEqual(audio.PlaybackState.PAUSED, self.audio.state) @@ -431,12 +432,12 @@ class AudioStateTest(unittest.TestCase): def test_state_changes_from_playing_to_stopped_on_stop(self): self.audio.state = audio.PlaybackState.PLAYING - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_NULL) - self.audio._on_playbin_state_changed( + self.audio._handler.on_playbin_state_changed( gst.STATE_PAUSED, gst.STATE_READY, gst.STATE_NULL) # We never get the following call, so the logic must work without it - # self.audio._on_playbin_state_changed( + # self.audio._handler.on_playbin_state_changed( # gst.STATE_READY, gst.STATE_NULL, gst.STATE_VOID_PENDING) self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) @@ -453,7 +454,7 @@ class AudioBufferingTest(unittest.TestCase): playbin.set_state.assert_called_with(gst.STATE_PLAYING) playbin.set_state.reset_mock() - self.audio._on_buffering(0) + self.audio._handler.on_buffering(0) playbin.set_state.assert_called_with(gst.STATE_PAUSED) self.assertTrue(self.audio._buffering) @@ -463,7 +464,7 @@ class AudioBufferingTest(unittest.TestCase): playbin.set_state.assert_called_with(gst.STATE_PAUSED) playbin.set_state.reset_mock() - self.audio._on_buffering(100) + self.audio._handler.on_buffering(100) self.assertEqual(playbin.set_state.call_count, 0) self.assertFalse(self.audio._buffering) @@ -473,12 +474,12 @@ class AudioBufferingTest(unittest.TestCase): playbin.set_state.assert_called_with(gst.STATE_PLAYING) playbin.set_state.reset_mock() - self.audio._on_buffering(0) + self.audio._handler.on_buffering(0) playbin.set_state.assert_called_with(gst.STATE_PAUSED) self.audio.pause_playback() playbin.set_state.reset_mock() - self.audio._on_buffering(100) + self.audio._handler.on_buffering(100) self.assertEqual(playbin.set_state.call_count, 0) self.assertFalse(self.audio._buffering) @@ -488,7 +489,7 @@ class AudioBufferingTest(unittest.TestCase): playbin.set_state.assert_called_with(gst.STATE_PLAYING) playbin.set_state.reset_mock() - self.audio._on_buffering(0) + self.audio._handler.on_buffering(0) playbin.set_state.assert_called_with(gst.STATE_PAUSED) playbin.set_state.reset_mock() From 027b7a53fe9212853062da352c464c4277ade510 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 16 Aug 2014 23:04:01 +0200 Subject: [PATCH 053/495] main: Log uncaught exceptions --- mopidy/commands.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/commands.py b/mopidy/commands.py index 2a0b6f48..237ec86b 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -280,6 +280,8 @@ class RootCommand(Command): exit_status_code = 1 except KeyboardInterrupt: logger.info('Interrupted. Exiting...') + except Exception: + logger.exception('Uncaught exception') finally: loop.quit() self.stop_frontends(frontend_classes) From a244761abc3f7cc514f8ef62aa6e9f3c81cd6c3b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 17 Aug 2014 23:25:09 +0200 Subject: [PATCH 054/495] mpd: Replace / with | instead of whitespace in playlist names --- docs/changelog.rst | 5 +++++ mopidy/mpd/dispatcher.py | 2 +- tests/mpd/protocol/test_stored_playlists.py | 8 ++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index c75bbb27..6d7da9d4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,11 @@ v0.20.0 (UNRELEASED) - Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: :issue:`697`, PR: :issue:`802`) +**MPD frontend** + +- In stored playlist names, replace "/", which are illegal, with "|" instead of + a whitespace. Pipes are more similar to forward slash. + v0.19.4 (UNRELEASED) ==================== diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 84550698..9c2f3471 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -269,7 +269,7 @@ class MpdContext(object): if not playlist.name: continue # TODO: add scheme to name perhaps 'foo (spotify)' etc. - name = self._invalid_playlist_chars.sub(' ', playlist.name) + name = self._invalid_playlist_chars.sub('|', playlist.name) self.insert_name_uri_mapping(name, playlist.uri) def lookup_playlist_from_name(self, name): diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index 56011435..4dc7dbbb 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -121,12 +121,12 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertNotInResponse('playlist: a\r') self.assertInResponse('OK') - def test_listplaylists_replaces_forward_slash_with_space(self): + def test_listplaylists_replaces_forward_slash_with_pipe(self): self.backend.playlists.playlists = [ - Playlist(name='a/', uri='dummy:')] + Playlist(name='a/b', uri='dummy:')] self.sendRequest('listplaylists') - self.assertInResponse('playlist: a ') - self.assertNotInResponse('playlist: a/') + self.assertInResponse('playlist: a|b') + self.assertNotInResponse('playlist: a/b') self.assertInResponse('OK') def test_load_appends_to_tracklist(self): From 1dfd14615a187c0bc942565347a89b633a92edff Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 19 Aug 2014 23:28:35 +0200 Subject: [PATCH 055/495] docs: Include mixers in concepts --- docs/api/concepts.rst | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index 68718935..d127561b 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -6,14 +6,15 @@ Architecture and concepts The overall architecture of Mopidy is organized around multiple frontends and backends. The frontends use the core API. The core actor makes multiple backends -work as one. The backends connect to various music sources. Both the core actor -and the backends use the audio actor to play audio and control audio volume. +work as one. The backends connect to various music sources. The core actor use +the mixer actor to control volume, while the backends use the audio actor to +play audio. .. digraph:: overall_architecture "Multiple frontends" -> Core Core -> "Multiple backends" - Core -> Audio + Core -> Mixer "Multiple backends" -> Audio @@ -93,7 +94,15 @@ Audio ===== The audio actor is a thin wrapper around the parts of the GStreamer library we -use. In addition to playback, it's responsible for volume control through both -GStreamer's own volume mixers, and mixers we've created ourselves. If you -implement an advanced backend, you may need to implement your own playback -provider using the :ref:`audio-api`. +use. If you implement an advanced backend, you may need to implement your own +playback provider using the :ref:`audio-api`. + + +Mixer +===== + +The mixer actor is responsible for volume control and muting. The default +mixer use the audio actor to control volume in software. The alternative +implementations are typically independent of the audio actor, but instead use +some third party Python library or a serial interface to control other forms +of volume controls. From dc65a08e3b03b6ae32a495da0e050411cc190d79 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 20 Aug 2014 00:48:12 +0200 Subject: [PATCH 056/495] docs: Unbreak API autodocs --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index 52e84e06..1eb6dd33 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,6 +52,7 @@ MOCK_MODULES = [ 'glib', 'gobject', 'gst', + 'gst.pbutils', 'pygst', 'pykka', 'pykka.actor', From b40409141fe6b8012ff11bb2d9fb041831e02571 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 21 Aug 2014 23:58:09 +0200 Subject: [PATCH 057/495] docs: Add sponsors page --- docs/index.rst | 1 + docs/sponsors.rst | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 docs/sponsors.rst diff --git a/docs/index.rst b/docs/index.rst index 71e8dee7..9653a5dd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -118,6 +118,7 @@ About :maxdepth: 1 authors + sponsors changelog versioning diff --git a/docs/sponsors.rst b/docs/sponsors.rst new file mode 100644 index 00000000..67aef554 --- /dev/null +++ b/docs/sponsors.rst @@ -0,0 +1,38 @@ +.. _sponsors: + +******** +Sponsors +******** + +The Mopidy project would like to thank the following sponsors for supporting +the project. + + +Rackspace +========= + +`Rackspace `_ lets Mopidy use their hosting services +for free. We use their services for the following sites: + +- Hosting of the APT package repository at https://apt.mopidy.com. + +- Hosting of the Discourse forum at https://discuss.mopidy.com. + +- Mailgun for sending emails from the Discourse forum. + +- Hosting of the Jenkins CI server at https://ci.mopidy.com. + +- Hosting of a Linux worker for https://ci.mopidy.com. + +- Hosting of a Windows worker for https://ci.mopidy.com. + +- CDN hosting at http://dl.mopidy.com, which is used to distribute Pi Musicbox + images. + + +GlobalSign +========== + +`GlobalSign `_ provides Mopidy with a free +wildcard SSL certificate for mopidy.com, which we use to secure access to all +our web sites. From 380223bb96e7f777e4ae4e65d13de4adf7f42ace Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sat, 30 Aug 2014 14:02:43 +0200 Subject: [PATCH 058/495] local: Return multiple tracks from lookup() --- mopidy/local/__init__.py | 6 ++---- mopidy/local/library.py | 8 +++++--- tests/local/test_library.py | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 104c43af..725e6783 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -99,11 +99,9 @@ class Library(object): """ Lookup the given URI. - Unlike the core APIs, local tracks uris can only be resolved to a - single track. - :param string uri: track URI - :rtype: :class:`~mopidy.models.Track` + :rtype: List of :class:`~mopidy.models.Track` or single + :class:`~mopidy.models.Track` for backward compatibility """ raise NotImplementedError diff --git a/mopidy/local/library.py b/mopidy/local/library.py index a4645084..ec5f4869 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -33,11 +33,13 @@ class LocalLibraryProvider(backend.LibraryProvider): def lookup(self, uri): if not self._library: return [] - track = self._library.lookup(uri) - if track is None: + tracks = self._library.lookup(uri) + if tracks is None: logger.debug('Failed to lookup %r', uri) return [] - return [track] + if isinstance(tracks, models.Track): + tracks = [tracks] + return tracks def find_exact(self, query=None, uris=None): if not self._library: diff --git a/tests/local/test_library.py b/tests/local/test_library.py index fcc6d4df..3cfcb49a 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -4,6 +4,7 @@ import os import shutil import tempfile import unittest +import mock import pykka @@ -129,6 +130,26 @@ class LocalLibraryProviderTest(unittest.TestCase): tracks = self.library.lookup('fake uri') self.assertEqual(tracks, []) + @mock.patch.object( + json.JsonLibrary, 'lookup') + def test_lookup_multiple_tracks(self, mock_lookup): + backend = actor.LocalBackend(config=self.config, audio=None) + + mock_lookup.return_value = self.tracks + tracks = backend.library.lookup('fake album uri') + mock_lookup.assert_called_with('fake album uri') + self.assertEqual(tracks, self.tracks) + + mock_lookup.return_value = [self.tracks[0]] + tracks = backend.library.lookup(self.tracks[0].uri) + mock_lookup.assert_called_with(self.tracks[0].uri) + self.assertEqual(tracks, self.tracks[0:1]) + + mock_lookup.return_value = [] + tracks = backend.library.lookup('fake uri') + mock_lookup.assert_called_with('fake uri') + self.assertEqual(tracks, []) + # TODO: move to search_test module def test_find_exact_no_hits(self): result = self.library.find_exact(track_name=['unknown track']) From f90671fe966999f5a52a8b29a8a4dff3d625df05 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sat, 30 Aug 2014 14:10:39 +0200 Subject: [PATCH 059/495] Fixed flake8: I201 Missing newline between sections or imports --- tests/local/test_library.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 3cfcb49a..831b8605 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -4,6 +4,7 @@ import os import shutil import tempfile import unittest + import mock import pykka From cf5660e8e5b4be902d8697ea0f4843356efb9359 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 4 Sep 2014 23:35:02 +0200 Subject: [PATCH 060/495] docs: Fix syntax error --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9ab0f2b7..7eefd533 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -37,7 +37,7 @@ Bug fix release. :issue:`833`) - Local library API: Add :attr:`mopidy.local.Library.ROOT_DIRECTORY_URI` - constant for use by implementors of :method:`mopidy.local.Library.browse`. + constant for use by implementors of :meth:`mopidy.local.Library.browse`. (Related to: :issue:`833`) - HTTP frontend: Guard against double close of WebSocket, which causes an From 806174916d1cd1c455203171e7c5590d2de21031 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 5 Sep 2014 05:35:18 +0200 Subject: [PATCH 061/495] Change JsonLibrary.lookup to return a list --- mopidy/local/json.py | 4 ++-- tests/local/test_library.py | 16 ++++++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 5ae04592..30fbb562 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -151,9 +151,9 @@ class JsonLibrary(local.Library): def lookup(self, uri): try: - return self._tracks[uri] + return [self._tracks[uri]] except KeyError: - return None + return [] def search(self, query=None, limit=100, offset=0, uris=None, exact=False): tracks = self._tracks.values() diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 831b8605..148c82e5 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -131,22 +131,18 @@ class LocalLibraryProviderTest(unittest.TestCase): tracks = self.library.lookup('fake uri') self.assertEqual(tracks, []) - @mock.patch.object( - json.JsonLibrary, 'lookup') - def test_lookup_multiple_tracks(self, mock_lookup): + # test backward compatibility with local libraries returning a + # single Track + @mock.patch.object(json.JsonLibrary, 'lookup') + def test_lookup_return_single_track(self, mock_lookup): backend = actor.LocalBackend(config=self.config, audio=None) - mock_lookup.return_value = self.tracks - tracks = backend.library.lookup('fake album uri') - mock_lookup.assert_called_with('fake album uri') - self.assertEqual(tracks, self.tracks) - - mock_lookup.return_value = [self.tracks[0]] + mock_lookup.return_value = self.tracks[0] tracks = backend.library.lookup(self.tracks[0].uri) mock_lookup.assert_called_with(self.tracks[0].uri) self.assertEqual(tracks, self.tracks[0:1]) - mock_lookup.return_value = [] + mock_lookup.return_value = None tracks = backend.library.lookup('fake uri') mock_lookup.assert_called_with('fake uri') self.assertEqual(tracks, []) From 61e724eb0e314e5ec5d8e39f2319a1d8a7e193c1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 5 Sep 2014 17:34:23 +0200 Subject: [PATCH 062/495] docs: Add Discuss link to readme --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 10ad4d3c..46880c9d 100644 --- a/README.rst +++ b/README.rst @@ -50,6 +50,7 @@ To get started with Mopidy, check out `the installation docs `_. - `Documentation `_ +- `Discuss `_ - `Source code `_ - `Issue tracker `_ - `Development branch tarball `_ From 13c92dae65ac86cea64e21009a02499801a7e40a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 6 Sep 2014 23:39:10 +0200 Subject: [PATCH 063/495] docs: Rewrite 'getting help' section --- docs/index.rst | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 9653a5dd..bd324af7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -56,15 +56,20 @@ To get started with Mopidy, start by reading :ref:`installation`. **Getting help** -If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net -`_ (with `searchable logs -`_) and also have a `mailing list at Google -Groups `_. If you -stumble into a bug or got a feature request, please create an issue in the -`issue tracker `_. The `source code -`_ may also be of help. If you want to stay -up to date on Mopidy developments, you can follow `@mopidy -`_ on Twitter. +If you get stuck, you can get help at the `Mopidy discussion forum +`_. We also hang around at IRC on the ``#mopidy`` +channel at `irc.freenode.net `_. The IRC channel has +`public searchable logs `_. + +If you stumble into a bug or have a feature request, please create an issue in +the `issue tracker `_. If you're +unsure if its a bug or not, ask for help in the forum or at IRC first. The +`source code `_ may also be of help. + +If you want to stay up to date on Mopidy developments, you can follow `@mopidy +`_ on Twitter. There's also a `mailing list +`_ used for +announcements related to Mopidy and Mopidy extensions. Usage From c129cd4b24d3fc1451ff1be52800782e0a51e2d0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 7 Sep 2014 21:36:31 +0200 Subject: [PATCH 064/495] audio: s/Triggering/Audio event/ --- mopidy/audio/actor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 8d86858b..910c6653 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -342,12 +342,12 @@ class _Handler(object): if target_state == new_state: target_state = None - logger.debug('Triggering: state_changed(old_state=%s, new_state=%s, ' + logger.debug('Audio event: state_changed(old_state=%s, new_state=%s, ' 'target_state=%s)', old_state, new_state, target_state) AudioListener.send('state_changed', old_state=old_state, new_state=new_state, target_state=target_state) if new_state == PlaybackState.STOPPED: - logger.debug('Triggering: stream_changed(uri=None)') + logger.debug('Audio event: stream_changed(uri=None)') AudioListener.send('stream_changed', uri=None) def on_buffering(self, percent): @@ -363,7 +363,7 @@ class _Handler(object): def on_end_of_stream(self): gst_logger.debug('Got end-of-stream message.') - logger.debug('Triggering: reached_end_of_stream()') + logger.debug('Audio event: reached_end_of_stream()') AudioListener.send('reached_end_of_stream') def on_error(self, error, debug): @@ -399,12 +399,12 @@ class _Handler(object): 'start=%s stop=%s position=%s', update, rate, format_.value_name, start, stop, position) position_ms = position // gst.MSECOND - logger.debug('Triggering: position_changed(position=%s)', position_ms) + logger.debug('Audio event: position_changed(position=%s)', position_ms) AudioListener.send('position_changed', position=position_ms) def on_stream_changed(self, uri): gst_logger.debug('Got stream-changed message: uri:%s', uri) - logger.debug('Triggering: stream_changed(uri=%s)', uri) + logger.debug('Audio event: stream_changed(uri=%s)', uri) AudioListener.send('stream_changed', uri=uri) From e744a6da87cd760fdbfc63871344fd2ccfef7596 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 7 Sep 2014 21:38:13 +0200 Subject: [PATCH 065/495] audio: Resolve review comments --- mopidy/audio/actor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 910c6653..8b77ee65 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -403,7 +403,7 @@ class _Handler(object): AudioListener.send('position_changed', position=position_ms) def on_stream_changed(self, uri): - gst_logger.debug('Got stream-changed message: uri:%s', uri) + gst_logger.debug('Got stream-changed message: uri=%s', uri) logger.debug('Audio event: stream_changed(uri=%s)', uri) AudioListener.send('stream_changed', uri=uri) @@ -484,8 +484,8 @@ class Audio(pykka.ThreadingActor): self._playbin.set_state(gst.STATE_NULL) def _setup_output(self): - # We don't want to test outputs for regular testing, so just instal - # an unsynced fakesink when someone asks for a testouput. + # We don't want to use outputs for regular testing, so just install + # an unsynced fakesink when someone asks for a 'testoutput'. if self._config['audio']['output'] == 'testoutput': self._outputs = gst.element_factory_make('fakesink') else: From 3ed6843d92c11bdb5a9fa6b7776cb0975ac44249 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 7 Sep 2014 22:32:04 +0200 Subject: [PATCH 066/495] docs: Update changelog with audio changes --- docs/changelog.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7eefd533..f2182e33 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,14 @@ v0.20.0 (UNRELEASED) - In stored playlist names, replace "/", which are illegal, with "|" instead of a whitespace. Pipes are more similar to forward slash. +**Audio** + +- Internal code cleanup within audio sub-system: + - Started splitting audio code into smaller better defined pieces. + - Improved GStreamer related debug logging. + - Provide better error messages for missing plugins. + - Add foundation for trying to re-add multiple output support. + v0.19.4 (2014-09-01) ==================== From 3f699b97d29efc5d0a7f14d173d155c68b898cee Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 7 Sep 2014 22:35:58 +0200 Subject: [PATCH 067/495] audio: Split message/event teardown handler --- mopidy/audio/actor.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 8b77ee65..9db6fbf5 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -279,13 +279,14 @@ class _Handler(object): self._pad = pad self._event_handler_id = pad.add_event_probe(self.on_event) - def teardown(self): + def teardown_message_handling(self): bus = self._element.get_bus() bus.remove_signal_watch() bus.disconnect(self._message_handler_id) - self._pad.remove_event_probe(self._event_handler_id) - self._message_handler_id = None + + def teardown_event_handling(self): + self._pad.remove_event_probe(self._event_handler_id) self._event_handler_id = None def on_message(self, bus, msg): @@ -478,7 +479,8 @@ class Audio(pykka.ThreadingActor): self._handler.setup_message_handling(playbin) def _teardown_playbin(self): - self._handler.teardown() + self._handler.teardown_message_handling() + self._handler.teardown_event_handling() self._signals.disconnect(self._playbin, 'about-to-finish') self._signals.disconnect(self._playbin, 'source-setup') self._playbin.set_state(gst.STATE_NULL) From ada7641ee63f4805b950d63b37f8f540b7af5dd9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 7 Sep 2014 23:27:35 +0200 Subject: [PATCH 068/495] audio: Remove visualizer support --- docs/changelog.rst | 5 +++++ docs/config.rst | 11 ----------- mopidy/audio/actor.py | 17 ----------------- mopidy/config/__init__.py | 2 +- mopidy/config/default.conf | 3 +-- 5 files changed, 7 insertions(+), 31 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f2182e33..2f6fa923 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -25,6 +25,11 @@ v0.20.0 (UNRELEASED) - Improved GStreamer related debug logging. - Provide better error messages for missing plugins. - Add foundation for trying to re-add multiple output support. +- Kill support for visualizers. Feature was originally added as a workaround for + all the people asking for ncmpcpp visualizer support. And since we could get + it almost for free thanks to GStreamer. But this feature didn't really ever + make sense for a server such as Mopidy. Currently the only way to find out if + it is in use and will be missed is to go ahead and remove it. v0.19.4 (2014-09-01) diff --git a/docs/config.rst b/docs/config.rst index f5f6bd19..03bb83ac 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -93,17 +93,6 @@ Audio configuration ``gst-inspect-0.10`` to see what output properties can be set on the sink. For example: ``gst-inspect-0.10 shout2send`` -.. confval:: audio/visualizer - - Visualizer to use. - - Can be left blank if no visualizer is desired. Otherwise this expects a - GStreamer visualizer. Typical values are ``monoscope``, ``goom``, - ``goom2k1`` or one of the `libvisual`_ visualizers. - -.. _libvisual: http://gstreamer.freedesktop.org/data/doc/gstreamer/head/gst-plugins-base-plugins/html/gst-plugins-base-plugins-plugin-libvisual.html - - Logging configuration --------------------- diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 9db6fbf5..5cea9f7b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -49,7 +49,6 @@ MB = 1 << 20 # Default flags to use for playbin: AUDIO, SOFT_VOLUME, DOWNLOAD PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7) -PLAYBIN_VIS_FLAGS = PLAYBIN_FLAGS | (1 << 3) # These are just to long to wrap nicely, so rename them locally. _get_missing_description = gst.pbutils.missing_plugin_message_get_description @@ -445,7 +444,6 @@ class Audio(pykka.ThreadingActor): self._setup_playbin() self._setup_output() self._setup_mixer() - self._setup_visualizer() except gobject.GError as ex: logger.exception(ex) process.exit_process() @@ -508,21 +506,6 @@ class Audio(pykka.ThreadingActor): if self.mixer: self.mixer.teardown() - def _setup_visualizer(self): - # TODO: kill - visualizer_element = self._config['audio']['visualizer'] - if not visualizer_element: - return - try: - visualizer = gst.element_factory_make(visualizer_element) - self._playbin.set_property('vis-plugin', visualizer) - self._playbin.set_property('flags', PLAYBIN_VIS_FLAGS) - logger.info('Audio visualizer set to "%s"', visualizer_element) - except gobject.GError as ex: - logger.error( - 'Failed to create audio visualizer "%s": %s', - visualizer_element, ex) - def _on_about_to_finish(self, element): gst_logger.debug('Got about-to-finish event.') if self._about_to_finish_callback: diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 61971875..7c0898aa 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -28,7 +28,7 @@ _audio_schema['mixer'] = String() _audio_schema['mixer_track'] = Deprecated() _audio_schema['mixer_volume'] = Integer(optional=True, minimum=0, maximum=100) _audio_schema['output'] = String() -_audio_schema['visualizer'] = String(optional=True) +_audio_schema['visualizer'] = Deprecated() _proxy_schema = ConfigSchema('proxy') _proxy_schema['scheme'] = String(optional=True, diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 6a900cf9..42edbbbd 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -9,11 +9,10 @@ config_file = mixer = software mixer_volume = output = autoaudiosink -visualizer = [proxy] scheme = hostname = -port = +port = username = password = From e9e8124e903dd72c1dfe81a37dd5f6bc75fe1dd5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Sep 2014 11:03:56 +0200 Subject: [PATCH 069/495] docs: Fix formatting --- docs/changelog.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2f6fa923..7a15fb5a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -20,11 +20,16 @@ v0.20.0 (UNRELEASED) **Audio** -- Internal code cleanup within audio sub-system: +- Internal code cleanup within audio subsystem: + - Started splitting audio code into smaller better defined pieces. + - Improved GStreamer related debug logging. + - Provide better error messages for missing plugins. + - Add foundation for trying to re-add multiple output support. + - Kill support for visualizers. Feature was originally added as a workaround for all the people asking for ncmpcpp visualizer support. And since we could get it almost for free thanks to GStreamer. But this feature didn't really ever From 12f9860e2dd43fc726d10717ba4c900af90600f2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Sep 2014 11:04:16 +0200 Subject: [PATCH 070/495] docs: Fix typo --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index bd324af7..395e683e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -63,7 +63,7 @@ channel at `irc.freenode.net `_. The IRC channel has If you stumble into a bug or have a feature request, please create an issue in the `issue tracker `_. If you're -unsure if its a bug or not, ask for help in the forum or at IRC first. The +unsure if it's a bug or not, ask for help in the forum or at IRC first. The `source code `_ may also be of help. If you want to stay up to date on Mopidy developments, you can follow `@mopidy From db4868207c48882931659c11576000b6124f8235 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Sep 2014 21:15:14 +0200 Subject: [PATCH 071/495] Print friendly error message if gobject isn't found Fixes #836 --- docs/changelog.rst | 5 +++++ mopidy/__main__.py | 18 ++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7a15fb5a..0ff362f8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,11 @@ This changelog is used to track all major changes to Mopidy. v0.20.0 (UNRELEASED) ==================== +**Commands** + +- Make the ``mopidy`` command print a friendly error message if the + :mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`) + **Local backend** - Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 9620b936..aa8d6dd9 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -4,9 +4,23 @@ import logging import os import signal import sys +import textwrap -import gobject -gobject.threads_init() +try: + import gobject + gobject.threads_init() +except ImportError: + print(textwrap.dedent(""" + ERROR: The gobject Python package was not found. + + Mopidy requires GStreamer (and GObject) to work. These are C libraries + with a number of dependencies themselves, and cannot be installed with + the regular Python tools like pip. + + Please see http://docs.mopidy.com/en/latest/installation/ for + instructions on how to install the required dependencies. + """)) + raise try: # Make GObject's mainloop the event loop for python-dbus From 2050385a5f5fdcffe333ae17463d6469af0b5cd8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Sep 2014 08:24:42 +0200 Subject: [PATCH 072/495] Update Python and Pykka version check error messages --- mopidy/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 7b55f20a..c91b15c8 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import platform import sys import warnings from distutils.version import StrictVersion as SV @@ -9,13 +10,14 @@ import pykka if not (2, 7) <= sys.version_info < (3,): sys.exit( - 'Mopidy requires Python >= 2.7, < 3, but found %s' % - '.'.join(map(str, sys.version_info[:3]))) + 'ERROR: Mopidy requires Python 2.7, but found %s.' % + platform.python_version()) if (isinstance(pykka.__version__, basestring) and not SV('1.1') <= SV(pykka.__version__) < SV('2.0')): sys.exit( - 'Mopidy requires Pykka >= 1.1, < 2, but found %s' % pykka.__version__) + 'ERROR: Mopidy requires Pykka >= 1.1, < 2, but found %s.' % + pykka.__version__) warnings.filterwarnings('ignore', 'could not open display') From eb97b55d88866f1ecd23fd5f3a8793d4d3a4ecac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Sep 2014 08:35:18 +0200 Subject: [PATCH 073/495] Keep gobject check together with the other checks --- mopidy/__init__.py | 16 ++++++++++++++++ mopidy/__main__.py | 18 ++---------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index c91b15c8..f6870e22 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import platform import sys +import textwrap import warnings from distutils.version import StrictVersion as SV @@ -19,6 +20,21 @@ if (isinstance(pykka.__version__, basestring) 'ERROR: Mopidy requires Pykka >= 1.1, < 2, but found %s.' % pykka.__version__) +try: + import gobject # noqa +except ImportError: + print(textwrap.dedent(""" + ERROR: The gobject Python package was not found. + + Mopidy requires GStreamer (and GObject) to work. These are C libraries + with a number of dependencies themselves, and cannot be installed with + the regular Python tools like pip. + + Please see http://docs.mopidy.com/en/latest/installation/ for + instructions on how to install the required dependencies. + """)) + raise + warnings.filterwarnings('ignore', 'could not open display') diff --git a/mopidy/__main__.py b/mopidy/__main__.py index aa8d6dd9..9620b936 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -4,23 +4,9 @@ import logging import os import signal import sys -import textwrap -try: - import gobject - gobject.threads_init() -except ImportError: - print(textwrap.dedent(""" - ERROR: The gobject Python package was not found. - - Mopidy requires GStreamer (and GObject) to work. These are C libraries - with a number of dependencies themselves, and cannot be installed with - the regular Python tools like pip. - - Please see http://docs.mopidy.com/en/latest/installation/ for - instructions on how to install the required dependencies. - """)) - raise +import gobject +gobject.threads_init() try: # Make GObject's mainloop the event loop for python-dbus From 37c736533dbd0f771be4df6bb49c19be01ed8f18 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Sep 2014 08:38:12 +0200 Subject: [PATCH 074/495] Remove Pykka version check This check was made redundant with our move to setuptools. The executables made by setuptools checks if all our Python dependencies are available in the required versions before starting Mopidy. --- mopidy/__init__.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index f6870e22..8b5b8e18 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -4,9 +4,6 @@ import platform import sys import textwrap import warnings -from distutils.version import StrictVersion as SV - -import pykka if not (2, 7) <= sys.version_info < (3,): @@ -14,12 +11,6 @@ if not (2, 7) <= sys.version_info < (3,): 'ERROR: Mopidy requires Python 2.7, but found %s.' % platform.python_version()) -if (isinstance(pykka.__version__, basestring) - and not SV('1.1') <= SV(pykka.__version__) < SV('2.0')): - sys.exit( - 'ERROR: Mopidy requires Pykka >= 1.1, < 2, but found %s.' % - pykka.__version__) - try: import gobject # noqa except ImportError: From 225092add5d7b1a2bf3f3fcf688023a4adead632 Mon Sep 17 00:00:00 2001 From: Dmitry Sandalov Date: Tue, 9 Sep 2014 11:10:05 +0400 Subject: [PATCH 075/495] fixes docs typo: AngularJS --- docs/ext/moped.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ext/moped.rst b/docs/ext/moped.rst index 38fa50cc..28254ed7 100644 --- a/docs/ext/moped.rst +++ b/docs/ext/moped.rst @@ -3,7 +3,7 @@ Moped https://github.com/martijnboland/moped -A Mopidy web client made with AnbularJS by Martijn Boland. +A Mopidy web client made with AngularJS by Martijn Boland. .. image:: /ext/moped.png :width: 720 From 4eacc911c9e0cf262f05b0f08343bb8e89233322 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Sep 2014 23:59:35 +0200 Subject: [PATCH 076/495] Switch from Fabric to Invoke We don't use any of the remote server features supported by Fabric, so better to use the smaller, more modern, and Python 3 compatible Invoke. --- dev-requirements.txt | 2 +- fabfile.py | 63 -------------------------------------------- tasks.py | 50 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 64 deletions(-) delete mode 100644 fabfile.py create mode 100644 tasks.py diff --git a/dev-requirements.txt b/dev-requirements.txt index b7367219..7b0e96c8 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,5 @@ # Automate tasks -fabric +invoke # Build documentation sphinx diff --git a/fabfile.py b/fabfile.py deleted file mode 100644 index f23da2b1..00000000 --- a/fabfile.py +++ /dev/null @@ -1,63 +0,0 @@ -from fabric.api import execute, local, settings, task - - -@task -def docs(): - local('make -C docs/ html') - - -@task -def autodocs(): - auto(docs) - - -@task -def test(path=None): - path = path or 'tests/' - local('nosetests ' + path) - - -@task -def autotest(path=None): - auto(test, path=path) - - -@task -def coverage(path=None): - path = path or 'tests/' - local( - 'nosetests --with-coverage --cover-package=mopidy ' - '--cover-branches --cover-html ' + path) - - -@task -def autocoverage(path=None): - auto(coverage, path=path) - - -@task -def lint(path=None): - path = path or '.' - local('flake8 $(find %s -iname "*.py")' % path) - - -@task -def autolint(path=None): - auto(lint, path=path) - - -def auto(task, *args, **kwargs): - while True: - local('clear') - with settings(warn_only=True): - execute(task, *args, **kwargs) - local( - 'inotifywait -q -e create -e modify -e delete ' - '--exclude ".*\.(pyc|sw.)" -r docs/ mopidy/ tests/') - - -@task -def update_authors(): - # Keep authors in the order of appearance and use awk to filter out dupes - local( - "git log --format='- %aN <%aE>' --reverse | awk '!x[$0]++' > AUTHORS") diff --git a/tasks.py b/tasks.py new file mode 100644 index 00000000..80b655b5 --- /dev/null +++ b/tasks.py @@ -0,0 +1,50 @@ +import sys + +from invoke import run, task + + +@task +def docs(watch=False, warn=False): + if watch: + return watcher(docs) + run('make -C docs/ html', warn=warn) + + +@task +def test(path=None, coverage=False, watch=False, warn=False): + if watch: + return watcher(test) + path = path or 'tests/' + cmd = 'nosetests' + if coverage: + cmd += ( + ' --with-coverage --cover-package=mopidy' + ' --cover-branches --cover-html') + cmd += ' %s' % path + run(cmd, pty=True, warn=warn) + + +@task +def lint(watch=False, warn=False): + if watch: + return watcher(lint) + run('flake8', warn=warn) + + +@task +def update_authors(): + # Keep authors in the order of appearance and use awk to filter out dupes + run("git log --format='- %aN <%aE>' --reverse | awk '!x[$0]++' > AUTHORS") + + +def watcher(task, *args, **kwargs): + while True: + run('clear') + kwargs['warn'] = True + task(*args, **kwargs) + try: + run( + 'inotifywait -q -e create -e modify -e delete ' + '--exclude ".*\.(pyc|sw.)" -r docs/ mopidy/ tests/') + except KeyboardInterrupt: + sys.exit() From d6e0c8d7e6ee6d63d3bd10e9d5bb5bbf59927f76 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Sep 2014 21:49:35 +0200 Subject: [PATCH 077/495] js: Move Mopidy.js source code to a new Git repo --- docs/api/js.rst | 7 +- js/Gruntfile.js | 101 ---- js/README.md | 121 ----- js/buster.js | 15 - js/lib/websocket/browser.js | 1 - js/lib/websocket/package.json | 4 - js/lib/websocket/server.js | 1 - js/package.json | 60 --- js/src/mopidy.js | 331 ------------ js/test/bind-helper.js | 29 - js/test/mopidy-test.js | 964 ---------------------------------- 11 files changed, 4 insertions(+), 1630 deletions(-) delete mode 100644 js/Gruntfile.js delete mode 100644 js/README.md delete mode 100644 js/buster.js delete mode 100644 js/lib/websocket/browser.js delete mode 100644 js/lib/websocket/package.json delete mode 100644 js/lib/websocket/server.js delete mode 100644 js/package.json delete mode 100644 js/src/mopidy.js delete mode 100644 js/test/bind-helper.js delete mode 100644 js/test/mopidy-test.js diff --git a/docs/api/js.rst b/docs/api/js.rst index 372e7f4e..361c24fd 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -66,9 +66,10 @@ After npm completes, you can import Mopidy.js using ``require()``: Getting the library for development on the library ================================================== -If you want to work on the Mopidy.js library itself, you'll find a complete -development setup in the ``js/`` dir in our repo. The instructions in -``js/README.md`` will guide you on your way. +If you want to work on the Mopidy.js library itself, you'll find the source +code and a complete development setup in the `Mopidy.js Git repo +`_. The instructions in ``README.md`` will +guide you on your way. Creating an instance diff --git a/js/Gruntfile.js b/js/Gruntfile.js deleted file mode 100644 index 81221676..00000000 --- a/js/Gruntfile.js +++ /dev/null @@ -1,101 +0,0 @@ -/*global module:false*/ -module.exports = function (grunt) { - - grunt.initConfig({ - pkg: grunt.file.readJSON("package.json"), - meta: { - banner: "/*! Mopidy.js v<%= pkg.version %> - built " + - "<%= grunt.template.today('yyyy-mm-dd') %>\n" + - " * http://www.mopidy.com/\n" + - " * Copyright (c) <%= grunt.template.today('yyyy') %> " + - "Stein Magnus Jodal and contributors\n" + - " * Licensed under the Apache License, Version 2.0 */\n", - files: { - own: ["Gruntfile.js", "src/**/*.js", "test/**/*-test.js"], - main: "src/mopidy.js", - concat: "../mopidy/http/data/mopidy.js", - minified: "../mopidy/http/data/mopidy.min.js" - } - }, - buster: { - all: {} - }, - browserify: { - test_mopidy: { - files: { - "test/lib/mopidy.js": "<%= meta.files.main %>" - }, - options: { - postBundleCB: function (err, src, next) { - next(err, grunt.template.process("<%= meta.banner %>") + src); - }, - standalone: "Mopidy" - } - }, - test_when: { - files: { - "test/lib/when.js": "node_modules/when/when.js" - }, - options: { - standalone: "when" - } - }, - dist: { - files: { - "<%= meta.files.concat %>": "<%= meta.files.main %>" - }, - options: { - postBundleCB: function (err, src, next) { - next(err, grunt.template.process("<%= meta.banner %>") + src); - }, - standalone: "Mopidy" - } - } - }, - jshint: { - options: { - curly: true, - eqeqeq: true, - immed: true, - indent: 4, - latedef: true, - newcap: true, - noarg: true, - sub: true, - quotmark: "double", - undef: true, - unused: true, - eqnull: true, - browser: true, - devel: true, - globals: {} - }, - files: "<%= meta.files.own %>" - }, - uglify: { - options: { - banner: "<%= meta.banner %>" - }, - all: { - files: { - "<%= meta.files.minified %>": ["<%= meta.files.concat %>"] - } - } - }, - watch: { - files: "<%= meta.files.own %>", - tasks: ["default"] - } - }); - - grunt.registerTask("test_build", ["browserify:test_when", "browserify:test_mopidy"]); - grunt.registerTask("test", ["jshint", "test_build", "buster"]); - grunt.registerTask("build", ["test", "browserify:dist", "uglify"]); - grunt.registerTask("default", ["build"]); - - grunt.loadNpmTasks("grunt-buster"); - grunt.loadNpmTasks("grunt-browserify"); - grunt.loadNpmTasks("grunt-contrib-jshint"); - grunt.loadNpmTasks("grunt-contrib-uglify"); - grunt.loadNpmTasks("grunt-contrib-watch"); -}; diff --git a/js/README.md b/js/README.md deleted file mode 100644 index 1b368bf5..00000000 --- a/js/README.md +++ /dev/null @@ -1,121 +0,0 @@ -Mopidy.js -========= - -Mopidy.js is a JavaScript library that is installed as a part of Mopidy's HTTP -frontend or from npm. The library makes Mopidy's core API available from the -browser or a Node.js environment, using JSON-RPC messages over a WebSocket to -communicate with Mopidy. - - -Getting it for browser use --------------------------- - -Regular and minified versions of Mopidy.js, ready for use, is installed -together with Mopidy. When the HTTP frontend is running, the files are -available at: - -- http://localhost:6680/mopidy/mopidy.js -- http://localhost:6680/mopidy/mopidy.min.js - -You may need to adjust hostname and port for your local setup. - -In the source repo, you can find the files at: - -- `mopidy/http/data/mopidy.js` -- `mopidy/http/data/mopidy.min.js` - - -Getting it for Node.js use --------------------------- - -If you want to use Mopidy.js from Node.js instead of a browser, you can install -Mopidy.js using npm: - - npm install mopidy - -After npm completes, you can import Mopidy.js using ``require()``: - - var Mopidy = require("mopidy"); - - -Using the library ------------------ - -See the [Mopidy.js documentation](http://docs.mopidy.com/en/latest/api/js/). - - -Building from source --------------------- - -1. Install [Node.js](http://nodejs.org/) and npm. If you're running Ubuntu: - - sudo apt-get install nodejs-legacy npm - -2. Enter the `js/` in Mopidy's Git repo dir and install all dependencies: - - cd js/ - npm install - -That's it. - -You can now run the tests: - - npm test - -To run tests automatically when you save a file: - - npm start - -To run tests, concatenate, minify the source, and update the JavaScript files -in `mopidy/http/data/`: - - npm run-script build - -To run other [grunt](http://gruntjs.com/) targets which isn't predefined in -`package.json` and thus isn't available through `npm run-script`: - - PATH=./node_modules/.bin:$PATH grunt foo - - -Changelog ---------- - -### 0.4.0 (2014-06-24) - -- Add support for method calls with by-name arguments. The old calling - convention, "by-position-only", is still the default, but this will change in - the future. A warning is printed to the console if you don't explicitly - select a calling convention. See the docs for details. - -### 0.3.0 (2014-06-16) - -- Upgrade to when.js 3, which brings great performance improvements and better - debugging facilities. If you maintain a Mopidy client, you should review the - [differences between when.js 2 and 3](https://github.com/cujojs/when/blob/master/docs/api.md#upgrading-to-30-from-2x) - and the - [when.js debugging guide](https://github.com/cujojs/when/blob/master/docs/api.md#debugging-promises). - -- All promise rejection values are now of the Error type. This ensures that all - JavaScript VMs will show a useful stack trace if a rejected promise's value - is used to throw an exception. To allow catch clauses to handle different - errors differently, server side errors are of the type `Mopidy.ServerError`, - and connection related errors are of the type `Mopidy.ConnectionError`. - -### 0.2.0 (2014-01-04) - -- **Backwards incompatible change for Node.js users:** - `var Mopidy = require('mopidy').Mopidy;` must be changed to - `var Mopidy = require('mopidy');` - -- Add support for [Browserify](http://browserify.org/). - -- Upgrade dependencies. - -### 0.1.1 (2013-09-17) - -- Upgrade dependencies. - -### 0.1.0 (2013-03-31) - -- Initial release as a Node.js module to the - [npm registry](https://npmjs.org/). diff --git a/js/buster.js b/js/buster.js deleted file mode 100644 index c5dec850..00000000 --- a/js/buster.js +++ /dev/null @@ -1,15 +0,0 @@ -var config = module.exports; - -config.browser_tests = { - environment: "browser", - libs: ["test/lib/*.js"], - testHelpers: ["test/**/*-helper.js"], - tests: ["test/**/*-test.js"] -}; - -config.node_tests = { - environment: "node", - sources: ["src/**/*.js"], - testHelpers: ["test/**/*-helper.js"], - tests: ["test/**/*-test.js"] -}; diff --git a/js/lib/websocket/browser.js b/js/lib/websocket/browser.js deleted file mode 100644 index e594246c..00000000 --- a/js/lib/websocket/browser.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = { Client: window.WebSocket }; diff --git a/js/lib/websocket/package.json b/js/lib/websocket/package.json deleted file mode 100644 index d1e2ac63..00000000 --- a/js/lib/websocket/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "browser": "browser.js", - "main": "server.js" -} diff --git a/js/lib/websocket/server.js b/js/lib/websocket/server.js deleted file mode 100644 index dd24f4be..00000000 --- a/js/lib/websocket/server.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('faye-websocket'); diff --git a/js/package.json b/js/package.json deleted file mode 100644 index b2b63f84..00000000 --- a/js/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "mopidy", - "version": "0.4.0", - "description": "Client lib for controlling a Mopidy music server over a WebSocket", - "keywords": [ - "mopidy", - "music", - "client", - "websocket", - "json-rpc" - ], - "homepage": "http://www.mopidy.com/", - "bugs": "https://github.com/mopidy/mopidy/issues", - "license": "Apache-2.0", - "author": { - "name": "Stein Magnus Jodal", - "email": "stein.magnus@jodal.no", - "url": "http://www.jodal.no" - }, - "contributors": [ - { - "name": "Stein Magnus Jodal", - "email": "stein.magnus@jodal.no", - "url": "http://www.jodal.no" - }, - { - "name": "Paul Connolley", - "email": "paul.connolley@gmail.com" - } - ], - "main": "src/mopidy.js", - "repository": { - "type": "git", - "url": "git://github.com/mopidy/mopidy.git" - }, - "scripts": { - "test": "grunt test", - "build": "grunt build", - "start": "grunt watch" - }, - "dependencies": { - "bane": "~1.1.0", - "faye-websocket": "~0.7.2", - "when": "~3.2.3" - }, - "devDependencies": { - "buster": "~0.7.13", - "browserify": "~3", - "grunt": "~0.4.5", - "grunt-buster": "~0.3.1", - "grunt-browserify": "~1.3.2", - "grunt-contrib-jshint": "~0.10.0", - "grunt-contrib-uglify": "~0.5.0", - "grunt-contrib-watch": "~0.6.1", - "phantomjs": "~1.9.7-8" - }, - "engines": { - "node": "*" - } -} diff --git a/js/src/mopidy.js b/js/src/mopidy.js deleted file mode 100644 index 7e019dd4..00000000 --- a/js/src/mopidy.js +++ /dev/null @@ -1,331 +0,0 @@ -/*global module:true, require:false*/ - -var bane = require("bane"); -var websocket = require("../lib/websocket/"); -var when = require("when"); - -function Mopidy(settings) { - if (!(this instanceof Mopidy)) { - return new Mopidy(settings); - } - - this._console = this._getConsole(settings || {}); - this._settings = this._configure(settings || {}); - - this._backoffDelay = this._settings.backoffDelayMin; - this._pendingRequests = {}; - this._webSocket = null; - - bane.createEventEmitter(this); - this._delegateEvents(); - - if (this._settings.autoConnect) { - this.connect(); - } -} - -Mopidy.ConnectionError = function (message) { - this.name = "ConnectionError"; - this.message = message; -}; -Mopidy.ConnectionError.prototype = new Error(); -Mopidy.ConnectionError.prototype.constructor = Mopidy.ConnectionError; - -Mopidy.ServerError = function (message) { - this.name = "ServerError"; - this.message = message; -}; -Mopidy.ServerError.prototype = new Error(); -Mopidy.ServerError.prototype.constructor = Mopidy.ServerError; - -Mopidy.WebSocket = websocket.Client; - -Mopidy.prototype._getConsole = function (settings) { - if (typeof settings.console !== "undefined") { - return settings.console; - } - - var con = typeof console !== "undefined" && console || {}; - - con.log = con.log || function () {}; - con.warn = con.warn || function () {}; - con.error = con.error || function () {}; - - return con; -}; - -Mopidy.prototype._configure = function (settings) { - var currentHost = (typeof document !== "undefined" && - document.location.host) || "localhost"; - settings.webSocketUrl = settings.webSocketUrl || - "ws://" + currentHost + "/mopidy/ws"; - - if (settings.autoConnect !== false) { - settings.autoConnect = true; - } - - settings.backoffDelayMin = settings.backoffDelayMin || 1000; - settings.backoffDelayMax = settings.backoffDelayMax || 64000; - - if (typeof settings.callingConvention === "undefined") { - this._console.warn( - "Mopidy.js is using the default calling convention. The " + - "default will change in the future. You should explicitly " + - "specify which calling convention you use."); - } - settings.callingConvention = ( - settings.callingConvention || "by-position-only"); - - return settings; -}; - -Mopidy.prototype._delegateEvents = function () { - // Remove existing event handlers - this.off("websocket:close"); - this.off("websocket:error"); - this.off("websocket:incomingMessage"); - this.off("websocket:open"); - this.off("state:offline"); - - // Register basic set of event handlers - this.on("websocket:close", this._cleanup); - this.on("websocket:error", this._handleWebSocketError); - this.on("websocket:incomingMessage", this._handleMessage); - this.on("websocket:open", this._resetBackoffDelay); - this.on("websocket:open", this._getApiSpec); - this.on("state:offline", this._reconnect); -}; - -Mopidy.prototype.connect = function () { - if (this._webSocket) { - if (this._webSocket.readyState === Mopidy.WebSocket.OPEN) { - return; - } else { - this._webSocket.close(); - } - } - - this._webSocket = this._settings.webSocket || - new Mopidy.WebSocket(this._settings.webSocketUrl); - - this._webSocket.onclose = function (close) { - this.emit("websocket:close", close); - }.bind(this); - - this._webSocket.onerror = function (error) { - this.emit("websocket:error", error); - }.bind(this); - - this._webSocket.onopen = function () { - this.emit("websocket:open"); - }.bind(this); - - this._webSocket.onmessage = function (message) { - this.emit("websocket:incomingMessage", message); - }.bind(this); -}; - -Mopidy.prototype._cleanup = function (closeEvent) { - Object.keys(this._pendingRequests).forEach(function (requestId) { - var resolver = this._pendingRequests[requestId]; - delete this._pendingRequests[requestId]; - var error = new Mopidy.ConnectionError("WebSocket closed"); - error.closeEvent = closeEvent; - resolver.reject(error); - }.bind(this)); - - this.emit("state:offline"); -}; - -Mopidy.prototype._reconnect = function () { - this.emit("reconnectionPending", { - timeToAttempt: this._backoffDelay - }); - - setTimeout(function () { - this.emit("reconnecting"); - this.connect(); - }.bind(this), this._backoffDelay); - - this._backoffDelay = this._backoffDelay * 2; - if (this._backoffDelay > this._settings.backoffDelayMax) { - this._backoffDelay = this._settings.backoffDelayMax; - } -}; - -Mopidy.prototype._resetBackoffDelay = function () { - this._backoffDelay = this._settings.backoffDelayMin; -}; - -Mopidy.prototype.close = function () { - this.off("state:offline", this._reconnect); - this._webSocket.close(); -}; - -Mopidy.prototype._handleWebSocketError = function (error) { - this._console.warn("WebSocket error:", error.stack || error); -}; - -Mopidy.prototype._send = function (message) { - switch (this._webSocket.readyState) { - case Mopidy.WebSocket.CONNECTING: - return when.reject( - new Mopidy.ConnectionError("WebSocket is still connecting")); - case Mopidy.WebSocket.CLOSING: - return when.reject( - new Mopidy.ConnectionError("WebSocket is closing")); - case Mopidy.WebSocket.CLOSED: - return when.reject( - new Mopidy.ConnectionError("WebSocket is closed")); - default: - var deferred = when.defer(); - message.jsonrpc = "2.0"; - message.id = this._nextRequestId(); - this._pendingRequests[message.id] = deferred.resolver; - this._webSocket.send(JSON.stringify(message)); - this.emit("websocket:outgoingMessage", message); - return deferred.promise; - } -}; - -Mopidy.prototype._nextRequestId = (function () { - var lastUsed = -1; - return function () { - lastUsed += 1; - return lastUsed; - }; -}()); - -Mopidy.prototype._handleMessage = function (message) { - try { - var data = JSON.parse(message.data); - if (data.hasOwnProperty("id")) { - this._handleResponse(data); - } else if (data.hasOwnProperty("event")) { - this._handleEvent(data); - } else { - this._console.warn( - "Unknown message type received. Message was: " + - message.data); - } - } catch (error) { - if (error instanceof SyntaxError) { - this._console.warn( - "WebSocket message parsing failed. Message was: " + - message.data); - } else { - throw error; - } - } -}; - -Mopidy.prototype._handleResponse = function (responseMessage) { - if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) { - this._console.warn( - "Unexpected response received. Message was:", responseMessage); - return; - } - - var error; - var resolver = this._pendingRequests[responseMessage.id]; - delete this._pendingRequests[responseMessage.id]; - - if (responseMessage.hasOwnProperty("result")) { - resolver.resolve(responseMessage.result); - } else if (responseMessage.hasOwnProperty("error")) { - error = new Mopidy.ServerError(responseMessage.error.message); - error.code = responseMessage.error.code; - error.data = responseMessage.error.data; - resolver.reject(error); - this._console.warn("Server returned error:", responseMessage.error); - } else { - error = new Error("Response without 'result' or 'error' received"); - error.data = {response: responseMessage}; - resolver.reject(error); - this._console.warn( - "Response without 'result' or 'error' received. Message was:", - responseMessage); - } -}; - -Mopidy.prototype._handleEvent = function (eventMessage) { - var type = eventMessage.event; - var data = eventMessage; - delete data.event; - - this.emit("event:" + this._snakeToCamel(type), data); -}; - -Mopidy.prototype._getApiSpec = function () { - return this._send({method: "core.describe"}) - .then(this._createApi.bind(this)) - .catch(this._handleWebSocketError); -}; - -Mopidy.prototype._createApi = function (methods) { - var byPositionOrByName = ( - this._settings.callingConvention === "by-position-or-by-name"); - - var caller = function (method) { - return function () { - var message = {method: method}; - if (arguments.length === 0) { - return this._send(message); - } - if (!byPositionOrByName) { - message.params = Array.prototype.slice.call(arguments); - return this._send(message); - } - if (arguments.length > 1) { - return when.reject(new Error( - "Expected zero arguments, a single array, " + - "or a single object.")); - } - if (!Array.isArray(arguments[0]) && - arguments[0] !== Object(arguments[0])) { - return when.reject(new TypeError( - "Expected an array or an object.")); - } - message.params = arguments[0]; - return this._send(message); - }.bind(this); - }.bind(this); - - var getPath = function (fullName) { - var path = fullName.split("."); - if (path.length >= 1 && path[0] === "core") { - path = path.slice(1); - } - return path; - }; - - var createObjects = function (objPath) { - var parentObj = this; - objPath.forEach(function (objName) { - objName = this._snakeToCamel(objName); - parentObj[objName] = parentObj[objName] || {}; - parentObj = parentObj[objName]; - }.bind(this)); - return parentObj; - }.bind(this); - - var createMethod = function (fullMethodName) { - var methodPath = getPath(fullMethodName); - var methodName = this._snakeToCamel(methodPath.slice(-1)[0]); - var object = createObjects(methodPath.slice(0, -1)); - object[methodName] = caller(fullMethodName); - object[methodName].description = methods[fullMethodName].description; - object[methodName].params = methods[fullMethodName].params; - }.bind(this); - - Object.keys(methods).forEach(createMethod); - this.emit("state:online"); -}; - -Mopidy.prototype._snakeToCamel = function (name) { - return name.replace(/(_[a-z])/g, function (match) { - return match.toUpperCase().replace("_", ""); - }); -}; - -module.exports = Mopidy; diff --git a/js/test/bind-helper.js b/js/test/bind-helper.js deleted file mode 100644 index a5a3e0f4..00000000 --- a/js/test/bind-helper.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * PhantomJS 1.6 does not support Function.prototype.bind, so we polyfill it. - * - * Implementation from: - * https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind - */ -if (!Function.prototype.bind) { - Function.prototype.bind = function (oThis) { - if (typeof this !== "function") { - // closest thing possible to the ECMAScript 5 internal IsCallable function - throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable"); - } - - var aArgs = Array.prototype.slice.call(arguments, 1), - fToBind = this, - fNOP = function () {}, - fBound = function () { - return fToBind.apply(this instanceof fNOP && oThis - ? this - : oThis, - aArgs.concat(Array.prototype.slice.call(arguments))); - }; - - fNOP.prototype = this.prototype; - fBound.prototype = new fNOP(); - - return fBound; - }; -} diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js deleted file mode 100644 index caf4ce21..00000000 --- a/js/test/mopidy-test.js +++ /dev/null @@ -1,964 +0,0 @@ -/*global require:false */ - -if (typeof module === "object" && typeof require === "function") { - var buster = require("buster"); - var Mopidy = require("../src/mopidy"); - var when = require("when"); -} - -var assert = buster.assert; -var refute = buster.refute; - -buster.testCase("Mopidy", { - setUp: function () { - // Sinon.JS doesn't manage to stub PhantomJS' WebSocket implementation, - // so we replace it with a dummy temporarily. - var fakeWebSocket = function () { - return { - send: function () {}, - close: function () {} - }; - }; - fakeWebSocket.CONNECTING = 0; - fakeWebSocket.OPEN = 1; - fakeWebSocket.CLOSING = 2; - fakeWebSocket.CLOSED = 3; - - this.realWebSocket = Mopidy.WebSocket; - Mopidy.WebSocket = fakeWebSocket; - - this.webSocketConstructorStub = this.stub(Mopidy, "WebSocket"); - - this.webSocket = { - close: this.stub(), - send: this.stub() - }; - this.mopidy = new Mopidy({ - callingConvention: "by-position-or-by-name", - webSocket: this.webSocket - }); - }, - - tearDown: function () { - Mopidy.WebSocket = this.realWebSocket; - }, - - "constructor": { - "connects when autoConnect is true": function () { - new Mopidy({ - autoConnect: true, - callingConvention: "by-position-or-by-name" - }); - - var currentHost = typeof document !== "undefined" && - document.location.host || "localhost"; - - assert.calledOnceWith(this.webSocketConstructorStub, - "ws://" + currentHost + "/mopidy/ws"); - }, - - "does not connect when autoConnect is false": function () { - new Mopidy({ - autoConnect: false, - callingConvention: "by-position-or-by-name" - }); - - refute.called(this.webSocketConstructorStub); - }, - - "does not connect when passed a WebSocket": function () { - new Mopidy({ - callingConvention: "by-position-or-by-name", - webSocket: {} - }); - - refute.called(this.webSocketConstructorStub); - }, - - "defaults to by-position-only calling convention": function () { - var console = { - warn: function () {} - }; - var mopidy = new Mopidy({ - console: console, - webSocket: this.webSocket, - }); - - assert.equals( - mopidy._settings.callingConvention, - "by-position-only"); - }, - - "warns if no calling convention explicitly selected": function () { - var console = { - warn: function () {} - }; - var stub = this.stub(console, "warn"); - - new Mopidy({console: console}); - - assert.calledOnceWith( - stub, - "Mopidy.js is using the default calling convention. The " + - "default will change in the future. You should explicitly " + - "specify which calling convention you use."); - }, - - "does not warn if calling convention chosen explicitly": function () { - var console = { - warn: function () {} - }; - var stub = this.stub(console, "warn"); - - new Mopidy({ - callingConvention: "by-position-or-by-name", - console: console - }); - - refute.called(stub); - }, - - "works without 'new' keyword": function () { - var mopidyConstructor = Mopidy; // To trick jshint into submission - - var mopidy = mopidyConstructor({ - callingConvention: "by-position-or-by-name", - webSocket: {} - }); - - assert.isObject(mopidy); - assert(mopidy instanceof Mopidy); - } - }, - - ".connect": { - "connects when autoConnect is false": function () { - var mopidy = new Mopidy({ - autoConnect: false, - callingConvention: "by-position-or-by-name" - }); - refute.called(this.webSocketConstructorStub); - - mopidy.connect(); - - var currentHost = typeof document !== "undefined" && - document.location.host || "localhost"; - - assert.calledOnceWith(this.webSocketConstructorStub, - "ws://" + currentHost + "/mopidy/ws"); - }, - - "does nothing when the WebSocket is open": function () { - this.webSocket.readyState = Mopidy.WebSocket.OPEN; - var mopidy = new Mopidy({ - callingConvention: "by-position-or-by-name", - webSocket: this.webSocket - }); - - mopidy.connect(); - - refute.called(this.webSocket.close); - refute.called(this.webSocketConstructorStub); - } - }, - - "WebSocket events": { - "emits 'websocket:close' when connection is closed": function () { - var spy = this.spy(); - this.mopidy.off("websocket:close"); - this.mopidy.on("websocket:close", spy); - - var closeEvent = {}; - this.webSocket.onclose(closeEvent); - - assert.calledOnceWith(spy, closeEvent); - }, - - "emits 'websocket:error' when errors occurs": function () { - var spy = this.spy(); - this.mopidy.off("websocket:error"); - this.mopidy.on("websocket:error", spy); - - var errorEvent = {}; - this.webSocket.onerror(errorEvent); - - assert.calledOnceWith(spy, errorEvent); - }, - - "emits 'websocket:incomingMessage' when a message arrives": function () { - var spy = this.spy(); - this.mopidy.off("websocket:incomingMessage"); - this.mopidy.on("websocket:incomingMessage", spy); - - var messageEvent = {data: "this is a message"}; - this.webSocket.onmessage(messageEvent); - - assert.calledOnceWith(spy, messageEvent); - }, - - "emits 'websocket:open' when connection is opened": function () { - var spy = this.spy(); - this.mopidy.off("websocket:open"); - this.mopidy.on("websocket:open", spy); - - this.webSocket.onopen(); - - assert.calledOnceWith(spy); - } - }, - - "._cleanup": { - setUp: function () { - this.mopidy.off("state:offline"); - }, - - "is called on 'websocket:close' event": function () { - var closeEvent = {}; - var stub = this.stub(this.mopidy, "_cleanup"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("websocket:close", closeEvent); - - assert.calledOnceWith(stub, closeEvent); - }, - - "rejects all pending requests": function (done) { - var closeEvent = {}; - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); - - var promise1 = this.mopidy._send({method: "foo"}); - var promise2 = this.mopidy._send({method: "bar"}); - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 2); - - this.mopidy._cleanup(closeEvent); - - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); - when.settle([promise1, promise2]).done( - done(function (descriptors) { - assert.equals(descriptors.length, 2); - descriptors.forEach(function (d) { - assert.equals(d.state, "rejected"); - assert(d.reason instanceof Error); - assert(d.reason instanceof Mopidy.ConnectionError); - assert.equals(d.reason.message, "WebSocket closed"); - assert.same(d.reason.closeEvent, closeEvent); - }); - }) - ); - }, - - "emits 'state:offline' event when done": function () { - var spy = this.spy(); - this.mopidy.on("state:offline", spy); - - this.mopidy._cleanup({}); - - assert.calledOnceWith(spy); - } - }, - - "._reconnect": { - "is called when the state changes to offline": function () { - var stub = this.stub(this.mopidy, "_reconnect"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("state:offline"); - - assert.calledOnceWith(stub); - }, - - "tries to connect after an increasing backoff delay": function () { - var clock = this.useFakeTimers(); - var connectStub = this.stub(this.mopidy, "connect"); - var pendingSpy = this.spy(); - this.mopidy.on("reconnectionPending", pendingSpy); - var reconnectingSpy = this.spy(); - this.mopidy.on("reconnecting", reconnectingSpy); - - refute.called(connectStub); - - this.mopidy._reconnect(); - assert.calledOnceWith(pendingSpy, {timeToAttempt: 1000}); - clock.tick(0); - refute.called(connectStub); - clock.tick(1000); - assert.calledOnceWith(reconnectingSpy); - assert.calledOnce(connectStub); - - pendingSpy.reset(); - reconnectingSpy.reset(); - this.mopidy._reconnect(); - assert.calledOnceWith(pendingSpy, {timeToAttempt: 2000}); - assert.calledOnce(connectStub); - clock.tick(0); - assert.calledOnce(connectStub); - clock.tick(1000); - assert.calledOnce(connectStub); - clock.tick(1000); - assert.calledOnceWith(reconnectingSpy); - assert.calledTwice(connectStub); - - pendingSpy.reset(); - reconnectingSpy.reset(); - this.mopidy._reconnect(); - assert.calledOnceWith(pendingSpy, {timeToAttempt: 4000}); - assert.calledTwice(connectStub); - clock.tick(0); - assert.calledTwice(connectStub); - clock.tick(2000); - assert.calledTwice(connectStub); - clock.tick(2000); - assert.calledOnceWith(reconnectingSpy); - assert.calledThrice(connectStub); - }, - - "tries to connect at least about once per minute": function () { - var clock = this.useFakeTimers(); - var connectStub = this.stub(this.mopidy, "connect"); - var pendingSpy = this.spy(); - this.mopidy.on("reconnectionPending", pendingSpy); - this.mopidy._backoffDelay = this.mopidy._settings.backoffDelayMax; - - refute.called(connectStub); - - this.mopidy._reconnect(); - assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000}); - clock.tick(0); - refute.called(connectStub); - clock.tick(64000); - assert.calledOnce(connectStub); - - pendingSpy.reset(); - this.mopidy._reconnect(); - assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000}); - assert.calledOnce(connectStub); - clock.tick(0); - assert.calledOnce(connectStub); - clock.tick(64000); - assert.calledTwice(connectStub); - } - }, - - "._resetBackoffDelay": { - "is called on 'websocket:open' event": function () { - var stub = this.stub(this.mopidy, "_resetBackoffDelay"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("websocket:open"); - - assert.calledOnceWith(stub); - }, - - "resets the backoff delay to the minimum value": function () { - this.mopidy._backoffDelay = this.mopidy._backoffDelayMax; - - this.mopidy._resetBackoffDelay(); - - assert.equals(this.mopidy._backoffDelay, - this.mopidy._settings.backoffDelayMin); - } - }, - - "close": { - "unregisters reconnection hooks": function () { - this.stub(this.mopidy, "off"); - - this.mopidy.close(); - - assert.calledOnceWith( - this.mopidy.off, "state:offline", this.mopidy._reconnect); - }, - - "closes the WebSocket": function () { - this.mopidy.close(); - - assert.calledOnceWith(this.mopidy._webSocket.close); - } - }, - - "._handleWebSocketError": { - "is called on 'websocket:error' event": function () { - var error = {}; - var stub = this.stub(this.mopidy, "_handleWebSocketError"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("websocket:error", error); - - assert.calledOnceWith(stub, error); - }, - - "without stack logs the error to the console": function () { - var stub = this.stub(this.mopidy._console, "warn"); - var error = {}; - - this.mopidy._handleWebSocketError(error); - - assert.calledOnceWith(stub, "WebSocket error:", error); - }, - - "with stack logs the error to the console": function () { - var stub = this.stub(this.mopidy._console, "warn"); - var error = {stack: "foo"}; - - this.mopidy._handleWebSocketError(error); - - assert.calledOnceWith(stub, "WebSocket error:", error.stack); - } - }, - - "._send": { - "adds JSON-RPC fields to the message": function () { - this.stub(this.mopidy, "_nextRequestId").returns(1); - var stub = this.stub(JSON, "stringify"); - - this.mopidy._send({method: "foo"}); - - assert.calledOnceWith(stub, { - jsonrpc: "2.0", - id: 1, - method: "foo" - }); - }, - - "adds a resolver to the pending requests queue": function () { - this.stub(this.mopidy, "_nextRequestId").returns(1); - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); - - this.mopidy._send({method: "foo"}); - - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1); - assert.isFunction(this.mopidy._pendingRequests[1].resolve); - }, - - "sends message on the WebSocket": function () { - refute.called(this.mopidy._webSocket.send); - - this.mopidy._send({method: "foo"}); - - assert.calledOnce(this.mopidy._webSocket.send); - }, - - "emits a 'websocket:outgoingMessage' event": function () { - var spy = this.spy(); - this.mopidy.on("websocket:outgoingMessage", spy); - this.stub(this.mopidy, "_nextRequestId").returns(1); - - this.mopidy._send({method: "foo"}); - - assert.calledOnceWith(spy, { - jsonrpc: "2.0", - id: 1, - method: "foo" - }); - }, - - "immediately rejects request if CONNECTING": function (done) { - this.mopidy._webSocket.readyState = Mopidy.WebSocket.CONNECTING; - - var promise = this.mopidy._send({method: "foo"}); - - refute.called(this.mopidy._webSocket.send); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof Mopidy.ConnectionError); - assert.equals( - error.message, "WebSocket is still connecting"); - }) - ); - }, - - "immediately rejects request if CLOSING": function (done) { - this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSING; - - var promise = this.mopidy._send({method: "foo"}); - - refute.called(this.mopidy._webSocket.send); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof Mopidy.ConnectionError); - assert.equals(error.message, "WebSocket is closing"); - }) - ); - }, - - "immediately rejects request if CLOSED": function (done) { - this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSED; - - var promise = this.mopidy._send({method: "foo"}); - - refute.called(this.mopidy._webSocket.send); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof Mopidy.ConnectionError); - assert.equals(error.message, "WebSocket is closed"); - }) - ); - } - }, - - "._nextRequestId": { - "returns an ever increasing ID": function () { - var base = this.mopidy._nextRequestId(); - assert.equals(this.mopidy._nextRequestId(), base + 1); - assert.equals(this.mopidy._nextRequestId(), base + 2); - assert.equals(this.mopidy._nextRequestId(), base + 3); - } - }, - - "._handleMessage": { - "is called on 'websocket:incomingMessage' event": function () { - var messageEvent = {}; - var stub = this.stub(this.mopidy, "_handleMessage"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("websocket:incomingMessage", messageEvent); - - assert.calledOnceWith(stub, messageEvent); - }, - - "passes JSON-RPC responses on to _handleResponse": function () { - var stub = this.stub(this.mopidy, "_handleResponse"); - var message = { - jsonrpc: "2.0", - id: 1, - result: null - }; - var messageEvent = {data: JSON.stringify(message)}; - - this.mopidy._handleMessage(messageEvent); - - assert.calledOnceWith(stub, message); - }, - - "passes events on to _handleEvent": function () { - var stub = this.stub(this.mopidy, "_handleEvent"); - var message = { - event: "track_playback_started", - track: {} - }; - var messageEvent = {data: JSON.stringify(message)}; - - this.mopidy._handleMessage(messageEvent); - - assert.calledOnceWith(stub, message); - }, - - "logs unknown messages": function () { - var stub = this.stub(this.mopidy._console, "warn"); - var messageEvent = {data: JSON.stringify({foo: "bar"})}; - - this.mopidy._handleMessage(messageEvent); - - assert.calledOnceWith(stub, - "Unknown message type received. Message was: " + - messageEvent.data); - }, - - "logs JSON parsing errors": function () { - var stub = this.stub(this.mopidy._console, "warn"); - var messageEvent = {data: "foobarbaz"}; - - this.mopidy._handleMessage(messageEvent); - - assert.calledOnceWith(stub, - "WebSocket message parsing failed. Message was: " + - messageEvent.data); - } - }, - - "._handleResponse": { - "logs unexpected responses": function () { - var stub = this.stub(this.mopidy._console, "warn"); - var responseMessage = { - jsonrpc: "2.0", - id: 1337, - result: null - }; - - this.mopidy._handleResponse(responseMessage); - - assert.calledOnceWith(stub, - "Unexpected response received. Message was:", responseMessage); - }, - - "removes the matching request from the pending queue": function () { - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); - this.mopidy._send({method: "bar"}); - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1); - - this.mopidy._handleResponse({ - jsonrpc: "2.0", - id: Object.keys(this.mopidy._pendingRequests)[0], - result: "baz" - }); - - assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); - }, - - "resolves requests which get results back": function (done) { - var promise = this.mopidy._send({method: "bar"}); - var responseResult = {}; - var responseMessage = { - jsonrpc: "2.0", - id: Object.keys(this.mopidy._pendingRequests)[0], - result: responseResult - }; - - this.mopidy._handleResponse(responseMessage); - promise.then(done(function (result) { - assert.equals(result, responseResult); - }), done(function () { - assert(false); - })); - }, - - "rejects and logs requests which get errors back": function (done) { - var stub = this.stub(this.mopidy._console, "warn"); - var promise = this.mopidy._send({method: "bar"}); - var responseError = { - code: -32601, - message: "Method not found", - data: {} - }; - var responseMessage = { - jsonrpc: "2.0", - id: Object.keys(this.mopidy._pendingRequests)[0], - error: responseError - }; - - this.mopidy._handleResponse(responseMessage); - - assert.calledOnceWith(stub, - "Server returned error:", responseError); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert.equals(error.code, responseError.code); - assert.equals(error.message, responseError.message); - assert.equals(error.data, responseError.data); - }) - ); - }, - - "rejects and logs requests which get errors without data": function (done) { - var stub = this.stub(this.mopidy._console, "warn"); - var promise = this.mopidy._send({method: "bar"}); - var responseError = { - code: -32601, - message: "Method not found" - // 'data' key intentionally missing - }; - var responseMessage = { - jsonrpc: "2.0", - id: Object.keys(this.mopidy._pendingRequests)[0], - error: responseError - }; - - this.mopidy._handleResponse(responseMessage); - - assert.calledOnceWith(stub, - "Server returned error:", responseError); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof Mopidy.ServerError); - assert.equals(error.code, responseError.code); - assert.equals(error.message, responseError.message); - refute.defined(error.data); - }) - ); - }, - - "rejects and logs responses without result or error": function (done) { - var stub = this.stub(this.mopidy._console, "warn"); - var promise = this.mopidy._send({method: "bar"}); - var responseMessage = { - jsonrpc: "2.0", - id: Object.keys(this.mopidy._pendingRequests)[0] - }; - - this.mopidy._handleResponse(responseMessage); - - assert.calledOnceWith(stub, - "Response without 'result' or 'error' received. Message was:", - responseMessage); - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert.equals( - error.message, - "Response without 'result' or 'error' received"); - assert.equals(error.data.response, responseMessage); - }) - ); - } - }, - - "._handleEvent": { - "emits server side even on Mopidy object": function () { - var spy = this.spy(); - this.mopidy.on(spy); - var track = {}; - var message = { - event: "track_playback_started", - track: track - }; - - this.mopidy._handleEvent(message); - - assert.calledOnceWith(spy, - "event:trackPlaybackStarted", {track: track}); - } - }, - - "._getApiSpec": { - "is called on 'websocket:open' event": function () { - var stub = this.stub(this.mopidy, "_getApiSpec"); - this.mopidy._delegateEvents(); - - this.mopidy.emit("websocket:open"); - - assert.calledOnceWith(stub); - }, - - "gets Api description from server and calls _createApi": function (done) { - var methods = {}; - var sendStub = this.stub(this.mopidy, "_send"); - sendStub.returns(when.resolve(methods)); - var _createApiStub = this.stub(this.mopidy, "_createApi"); - - this.mopidy._getApiSpec().then(done(function () { - assert.calledOnceWith(sendStub, {method: "core.describe"}); - assert.calledOnceWith(_createApiStub, methods); - })); - } - }, - - "._createApi": { - "can create an API with methods on the root object": function () { - refute.defined(this.mopidy.hello); - refute.defined(this.mopidy.hi); - - this.mopidy._createApi({ - hello: { - description: "Says hello", - params: [] - }, - hi: { - description: "Says hi", - params: [] - } - }); - - assert.isFunction(this.mopidy.hello); - assert.equals(this.mopidy.hello.description, "Says hello"); - assert.equals(this.mopidy.hello.params, []); - assert.isFunction(this.mopidy.hi); - assert.equals(this.mopidy.hi.description, "Says hi"); - assert.equals(this.mopidy.hi.params, []); - }, - - "can create an API with methods on a sub-object": function () { - refute.defined(this.mopidy.hello); - - this.mopidy._createApi({ - "hello.world": { - description: "Says hello to the world", - params: [] - } - }); - - assert.defined(this.mopidy.hello); - assert.isFunction(this.mopidy.hello.world); - }, - - "strips off 'core' from method paths": function () { - refute.defined(this.mopidy.hello); - - this.mopidy._createApi({ - "core.hello.world": { - description: "Says hello to the world", - params: [] - } - }); - - assert.defined(this.mopidy.hello); - assert.isFunction(this.mopidy.hello.world); - }, - - "converts snake_case to camelCase": function () { - refute.defined(this.mopidy.mightyGreetings); - - this.mopidy._createApi({ - "mighty_greetings.hello_world": { - description: "Says hello to the world", - params: [] - } - }); - - assert.defined(this.mopidy.mightyGreetings); - assert.isFunction(this.mopidy.mightyGreetings.helloWorld); - }, - - "triggers 'state:online' event when API is ready for use": function () { - var spy = this.spy(); - this.mopidy.on("state:online", spy); - - this.mopidy._createApi({}); - - assert.calledOnceWith(spy); - }, - - "by-position-only calling convention": { - setUp: function () { - this.mopidy = new Mopidy({ - webSocket: this.webSocket, - callingConvention: "by-position-only" - }); - this.mopidy._createApi({ - foo: { - params: ["bar", "baz"] - } - }); - this.sendStub = this.stub(this.mopidy, "_send"); - - }, - - "sends no params if no arguments passed to function": function () { - this.mopidy.foo(); - - assert.calledOnceWith(this.sendStub, {method: "foo"}); - }, - - "sends messages with function arguments unchanged": function () { - this.mopidy.foo(31, 97); - - assert.calledOnceWith(this.sendStub, { - method: "foo", - params: [31, 97] - }); - }, - }, - - "by-position-or-by-name calling convention": { - setUp: function () { - this.mopidy = new Mopidy({ - webSocket: this.webSocket, - callingConvention: "by-position-or-by-name" - }); - this.mopidy._createApi({ - foo: { - params: ["bar", "baz"] - } - }); - this.sendStub = this.stub(this.mopidy, "_send"); - }, - - "must be turned on manually": function () { - assert.equals( - this.mopidy._settings.callingConvention, - "by-position-or-by-name"); - }, - - "sends no params if no arguments passed to function": function () { - this.mopidy.foo(); - - assert.calledOnceWith(this.sendStub, {method: "foo"}); - }, - - "sends by-position if argument is a list": function () { - this.mopidy.foo([31, 97]); - - assert.calledOnceWith(this.sendStub, { - method: "foo", - params: [31, 97] - }); - }, - - "sends by-name if argument is an object": function () { - this.mopidy.foo({bar: 31, baz: 97}); - - assert.calledOnceWith(this.sendStub, { - method: "foo", - params: {bar: 31, baz: 97} - }); - }, - - "rejects with error if more than one argument": function (done) { - var promise = this.mopidy.foo([1, 2], {c: 3, d: 4}); - - refute.called(this.sendStub); - - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert.equals( - error.message, - "Expected zero arguments, a single array, " + - "or a single object."); - }) - ); - }, - - "rejects with error if string": function (done) { - var promise = this.mopidy.foo("hello"); - - refute.called(this.sendStub); - - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof TypeError); - assert.equals( - error.message, "Expected an array or an object."); - }) - ); - }, - - "rejects with error if number": function (done) { - var promise = this.mopidy.foo(1337); - - refute.called(this.sendStub); - - promise.done( - done(function () { - assert(false); - }), - done(function (error) { - assert(error instanceof Error); - assert(error instanceof TypeError); - assert.equals( - error.message, "Expected an array or an object."); - }) - ); - } - } - } -}); From 4c6c7ce01609f4ce0175788426d0c92ca4c058ec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Sep 2014 21:50:11 +0200 Subject: [PATCH 078/495] http: Update Mopidy.js to v0.4.1 --- mopidy/http/data/mopidy.js | 2 +- mopidy/http/data/mopidy.min.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/http/data/mopidy.js b/mopidy/http/data/mopidy.js index af54f768..ce2f9763 100644 --- a/mopidy/http/data/mopidy.js +++ b/mopidy/http/data/mopidy.js @@ -1,4 +1,4 @@ -/*! Mopidy.js v0.4.0 - built 2014-06-24 +/*! Mopidy.js v0.4.1 - built 2014-09-11 * http://www.mopidy.com/ * Copyright (c) 2014 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ diff --git a/mopidy/http/data/mopidy.min.js b/mopidy/http/data/mopidy.min.js index ef3431b3..01f7ee3e 100644 --- a/mopidy/http/data/mopidy.min.js +++ b/mopidy/http/data/mopidy.min.js @@ -1,4 +1,4 @@ -/*! Mopidy.js v0.4.0 - built 2014-06-24 +/*! Mopidy.js v0.4.1 - built 2014-09-11 * http://www.mopidy.com/ * Copyright (c) 2014 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ From 40089f490ce3717ed8d62680ea255e5763675f0e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Sep 2014 22:10:23 +0200 Subject: [PATCH 079/495] docs: Update authors --- .mailmap | 1 + AUTHORS | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.mailmap b/.mailmap index d380162e..93a5573b 100644 --- a/.mailmap +++ b/.mailmap @@ -16,3 +16,4 @@ Janez Troha Luke Giuliani Colin Montgomerie Ignasi Fosch +Christopher Schirner diff --git a/AUTHORS b/AUTHORS index e36d953d..110a9ec4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -42,3 +42,5 @@ - Sam Willcocks - Ignasi Fosch - Arjun Naik +- Christopher Schirner +- Dmitry Sandalov From 8131f94530cb2274df5f1535622a4f8bff18a538 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Sep 2014 22:16:11 +0200 Subject: [PATCH 080/495] js: Remove references to js/ dir --- .gitignore | 2 -- MANIFEST.in | 4 ---- 2 files changed, 6 deletions(-) diff --git a/.gitignore b/.gitignore index 863c9796..0edb30e0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,6 @@ cover/ coverage.xml dist/ docs/_build/ -js/test/lib/ mopidy.log* -node_modules/ nosetests.xml xunit-*.xml diff --git a/MANIFEST.in b/MANIFEST.in index 000fc1ad..df9f8491 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -14,10 +14,6 @@ recursive-include data * recursive-include docs * prune docs/_build -recursive-include js * -prune js/node_modules -prune js/test/lib - recursive-include mopidy *.conf recursive-include mopidy/http/data * From 151986328e09910e57c9d18f2a209d7a53ce5050 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Sep 2014 11:31:05 +0200 Subject: [PATCH 081/495] tasks: Pass path and coverage on to test task --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 80b655b5..7b5692e3 100644 --- a/tasks.py +++ b/tasks.py @@ -13,7 +13,7 @@ def docs(watch=False, warn=False): @task def test(path=None, coverage=False, watch=False, warn=False): if watch: - return watcher(test) + return watcher(test, path=path, coverage=coverage) path = path or 'tests/' cmd = 'nosetests' if coverage: From c2810d042381129dd1c96457062178ef5947690e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Sep 2014 23:17:23 +0200 Subject: [PATCH 082/495] py3: Fix thread import --- mopidy/utils/process.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 0660efe0..2887e705 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -2,7 +2,10 @@ from __future__ import unicode_literals import logging import signal -import thread +try: + import _thread as thread # Python 3 +except ImportError: + import thread # Python 2 import threading from pykka import ActorDeadError From cd3d44ff6d69d7bb2e9dc81fc30bfd40a9463489 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Sep 2014 09:48:38 +0200 Subject: [PATCH 083/495] py3: Use '0o' octal literal --- mopidy/utils/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 5870fc6e..bad3b6c4 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -36,7 +36,7 @@ def get_or_create_dir(dir_path): '"%s", already exists.' % dir_path) elif not os.path.isdir(dir_path): logger.info('Creating dir %s', dir_path) - os.makedirs(dir_path, 0755) + os.makedirs(dir_path, 0o755) return dir_path From f58fe9a19206f944448ed77a33fb43874c622a0a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Sep 2014 20:13:30 +0200 Subject: [PATCH 084/495] py3: Replace xrange() with range() --- mopidy/audio/playlists.py | 2 +- mopidy/config/schemas.py | 6 +++--- tests/mpd/test_status.py | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 35e0800d..37ef2569 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -76,7 +76,7 @@ def parse_pls(data): for section in cp.sections(): if section.lower() != 'playlist': continue - for i in xrange(cp.getint(section, 'numberofentries')): + for i in range(cp.getint(section, 'numberofentries')): yield cp.get(section, 'file%d' % (i+1)) diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 12536c0c..3d997ffe 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -25,10 +25,10 @@ def _levenshtein(a, b): if n > m: return _levenshtein(b, a) - current = xrange(n + 1) - for i in xrange(1, m + 1): + current = range(n + 1) + for i in range(1, m + 1): previous, current = current, [i] + [0] * n - for j in xrange(1, n + 1): + for j in range(1, n + 1): add, delete = previous[j] + 1, current[j - 1] + 1 change = previous[j - 1] if a[j - 1] != b[i - 1]: diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index cd910340..6a455136 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -98,7 +98,8 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_contains_playlist(self): result = dict(status.status(self.context)) self.assertIn('playlist', result) - self.assertIn(int(result['playlist']), xrange(0, 2 ** 31 - 1)) + self.assertGreaterEqual(int(result['playlist']), 0) + self.assertLessEqual(int(result['playlist']), 2 ** 31 - 1) def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) From eedaf0eb3b10a5bf02350a2608cc599f82475bfe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Sep 2014 00:49:57 +0200 Subject: [PATCH 085/495] docs: Add Mopidy-Musicbox-Webclient to the web extensions list --- docs/clients/http.rst | 22 +----------------- .../musicbox_webclient.png} | Bin docs/ext/musicbox_webclient.rst | 21 +++++++++++++++++ docs/ext/web.rst | 3 +++ 4 files changed, 25 insertions(+), 21 deletions(-) rename docs/{clients/mopidy-musicbox-webclient.png => ext/musicbox_webclient.png} (100%) create mode 100644 docs/ext/musicbox_webclient.rst diff --git a/docs/clients/http.rst b/docs/clients/http.rst index bd7a39b0..5467f3b7 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -15,27 +15,7 @@ created one, please notify us so we can include your client on this page. See :ref:`http-api` for details on how to build your own web client. -Mopidy MusicBox Webclient -========================= - -.. image:: mopidy-musicbox-webclient.png - :width: 1275 - :height: 600 - -The first web client for Mopidy, made with jQuery Mobile by Wouter van Wijk. -Also the web client used for Wouter's popular `Pi Musicbox -`_ image for Raspberry Pi. - - With Mopidy MusicBox Webclient, you can play your music on your computer - (Raspberry Pi) and remotely control it from a computer, phone, tablet, - laptop. From your couch. - - This is a responsive HTML/JS/CSS client especially written for Mopidy, a - music server. Responsive, so it works on desktop and mobile browsers. You - can browse, search and play albums, artists, playlists, and it has cover - art from Last.fm. - - -- https://github.com/woutervanwijk/Mopidy-MusicBox-Webclient +.. include:: /ext/musicbox_webclient.rst .. include:: /ext/moped.rst diff --git a/docs/clients/mopidy-musicbox-webclient.png b/docs/ext/musicbox_webclient.png similarity index 100% rename from docs/clients/mopidy-musicbox-webclient.png rename to docs/ext/musicbox_webclient.png diff --git a/docs/ext/musicbox_webclient.rst b/docs/ext/musicbox_webclient.rst new file mode 100644 index 00000000..cdde6c10 --- /dev/null +++ b/docs/ext/musicbox_webclient.rst @@ -0,0 +1,21 @@ +Mopidy MusicBox Webclient +========================= + +.. image:: /ext/musicbox_webclient.png + :width: 1275 + :height: 600 + +The first web client for Mopidy, made with jQuery Mobile by Wouter van Wijk. +Also the web client used for Wouter's popular `Pi Musicbox +`_ image for Raspberry Pi. + + With Mopidy MusicBox Webclient, you can play your music on your computer + (Raspberry Pi) and remotely control it from a computer, phone, tablet, + laptop. From your couch. + + This is a responsive HTML/JS/CSS client especially written for Mopidy, a + music server. Responsive, so it works on desktop and mobile browsers. You + can browse, search and play albums, artists, playlists, and it has cover + art from Last.fm. + + -- https://github.com/woutervanwijk/Mopidy-MusicBox-Webclient diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 7df054cd..e9a2bb02 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -16,3 +16,6 @@ to show up here, follow the :ref:`guide on creating extensions `. .. include:: /ext/moped.rst + + +.. include:: /ext/musicbox_webclient.rst From 249dcf92c46d5a7d215c87b834475b84b7881550 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Sep 2014 00:55:33 +0200 Subject: [PATCH 086/495] docs: More concistent format for web extensions --- docs/ext/musicbox_webclient.rst | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/docs/ext/musicbox_webclient.rst b/docs/ext/musicbox_webclient.rst index cdde6c10..b70c8e84 100644 --- a/docs/ext/musicbox_webclient.rst +++ b/docs/ext/musicbox_webclient.rst @@ -1,21 +1,14 @@ -Mopidy MusicBox Webclient +Mopidy-MusicBox-Webclient ========================= -.. image:: /ext/musicbox_webclient.png - :width: 1275 - :height: 600 +https://github.com/woutervanwijk/Mopidy-MusicBox-Webclient The first web client for Mopidy, made with jQuery Mobile by Wouter van Wijk. Also the web client used for Wouter's popular `Pi Musicbox `_ image for Raspberry Pi. - With Mopidy MusicBox Webclient, you can play your music on your computer - (Raspberry Pi) and remotely control it from a computer, phone, tablet, - laptop. From your couch. - This is a responsive HTML/JS/CSS client especially written for Mopidy, a - music server. Responsive, so it works on desktop and mobile browsers. You - can browse, search and play albums, artists, playlists, and it has cover - art from Last.fm. - -- https://github.com/woutervanwijk/Mopidy-MusicBox-Webclient +.. image:: /ext/musicbox_webclient.png + :width: 1275 + :height: 600 From da18a599ac5a9c03931def4f6167e4000e0b3c0b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Sep 2014 00:57:34 +0200 Subject: [PATCH 087/495] docs: Add install instructions for the web extensions --- docs/ext/api_explorer.rst | 4 ++++ docs/ext/moped.rst | 8 ++++++-- docs/ext/musicbox_webclient.rst | 2 ++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/ext/api_explorer.rst b/docs/ext/api_explorer.rst index 351ddf15..36e9dfb6 100644 --- a/docs/ext/api_explorer.rst +++ b/docs/ext/api_explorer.rst @@ -7,6 +7,10 @@ https://github.com/dz0ny/mopidy-api-explorer Web extension for browsing the Mopidy HTTP API. +To install, run:: + + pip install Mopidy-API-Explorer + .. image:: /ext/api_explorer.png :width: 1176 :height: 713 diff --git a/docs/ext/moped.rst b/docs/ext/moped.rst index 28254ed7..a05d550f 100644 --- a/docs/ext/moped.rst +++ b/docs/ext/moped.rst @@ -1,10 +1,14 @@ -Moped -===== +Mopidy-Moped +============ https://github.com/martijnboland/moped A Mopidy web client made with AngularJS by Martijn Boland. +To install, run:: + + pip install Mopidy-Moped + .. image:: /ext/moped.png :width: 720 :height: 450 diff --git a/docs/ext/musicbox_webclient.rst b/docs/ext/musicbox_webclient.rst index b70c8e84..afa78685 100644 --- a/docs/ext/musicbox_webclient.rst +++ b/docs/ext/musicbox_webclient.rst @@ -7,7 +7,9 @@ The first web client for Mopidy, made with jQuery Mobile by Wouter van Wijk. Also the web client used for Wouter's popular `Pi Musicbox `_ image for Raspberry Pi. +To install, run:: + pip install Mopidy-MusicBox-Webclient .. image:: /ext/musicbox_webclient.png :width: 1275 From 0e2866df307a8a1aa54879afea1ab3725fa7eacd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Sep 2014 01:01:32 +0200 Subject: [PATCH 088/495] docs: Add Mopidy-HTTP-Kuechenradio --- docs/clients/http.rst | 3 +++ docs/ext/kuechenradio.rst | 15 +++++++++++++++ docs/ext/web.rst | 3 +++ 3 files changed, 21 insertions(+) create mode 100644 docs/ext/kuechenradio.rst diff --git a/docs/clients/http.rst b/docs/clients/http.rst index 5467f3b7..296a1886 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -21,6 +21,9 @@ See :ref:`http-api` for details on how to build your own web client. .. include:: /ext/moped.rst +.. include:: /ext/kuechenradio.rst + + JukePi ====== diff --git a/docs/ext/kuechenradio.rst b/docs/ext/kuechenradio.rst new file mode 100644 index 00000000..fb8df675 --- /dev/null +++ b/docs/ext/kuechenradio.rst @@ -0,0 +1,15 @@ +Mopidy-HTTP-Kuechenradio +========================= + +https://github.com/tkem/mopidy-http-kuechenradio + +A deliberately simple Mopidy Web client for mobile devices. Made with jQuery +Mobile by Thomas Kemmer. + +To install, run:: + + pip install Mopidy-HTTP-Kuechenradio + +.. image:: /ext/kuechenradio.png + :width: 1275 + :height: 600 diff --git a/docs/ext/web.rst b/docs/ext/web.rst index e9a2bb02..8a72c7e1 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -15,6 +15,9 @@ to show up here, follow the :ref:`guide on creating extensions `. .. include:: /ext/api_explorer.rst +.. include:: /ext/kuechenradio.rst + + .. include:: /ext/moped.rst From 580e5b079fb31b1ba6df6bfd5235272939e2dfc2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Sep 2014 01:03:48 +0200 Subject: [PATCH 089/495] docs: Kuechenradio doesn't have a screenshot yet --- docs/ext/kuechenradio.rst | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/ext/kuechenradio.rst b/docs/ext/kuechenradio.rst index fb8df675..5288caeb 100644 --- a/docs/ext/kuechenradio.rst +++ b/docs/ext/kuechenradio.rst @@ -9,7 +9,3 @@ Mobile by Thomas Kemmer. To install, run:: pip install Mopidy-HTTP-Kuechenradio - -.. image:: /ext/kuechenradio.png - :width: 1275 - :height: 600 From 52baf639929fd5b94169c5fb179330f900d2fd7e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Sep 2014 01:05:34 +0200 Subject: [PATCH 090/495] docs: Put screenshot between description and install instructions --- docs/ext/api_explorer.rst | 8 ++++---- docs/ext/moped.rst | 8 ++++---- docs/ext/musicbox_webclient.rst | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/ext/api_explorer.rst b/docs/ext/api_explorer.rst index 36e9dfb6..3049a647 100644 --- a/docs/ext/api_explorer.rst +++ b/docs/ext/api_explorer.rst @@ -7,10 +7,10 @@ https://github.com/dz0ny/mopidy-api-explorer Web extension for browsing the Mopidy HTTP API. -To install, run:: - - pip install Mopidy-API-Explorer - .. image:: /ext/api_explorer.png :width: 1176 :height: 713 + +To install, run:: + + pip install Mopidy-API-Explorer diff --git a/docs/ext/moped.rst b/docs/ext/moped.rst index a05d550f..573a6519 100644 --- a/docs/ext/moped.rst +++ b/docs/ext/moped.rst @@ -5,10 +5,10 @@ https://github.com/martijnboland/moped A Mopidy web client made with AngularJS by Martijn Boland. -To install, run:: - - pip install Mopidy-Moped - .. image:: /ext/moped.png :width: 720 :height: 450 + +To install, run:: + + pip install Mopidy-Moped diff --git a/docs/ext/musicbox_webclient.rst b/docs/ext/musicbox_webclient.rst index afa78685..83cc4e37 100644 --- a/docs/ext/musicbox_webclient.rst +++ b/docs/ext/musicbox_webclient.rst @@ -7,10 +7,10 @@ The first web client for Mopidy, made with jQuery Mobile by Wouter van Wijk. Also the web client used for Wouter's popular `Pi Musicbox `_ image for Raspberry Pi. -To install, run:: - - pip install Mopidy-MusicBox-Webclient - .. image:: /ext/musicbox_webclient.png :width: 1275 :height: 600 + +To install, run:: + + pip install Mopidy-MusicBox-Webclient From 456faee9483e9b8407d0fd4c8dd472c4ae4e20cd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Sep 2014 01:08:04 +0200 Subject: [PATCH 091/495] docs: Remove duplicate extensions --- docs/ext/backends.rst | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 2b516f55..5b7c99bb 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -106,24 +106,6 @@ Extension for Mopidy-Podcast that lets you search and browse podcasts from the Apple iTunes Store. -Mopidy-Podcast-gpodder.net -========================== - -https://github.com/tkem/mopidy-podcast-gpodder - -Extension for Mopidy-Podcast that lets you search and browse podcasts from the -`gpodder.net `_ web site. - - -Mopidy-Podcast-iTunes -===================== - -https://github.com/tkem/mopidy-podcast-itunes - -Extension for Mopidy-Podcast that lets you search and browse podcasts from the -Apple iTunes Store. - - Mopidy-radio-de =============== From 00c17ae1937107fa9bff6235c5f84b9882e6670f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 21 Sep 2014 21:01:11 +0200 Subject: [PATCH 092/495] audio: Deprecate emit_end_of_stream --- docs/changelog.rst | 3 +++ mopidy/audio/actor.py | 25 ++++++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0ff362f8..9611d49d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -25,6 +25,9 @@ v0.20.0 (UNRELEASED) **Audio** +- Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a + :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` end the stream. + - Internal code cleanup within audio subsystem: - Started splitting audio code into smaller better defined pieces. diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 5cea9f7b..51b30f91 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -131,10 +131,11 @@ class _Appsrc(object): self._source = source def push(self, buffer_): - return self._source.emit('push-buffer', buffer_) == gst.FLOW_OK - - def end_of_stream(self): - self._source.emit('end-of-stream') + if buffer_ is None: + gst_logger.debug('Sending appsrc end-of-stream event.') + return self._source.emit('end-of-stream') == gst.FLOW_OK + else: + return self._source.emit('push-buffer', buffer_) == gst.FLOW_OK def _on_signal(self, element, clocktime, func): # This shim is used to ensure we always return true, and also handles @@ -560,12 +561,16 @@ class Audio(pykka.ThreadingActor): """ Call this to deliver raw audio data to be played. - Note that the uri must be set to ``appsrc://`` for this to work. + If the buffer is :class:`None`, the end-of-stream token is put on the + playbin. We will get a GStreamer message when the stream playback + reaches the token, and can then do any end-of-stream related tasks. - Returns true if data was delivered. + Note that the URI must be set to ``appsrc://`` for this to work. + + Returns :class:`True` if data was delivered. :param buffer_: buffer to pass to appsrc - :type buffer_: :class:`gst.Buffer` + :type buffer_: :class:`gst.Buffer` or :class:`None` :rtype: boolean """ return self._appsrc.push(buffer_) @@ -577,9 +582,11 @@ class Audio(pykka.ThreadingActor): We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. + + .. deprecated:: 0.20 + Use :meth:`emit_data` with a :class:`None` buffer instead. """ - self._appsrc.end_of_stream() - gst_logger.debug('Sent appsrc end-of-stream event.') + self._appsrc.push(None) def set_about_to_finish_callback(self, callback): """ From 713bca384a4eebd63b2d0fbe4eabf7ffb2ad4191 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 21 Sep 2014 23:24:51 +0200 Subject: [PATCH 093/495] docs: Update Mopidy-HTTP docs wrt the HTTP server-side API --- docs/ext/http.rst | 54 ++++++++++++++++------------------------------- 1 file changed, 18 insertions(+), 36 deletions(-) diff --git a/docs/ext/http.rst b/docs/ext/http.rst index 0175fe1d..a006c770 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -23,50 +23,25 @@ When it is enabled it starts a web server at the port specified by the takes care or user authentication. You have been warned. -Using a web based Mopidy client -=============================== +Hosting web clients +=================== -Mopidy-HTTP's web server can also host any static files, for example the HTML, -CSS, JavaScript, and images needed for a web based Mopidy client. To host -static files, change the :confval:`http/static_dir` config value to point to -the root directory of your web client, for example:: +Mopidy-HTTP's web server can also host Tornado apps or any static files, for +example the HTML, CSS, JavaScript, and images needed for a web based Mopidy +client. See :ref:`http-server-api` for how to make static files or server-side +functionality from a Mopidy extension available through Mopidy's web server. - [http] - static_dir = /home/alice/dev/the-client - -If the directory includes a file named ``index.html``, it will be served on the -root of Mopidy's web server. - -If you're making a web based client and wants to do server side development as -well, you are of course free to run your own web server and just use Mopidy's -web server to host the API end points. But, for clients implemented purely in -JavaScript, letting Mopidy host the files is a simpler solution. +If you're making a web based client and wants to do server side development +using some other technology than Tornado, you are of course free to run your +own web server and just use Mopidy's web server to host the API endpoints. +But, for clients implemented purely in JavaScript, letting Mopidy host the +files is a simpler solution. See :ref:`http-api` for details on how to integrate with Mopidy over HTTP. If you're looking for a web based client for Mopidy, go check out :ref:`http-clients`. -Extending the server's functionality -==================================== - -If you wish to extend the server with additional server side functionality you -must create class that implements the :class:`mopidy.http.Router` interface and -install it in the extension registry under the ``http:router`` name. - -The default implementation of :class:`mopidy.http.Router` already supports -serving static files. If you just want to serve static files you only need to -define the class variables :attr:`mopidy.http.Router.name` and -:attr:`mopidy.http.Router.path`. For example:: - - class MyWebClient(http.Router): - name = 'mywebclient' - path = os.path.join(os.path.dirname(__file__), 'public_html') - -If you wish to extend server with custom methods you can override the method -:meth:`mopidy.http.Router.setup_routes` and define custom routes. - - Configuration ============= @@ -103,6 +78,13 @@ See :ref:`config` for general help on configuring Mopidy. Change this to have Mopidy serve e.g. files for your JavaScript client. "/mopidy" will continue to work as usual even if you change this setting. + This config value isn't deprecated yet, but you're strongly encouraged to + make Mopidy extensions which use the the :ref:`http-server-api` to host + static files on Mopidy's web server instead of using + :confval:`http/static_dir`. That way, installation of your web client will + be a lot easier for your end users, and multiple web clients can easily + share the same web server. + .. confval:: http/zeroconf Name of the HTTP service when published through Zeroconf. The variables From 839108e7c56aaacebac349835448e40027abef91 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Sun, 21 Sep 2014 22:49:27 +0100 Subject: [PATCH 094/495] docs: typo in Mopidy-HTTP --- docs/ext/http.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ext/http.rst b/docs/ext/http.rst index a006c770..54d44ce0 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -31,7 +31,7 @@ example the HTML, CSS, JavaScript, and images needed for a web based Mopidy client. See :ref:`http-server-api` for how to make static files or server-side functionality from a Mopidy extension available through Mopidy's web server. -If you're making a web based client and wants to do server side development +If you're making a web based client and want to do server side development using some other technology than Tornado, you are of course free to run your own web server and just use Mopidy's web server to host the API endpoints. But, for clients implemented purely in JavaScript, letting Mopidy host the From 305a76486d59a4fd24f335b16c80854dd0a232df Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Sep 2014 21:42:57 +0200 Subject: [PATCH 095/495] models: Hide empty lists from repr() --- docs/changelog.rst | 4 ++++ mopidy/models.py | 2 ++ tests/test_models.py | 21 ++++++++------------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0ff362f8..04752b88 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -41,6 +41,10 @@ v0.20.0 (UNRELEASED) make sense for a server such as Mopidy. Currently the only way to find out if it is in use and will be missed is to go ahead and remove it. +**Models** + +- Hide empty collections from :func:`repr()` representations. + v0.19.4 (2014-09-01) ==================== diff --git a/mopidy/models.py b/mopidy/models.py index 42313922..83888ae5 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -29,6 +29,8 @@ class ImmutableObject(object): kwarg_pairs = [] for (key, value) in sorted(self.__dict__.items()): if isinstance(value, (frozenset, tuple)): + if not value: + continue value = list(value) kwarg_pairs.append('%s=%s' % (key, repr(value))) return '%(classname)s(%(kwargs)s)' % { diff --git a/tests/test_models.py b/tests/test_models.py index 43343ce7..448d6208 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -318,13 +318,12 @@ class AlbumTest(unittest.TestCase): def test_repr_without_artists(self): self.assertEquals( - "Album(artists=[], images=[], name=u'name', uri=u'uri')", + "Album(name=u'name', uri=u'uri')", repr(Album(uri='uri', name='name'))) def test_repr_with_artists(self): self.assertEquals( - "Album(artists=[Artist(name=u'foo')], images=[], name=u'name', " - "uri=u'uri')", + "Album(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", repr(Album(uri='uri', name='name', artists=[Artist(name='foo')]))) def test_serialize_without_artists(self): @@ -551,14 +550,12 @@ class TrackTest(unittest.TestCase): def test_repr_without_artists(self): self.assertEquals( - "Track(artists=[], composers=[], name=u'name', " - "performers=[], uri=u'uri')", + "Track(name=u'name', uri=u'uri')", repr(Track(uri='uri', name='name'))) def test_repr_with_artists(self): self.assertEquals( - "Track(artists=[Artist(name=u'foo')], composers=[], name=u'name', " - "performers=[], uri=u'uri')", + "Track(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", repr(Track(uri='uri', name='name', artists=[Artist(name='foo')]))) def test_serialize_without_artists(self): @@ -773,8 +770,7 @@ class TlTrackTest(unittest.TestCase): def test_repr(self): self.assertEquals( - "TlTrack(tlid=123, track=Track(artists=[], composers=[], " - "performers=[], uri=u'uri'))", + "TlTrack(tlid=123, track=Track(uri=u'uri'))", repr(TlTrack(tlid=123, track=Track(uri='uri')))) def test_serialize(self): @@ -903,13 +899,12 @@ class PlaylistTest(unittest.TestCase): def test_repr_without_tracks(self): self.assertEquals( - "Playlist(name=u'name', tracks=[], uri=u'uri')", + "Playlist(name=u'name', uri=u'uri')", repr(Playlist(uri='uri', name='name'))) def test_repr_with_tracks(self): self.assertEquals( - "Playlist(name=u'name', tracks=[Track(artists=[], composers=[], " - "name=u'foo', performers=[])], uri=u'uri')", + "Playlist(name=u'name', tracks=[Track(name=u'foo')], uri=u'uri')", repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')]))) def test_serialize_without_tracks(self): @@ -1036,7 +1031,7 @@ class SearchResultTest(unittest.TestCase): def test_repr_without_results(self): self.assertEquals( - "SearchResult(albums=[], artists=[], tracks=[], uri=u'uri')", + "SearchResult(uri=u'uri')", repr(SearchResult(uri='uri'))) def test_serialize_without_results(self): From bdd1fb983b3abbbb604686e63647a8aaaa5d97a0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Sep 2014 21:48:26 +0200 Subject: [PATCH 096/495] models: Fix equality for fields set to the default Fixes #837 --- docs/changelog.rst | 6 ++++++ mopidy/models.py | 8 ++++---- tests/test_models.py | 22 ++++++++++++++++++++-- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 04752b88..77a3f435 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -45,6 +45,12 @@ v0.20.0 (UNRELEASED) - Hide empty collections from :func:`repr()` representations. +- Field values are no longer stored on the model instance when the value + matches the default value for the field. This makes two models equal when + they have a field which in one case is implicitly set to the default value + and in the other case explicitly set to the default value, but with otherwise + equal fields. (Fixes: :issue:`837`) + v0.19.4 (2014-09-01) ==================== diff --git a/mopidy/models.py b/mopidy/models.py index 83888ae5..510cb56a 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -18,6 +18,8 @@ class ImmutableObject(object): raise TypeError( '__init__() got an unexpected keyword argument "%s"' % key) + if value == getattr(self, key): + continue # Don't explicitly set default values self.__dict__[key] = value def __setattr__(self, name, value): @@ -72,13 +74,11 @@ class ImmutableObject(object): for key in self.__dict__.keys(): public_key = key.lstrip('_') value = values.pop(public_key, self.__dict__[key]) - if value is not None: - data[public_key] = value + data[public_key] = value for key in values.keys(): if hasattr(self, key): value = values.pop(key) - if value is not None: - data[key] = value + data[key] = value if values: raise TypeError( 'copy() got an unexpected keyword argument "%s"' % key) diff --git a/tests/test_models.py b/tests/test_models.py index 448d6208..09610b99 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -171,8 +171,8 @@ class ArtistTest(unittest.TestCase): def test_serialize_falsy_values(self): self.assertDictEqual( - {'__model__': 'Artist', 'uri': '', 'name': None}, - Artist(uri='', name=None).serialize()) + {'__model__': 'Artist', 'uri': '', 'name': ''}, + Artist(uri='', name='').serialize()) def test_to_json_and_back(self): artist1 = Artist(uri='uri', name='name') @@ -735,6 +735,24 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) + def test_ignores_values_with_default_value_none(self): + track1 = Track(name='name1') + track2 = Track(name='name1', album=None) + self.assertEqual(track1, track2) + self.assertEqual(hash(track1), hash(track2)) + + def test_ignores_values_with_default_value_zero(self): + track1 = Track(name='name1') + track2 = Track(name='name1', track_no=0) + self.assertEqual(track1, track2) + self.assertEqual(hash(track1), hash(track2)) + + def test_copy_can_reset_to_default_value(self): + track1 = Track(name='name1') + track2 = Track(name='name1', album=Album()).copy(album=None) + self.assertEqual(track1, track2) + self.assertEqual(hash(track1), hash(track2)) + class TlTrackTest(unittest.TestCase): def test_tlid(self): From abed15b9e409aaa5f50a689012d875b0903313ea Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Sep 2014 22:25:42 +0200 Subject: [PATCH 097/495] models: Make all fields default to None or empty collection --- docs/changelog.rst | 4 ++++ mopidy/models.py | 12 ++++++------ mopidy/mpd/translator.py | 6 +++--- tests/test_models.py | 6 ------ 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 77a3f435..f50ff426 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -51,6 +51,10 @@ v0.20.0 (UNRELEASED) and in the other case explicitly set to the default value, but with otherwise equal fields. (Fixes: :issue:`837`) +- Changed the default value of :attr:`mopidy.models.Album.num_tracks`, + :attr:`mopidy.models.Track.track_no`, and + :attr:`mopidy.models.Track.last_modified` from ``0`` to :class:`None`. + v0.19.4 (2014-09-01) ==================== diff --git a/mopidy/models.py b/mopidy/models.py index 510cb56a..bedf8ca5 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -241,7 +241,7 @@ class Album(ImmutableObject): :param artists: album artists :type artists: list of :class:`Artist` :param num_tracks: number of tracks in album - :type num_tracks: integer + :type num_tracks: integer or :class:`None` if unknown :param num_discs: number of discs in album :type num_discs: integer or :class:`None` if unknown :param date: album release date (YYYY or YYYY-MM-DD) @@ -262,7 +262,7 @@ class Album(ImmutableObject): artists = frozenset() #: The number of tracks in the album. Read-only. - num_tracks = 0 + num_tracks = None #: The number of discs in the album. Read-only. num_discs = None @@ -302,7 +302,7 @@ class Track(ImmutableObject): :param genre: track genre :type genre: string :param track_no: track number in album - :type track_no: integer + :type track_no: integer or :class:`None` if unknown :param disc_no: disc number in album :type disc_no: integer or :class:`None` if unknown :param date: track release date (YYYY or YYYY-MM-DD) @@ -316,7 +316,7 @@ class Track(ImmutableObject): :param musicbrainz_id: MusicBrainz ID :type musicbrainz_id: string :param last_modified: Represents last modification time - :type last_modified: integer + :type last_modified: integer or :class:`None` if unknown """ #: The track URI. Read-only. @@ -341,7 +341,7 @@ class Track(ImmutableObject): genre = None #: The track number in the album. Read-only. - track_no = 0 + track_no = None #: The disc number in the album. Read-only. disc_no = None @@ -364,7 +364,7 @@ class Track(ImmutableObject): #: Integer representing when the track was last modified, exact meaning #: depends on source of track. For local files this is the mtime, for other #: backends it could be a timestamp or simply a version counter. - last_modified = 0 + last_modified = None def __init__(self, *args, **kwargs): get = lambda key: frozenset(kwargs.pop(key, None) or []) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 252725ee..f3264a46 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -44,11 +44,11 @@ def track_to_mpd_format(track, position=None): if track.date: result.append(('Date', track.date)) - if track.album is not None and track.album.num_tracks != 0: + if track.album is not None and track.album.num_tracks is not None: result.append(('Track', '%d/%d' % ( - track.track_no, track.album.num_tracks))) + track.track_no or 0, track.album.num_tracks))) else: - result.append(('Track', track.track_no)) + result.append(('Track', track.track_no or 0)) if position is not None and tlid is not None: result.append(('Pos', position)) result.append(('Id', tlid)) diff --git a/tests/test_models.py b/tests/test_models.py index 09610b99..56d6c76b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -741,12 +741,6 @@ class TrackTest(unittest.TestCase): self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) - def test_ignores_values_with_default_value_zero(self): - track1 = Track(name='name1') - track2 = Track(name='name1', track_no=0) - self.assertEqual(track1, track2) - self.assertEqual(hash(track1), hash(track2)) - def test_copy_can_reset_to_default_value(self): track1 = Track(name='name1') track2 = Track(name='name1', album=Album()).copy(album=None) From d5de898b2d6101ff06c9a1ed287f736ff9c9a13e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 01:10:51 +0200 Subject: [PATCH 098/495] core: Remove clear_current_track argument from stop() --- docs/changelog.rst | 6 ++++++ mopidy/core/playback.py | 21 ++++++++------------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a56e94b8..84ab58a8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,12 @@ This changelog is used to track all major changes to Mopidy. v0.20.0 (UNRELEASED) ==================== +**Core API** + +- Removed ``clear_current_track`` keyword argument to + :meth:`mopidy.core.Playback.stop`. It was a leaky internal abstraction, + which was never intended to be used externally. + **Commands** - Make the ``mopidy`` command print a friendly error message if the diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index df48422d..5cd2b65f 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -163,7 +163,8 @@ class PlaybackController(object): if next_tl_track: self.change_track(next_tl_track) else: - self.stop(clear_current_track=True) + self.stop() + self.current_tl_track = None self.core.tracklist.mark_played(original_tl_track) @@ -174,7 +175,8 @@ class PlaybackController(object): Used by :class:`mopidy.core.TracklistController`. """ if self.current_tl_track not in self.core.tracklist.tl_tracks: - self.stop(clear_current_track=True) + self.stop() + self.current_tl_track = None def next(self): """ @@ -190,7 +192,8 @@ class PlaybackController(object): # wait for state change? self.change_track(tl_track) else: - self.stop(clear_current_track=True) + self.stop() + self.current_tl_track = None def pause(self): """Pause playback.""" @@ -315,22 +318,14 @@ class PlaybackController(object): self._trigger_seeked(time_position) return success - def stop(self, clear_current_track=False): - """ - Stop playing. - - :param clear_current_track: whether to clear the current track _after_ - stopping - :type clear_current_track: boolean - """ + def stop(self): + """Stop playing.""" if self.state != PlaybackState.STOPPED: backend = self._get_backend() time_position_before_stop = self.time_position if not backend or backend.playback.stop().get(): self.state = PlaybackState.STOPPED self._trigger_track_playback_ended(time_position_before_stop) - if clear_current_track: - self.current_tl_track = None def _trigger_track_playback_paused(self): logger.debug('Triggering track playback paused event') From c629e105d760d438ad6a748c09647baeb567e973 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 14:29:30 +0200 Subject: [PATCH 099/495] docs: Update changelog with PR#840 --- docs/changelog.rst | 5 +++++ mopidy/local/__init__.py | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a56e94b8..ad83e0c0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,11 @@ v0.20.0 (UNRELEASED) - Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: :issue:`697`, PR: :issue:`802`) +- Local library API: Implementors of :meth:`mopidy.local.Library.lookup` should + now return a list of :class:`~mopidy.models.Track` instead of a single track, + just like the other ``lookup()`` methods in Mopidy. For now, returning a + single track will continue to work. (PR: :issue:`840`) + **MPD frontend** - In stored playlist names, replace "/", which are illegal, with "|" instead of diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 725e6783..fc1035fe 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -100,8 +100,8 @@ class Library(object): Lookup the given URI. :param string uri: track URI - :rtype: List of :class:`~mopidy.models.Track` or single - :class:`~mopidy.models.Track` for backward compatibility + :rtype: list of :class:`~mopidy.models.Track` (or single + :class:`~mopidy.models.Track` for backward compatibility) """ raise NotImplementedError From ed87ab8dd10f74ebd99b004651c0a8213527eb41 Mon Sep 17 00:00:00 2001 From: Arjun Naik Date: Mon, 28 Jul 2014 22:48:26 +0200 Subject: [PATCH 100/495] Added a playback history object to the core. --- mopidy/core/__init__.py | 1 + mopidy/core/actor.py | 7 +++++++ mopidy/core/history.py | 42 ++++++++++++++++++++++++++++++++++++++ mopidy/core/playback.py | 1 + tests/core/test_history.py | 27 ++++++++++++++++++++++++ 5 files changed, 78 insertions(+) create mode 100644 mopidy/core/history.py create mode 100644 tests/core/test_history.py diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index f49bbbe7..d857d4fb 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals # flake8: noqa from .actor import Core +from .history import TrackHistory from .library import LibraryController from .listener import CoreListener from .playback import PlaybackController, PlaybackState diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 66f2aa82..bb5058ee 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -7,6 +7,7 @@ import pykka from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState +from mopidy.core.history import TrackHistory from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener from mopidy.core.playback import PlaybackController @@ -23,6 +24,10 @@ class Core( """The library controller. An instance of :class:`mopidy.core.LibraryController`.""" + history = None + """The playback history. An instance of + :class:`mopidy.core.TrackHistory`""" + playback = None """The playback controller. An instance of :class:`mopidy.core.PlaybackController`.""" @@ -42,6 +47,8 @@ class Core( self.library = LibraryController(backends=self.backends, core=self) + self.history = TrackHistory() + self.playback = PlaybackController( mixer=mixer, backends=self.backends, core=self) diff --git a/mopidy/core/history.py b/mopidy/core/history.py new file mode 100644 index 00000000..7f617441 --- /dev/null +++ b/mopidy/core/history.py @@ -0,0 +1,42 @@ +from __future__ import unicode_literals + +import logging + +from mopidy.models import Track + + +logger = logging.getLogger(__name__) + + +class TrackHistory(): + track_list = [] + + def add_track(self, track): + """ + :param track: track to change to + :type track: :class:`mopidy.models.Track` + """ + if type(track) is not Track: + logger.warning('Cannot add non-Track type object to TrackHistory') + return + + # Reorder the track history if the track is already present. + if track in self.track_list: + self.track_list.remove(track) + self.track_list.insert(0, track) + + def get_history_size(self): + """ + Returns the number of tracks in the history. + :returns: The number of tracks in the history. + :rtype :int + """ + return len(self.track_list) + + def get_history(self): + """ + Returns the history. + :returns: The history as a list of `mopidy.models.Track` + :rtype: L{`mopidy.models.Track`} + """ + return self.track_list diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index df48422d..f0262830 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -248,6 +248,7 @@ class PlaybackController(object): if success: self.core.tracklist.mark_playing(tl_track) # TODO: replace with stream-changed + self.core.history.add_track(tl_track.track) self._trigger_track_playback_started() else: self.core.tracklist.mark_unplayable(tl_track) diff --git a/tests/core/test_history.py b/tests/core/test_history.py new file mode 100644 index 00000000..4e9b059c --- /dev/null +++ b/tests/core/test_history.py @@ -0,0 +1,27 @@ +import unittest + +from mopidy.core import TrackHistory + +from mopidy.models import Track + + +class PlaybackHistoryTest(unittest.TestCase): + def setUp(self): + self.tracks = [ + Track(uri='dummy1:a', name='foo'), + Track(uri='dummy2:a', name='foo'), + Track(uri='dummy3:a', name='bar') + ] + self.history = TrackHistory() + + def test_add_track(self): + self.history.add_track(self.tracks[0]) + self.assertEqual(self.history.get_history_size(), 1) + + def test_track_order(self): + self.history.add_track(self.tracks[0]) + self.history.add_track(self.tracks[1]) + self.history.add_track(self.tracks[2]) + self.history.add_track(self.tracks[0]) + self.assertEqual(self.history.get_history_size(), 3) + self.assertEqual(self.history.get_history()[0], self.tracks[0]) \ No newline at end of file From 9006968f75c47428cde9f024147ab517ae5c2625 Mon Sep 17 00:00:00 2001 From: Arjun Naik Date: Mon, 4 Aug 2014 01:07:26 +0200 Subject: [PATCH 101/495] TrackHistory stores Ref instances. Timestamp as epoch in milliseconds also stored. --- mopidy/core/__init__.py | 2 +- mopidy/core/actor.py | 4 ++-- mopidy/core/history.py | 29 ++++++++++++++++++-------- mopidy/core/playback.py | 2 +- tests/core/test_history.py | 42 +++++++++++++++++++++++++------------- 5 files changed, 52 insertions(+), 27 deletions(-) diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index d857d4fb..302c0a74 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals # flake8: noqa from .actor import Core -from .history import TrackHistory +from .history import History from .library import LibraryController from .listener import CoreListener from .playback import PlaybackController, PlaybackState diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index bb5058ee..46f94d00 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -7,7 +7,7 @@ import pykka from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState -from mopidy.core.history import TrackHistory +from mopidy.core.history import History from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener from mopidy.core.playback import PlaybackController @@ -47,7 +47,7 @@ class Core( self.library = LibraryController(backends=self.backends, core=self) - self.history = TrackHistory() + self.history = History() self.playback = PlaybackController( mixer=mixer, backends=self.backends, core=self) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 7f617441..3b7b9d9f 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -1,17 +1,19 @@ from __future__ import unicode_literals +import copy +import datetime import logging -from mopidy.models import Track +from mopidy.models import Ref, Track logger = logging.getLogger(__name__) -class TrackHistory(): +class History(object): track_list = [] - def add_track(self, track): + def add(self, track): """ :param track: track to change to :type track: :class:`mopidy.models.Track` @@ -20,12 +22,21 @@ class TrackHistory(): logger.warning('Cannot add non-Track type object to TrackHistory') return - # Reorder the track history if the track is already present. - if track in self.track_list: - self.track_list.remove(track) - self.track_list.insert(0, track) + timestamp = int(datetime.datetime.now().strftime("%s")) * 1000 + name_parts = [] + if track.name is not None: + name_parts.append(track.name) + if track.artists: + name_parts.append( + ', '.join([artist.name for artist in track.artists]) + ) + ref_name = ' - '.join(name_parts) + track_ref = Ref.track(uri=track.uri, name=ref_name) - def get_history_size(self): + self.track_list.insert(0, (timestamp, track_ref)) + + @property + def size(self): """ Returns the number of tracks in the history. :returns: The number of tracks in the history. @@ -39,4 +50,4 @@ class TrackHistory(): :returns: The history as a list of `mopidy.models.Track` :rtype: L{`mopidy.models.Track`} """ - return self.track_list + return copy.copy(self.track_list) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index f0262830..58cc1a4b 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -248,7 +248,7 @@ class PlaybackController(object): if success: self.core.tracklist.mark_playing(tl_track) # TODO: replace with stream-changed - self.core.history.add_track(tl_track.track) + self.core.history.add(tl_track.track) self._trigger_track_playback_started() else: self.core.tracklist.mark_unplayable(tl_track) diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 4e9b059c..67e3ccee 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -1,27 +1,41 @@ +from __future__ import unicode_literals + import unittest -from mopidy.core import TrackHistory - -from mopidy.models import Track +from mopidy.core import History +from mopidy.models import Artist, Track class PlaybackHistoryTest(unittest.TestCase): def setUp(self): self.tracks = [ - Track(uri='dummy1:a', name='foo'), + Track(uri='dummy1:a', name='foo', + artists=[Artist(name='foober'), Artist(name='barber')]), Track(uri='dummy2:a', name='foo'), Track(uri='dummy3:a', name='bar') ] - self.history = TrackHistory() + self.history = History() def test_add_track(self): - self.history.add_track(self.tracks[0]) - self.assertEqual(self.history.get_history_size(), 1) + self.history.add(self.tracks[0]) + self.history.add(self.tracks[1]) + self.history.add(self.tracks[2]) + self.assertEqual(self.history.size, 3) - def test_track_order(self): - self.history.add_track(self.tracks[0]) - self.history.add_track(self.tracks[1]) - self.history.add_track(self.tracks[2]) - self.history.add_track(self.tracks[0]) - self.assertEqual(self.history.get_history_size(), 3) - self.assertEqual(self.history.get_history()[0], self.tracks[0]) \ No newline at end of file + def test_unsuitable_add(self): + size = self.history.size + self.history.add(self.tracks[0]) + self.history.add(object()) + self.history.add(self.tracks[1]) + self.assertEqual(self.history.size, size + 2) + + def test_history_sanity(self): + track = self.tracks[0] + self.history.add(track) + stored_history = self.history.get_history() + track_ref = stored_history[0][1] + self.assertEqual(track_ref.uri, track.uri) + self.assertTrue(track.name in track_ref.name) + if track.artists: + for artist in track.artists: + self.assertTrue(artist.name in track_ref.name) From a6370b0a671d46e9cb090848f6e56b9930d79a05 Mon Sep 17 00:00:00 2001 From: Arjun Naik Date: Sat, 23 Aug 2014 01:38:05 +0200 Subject: [PATCH 102/495] Switched track name and artist order in history object. --- mopidy/core/history.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 3b7b9d9f..24b808d2 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -24,12 +24,12 @@ class History(object): timestamp = int(datetime.datetime.now().strftime("%s")) * 1000 name_parts = [] - if track.name is not None: - name_parts.append(track.name) if track.artists: name_parts.append( ', '.join([artist.name for artist in track.artists]) ) + if track.name is not None: + name_parts.append(track.name) ref_name = ' - '.join(name_parts) track_ref = Ref.track(uri=track.uri, name=ref_name) From c38b9f378f7dbf968ee6bcb7b3f0625a1993d61d Mon Sep 17 00:00:00 2001 From: Arjun Naik Date: Sat, 23 Aug 2014 01:38:38 +0200 Subject: [PATCH 103/495] Use assertIn instead of assertTrue to test membership. --- tests/core/test_history.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 67e3ccee..7b681d83 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -35,7 +35,7 @@ class PlaybackHistoryTest(unittest.TestCase): stored_history = self.history.get_history() track_ref = stored_history[0][1] self.assertEqual(track_ref.uri, track.uri) - self.assertTrue(track.name in track_ref.name) + self.assertIn(track.name, track_ref.name) if track.artists: for artist in track.artists: - self.assertTrue(artist.name in track_ref.name) + self.assertIn(artist.name, track_ref.name) From 718405421daae5bf882783fb633d4124c2a4f8eb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:32:34 +0200 Subject: [PATCH 104/495] history: Rename class to HistoryController For consistency with other core controllers. --- mopidy/core/__init__.py | 2 +- mopidy/core/actor.py | 8 ++++---- mopidy/core/history.py | 2 +- tests/core/test_history.py | 5 +++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 302c0a74..019034fc 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals # flake8: noqa from .actor import Core -from .history import History +from .history import HistoryController from .library import LibraryController from .listener import CoreListener from .playback import PlaybackController, PlaybackState diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 46f94d00..edf13679 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -7,7 +7,7 @@ import pykka from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState -from mopidy.core.history import History +from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener from mopidy.core.playback import PlaybackController @@ -25,8 +25,8 @@ class Core( :class:`mopidy.core.LibraryController`.""" history = None - """The playback history. An instance of - :class:`mopidy.core.TrackHistory`""" + """The playback history controller. An instance of + :class:`mopidy.core.HistoryController`.""" playback = None """The playback controller. An instance of @@ -47,7 +47,7 @@ class Core( self.library = LibraryController(backends=self.backends, core=self) - self.history = History() + self.history = HistoryController() self.playback = PlaybackController( mixer=mixer, backends=self.backends, core=self) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 24b808d2..38ebe74d 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -10,7 +10,7 @@ from mopidy.models import Ref, Track logger = logging.getLogger(__name__) -class History(object): +class HistoryController(object): track_list = [] def add(self, track): diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 7b681d83..3592274c 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -2,11 +2,12 @@ from __future__ import unicode_literals import unittest -from mopidy.core import History +from mopidy.core import HistoryController from mopidy.models import Artist, Track class PlaybackHistoryTest(unittest.TestCase): + def setUp(self): self.tracks = [ Track(uri='dummy1:a', name='foo', @@ -14,7 +15,7 @@ class PlaybackHistoryTest(unittest.TestCase): Track(uri='dummy2:a', name='foo'), Track(uri='dummy3:a', name='bar') ] - self.history = History() + self.history = HistoryController() def test_add_track(self): self.history.add(self.tracks[0]) From f8f06f4ec4cc7c8de9f2787689a1145daa40a6a8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:33:24 +0200 Subject: [PATCH 105/495] playback: Move comment so its next to the line it applies to --- mopidy/core/playback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 58cc1a4b..4c968d81 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -247,8 +247,8 @@ class PlaybackController(object): if success: self.core.tracklist.mark_playing(tl_track) - # TODO: replace with stream-changed self.core.history.add(tl_track.track) + # TODO: replace with stream-changed self._trigger_track_playback_started() else: self.core.tracklist.mark_unplayable(tl_track) From 1f9c5c609aa552fbcf4fc5d7c618e6bebe34a79e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:34:50 +0200 Subject: [PATCH 106/495] history: Use time.time() to get time since epoch --- mopidy/core/history.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 38ebe74d..da7d298a 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import copy -import datetime import logging +import time from mopidy.models import Ref, Track @@ -22,7 +22,8 @@ class HistoryController(object): logger.warning('Cannot add non-Track type object to TrackHistory') return - timestamp = int(datetime.datetime.now().strftime("%s")) * 1000 + timestamp = int(time.time() * 1000) + name_parts = [] if track.artists: name_parts.append( From f3a6c10188d9a86ec7c603c059ba5fa35d0d1758 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:37:18 +0200 Subject: [PATCH 107/495] history: Import entire modules For concistency with other code. --- mopidy/core/history.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index da7d298a..787d55d7 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -4,7 +4,7 @@ import copy import logging import time -from mopidy.models import Ref, Track +from mopidy import models logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ class HistoryController(object): :param track: track to change to :type track: :class:`mopidy.models.Track` """ - if type(track) is not Track: + if type(track) is not models.Track: logger.warning('Cannot add non-Track type object to TrackHistory') return @@ -32,7 +32,7 @@ class HistoryController(object): if track.name is not None: name_parts.append(track.name) ref_name = ' - '.join(name_parts) - track_ref = Ref.track(uri=track.uri, name=ref_name) + track_ref = models.Ref.track(uri=track.uri, name=ref_name) self.track_list.insert(0, (timestamp, track_ref)) From ded43039bf15e8d46c49caf5f762686be019d9e1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:38:38 +0200 Subject: [PATCH 108/495] history: Keep history in private attribute So it is not accessible directly from other actors. --- mopidy/core/history.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 787d55d7..23f5d74f 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -11,7 +11,9 @@ logger = logging.getLogger(__name__) class HistoryController(object): - track_list = [] + + def __init__(self): + self._history = [] def add(self, track): """ @@ -34,7 +36,7 @@ class HistoryController(object): ref_name = ' - '.join(name_parts) track_ref = models.Ref.track(uri=track.uri, name=ref_name) - self.track_list.insert(0, (timestamp, track_ref)) + self._history.insert(0, (timestamp, track_ref)) @property def size(self): @@ -43,7 +45,7 @@ class HistoryController(object): :returns: The number of tracks in the history. :rtype :int """ - return len(self.track_list) + return len(self._history) def get_history(self): """ @@ -51,4 +53,4 @@ class HistoryController(object): :returns: The history as a list of `mopidy.models.Track` :rtype: L{`mopidy.models.Track`} """ - return copy.copy(self.track_list) + return copy.copy(self._history) From d30cf68efd1ac34273edb780c2f56497bac7d878 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:42:26 +0200 Subject: [PATCH 109/495] history: Raise TypeError if non-Tracks are added --- mopidy/core/history.py | 5 ++--- tests/core/test_history.py | 11 +++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 23f5d74f..b66cd078 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -20,9 +20,8 @@ class HistoryController(object): :param track: track to change to :type track: :class:`mopidy.models.Track` """ - if type(track) is not models.Track: - logger.warning('Cannot add non-Track type object to TrackHistory') - return + if not isinstance(track, models.Track): + raise TypeError('Only Track objects can be added to the history') timestamp = int(time.time() * 1000) diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 3592274c..ed918c1f 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -23,12 +23,11 @@ class PlaybackHistoryTest(unittest.TestCase): self.history.add(self.tracks[2]) self.assertEqual(self.history.size, 3) - def test_unsuitable_add(self): - size = self.history.size - self.history.add(self.tracks[0]) - self.history.add(object()) - self.history.add(self.tracks[1]) - self.assertEqual(self.history.size, size + 2) + def test_non_tracks_are_rejected(self): + with self.assertRaises(TypeError): + self.history.add(object()) + + self.assertEqual(self.history.size, 0) def test_history_sanity(self): track = self.tracks[0] From 1f1e86023b9970ec0d61cf6c9934c1c5d618d05c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:44:17 +0200 Subject: [PATCH 110/495] history: Cleanup docstrings --- mopidy/core/history.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index b66cd078..16a5208f 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -16,8 +16,9 @@ class HistoryController(object): self._history = [] def add(self, track): - """ - :param track: track to change to + """Add track to the playback history. + + :param track: track to add :type track: :class:`mopidy.models.Track` """ if not isinstance(track, models.Track): @@ -39,17 +40,17 @@ class HistoryController(object): @property def size(self): - """ - Returns the number of tracks in the history. - :returns: The number of tracks in the history. - :rtype :int + """Get the number of tracks in the history. + + :returns: the history length + :rtype: int """ return len(self._history) def get_history(self): - """ - Returns the history. - :returns: The history as a list of `mopidy.models.Track` - :rtype: L{`mopidy.models.Track`} + """Get the track history. + + :returns: the track history + :rtype: list of (timestamp, mopidy.models.Ref) tuples """ return copy.copy(self._history) From 177f91fb274ee4b358d304b1080bfdc6dea37b3f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:45:24 +0200 Subject: [PATCH 111/495] history: Tweaking formatting and variable names --- mopidy/core/history.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 16a5208f..021ee34d 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -29,14 +29,13 @@ class HistoryController(object): name_parts = [] if track.artists: name_parts.append( - ', '.join([artist.name for artist in track.artists]) - ) + ', '.join([artist.name for artist in track.artists])) if track.name is not None: name_parts.append(track.name) - ref_name = ' - '.join(name_parts) - track_ref = models.Ref.track(uri=track.uri, name=ref_name) + name = ' - '.join(name_parts) + ref = models.Ref.track(uri=track.uri, name=name) - self._history.insert(0, (timestamp, track_ref)) + self._history.insert(0, (timestamp, ref)) @property def size(self): From 5a67339855c5a400cfebf1a64fe606c999501aa0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:47:28 +0200 Subject: [PATCH 112/495] history: Cleanup history entry test --- tests/core/test_history.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/core/test_history.py b/tests/core/test_history.py index ed918c1f..e0f607f9 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -29,13 +29,15 @@ class PlaybackHistoryTest(unittest.TestCase): self.assertEqual(self.history.size, 0) - def test_history_sanity(self): + def test_history_entry_contents(self): track = self.tracks[0] self.history.add(track) - stored_history = self.history.get_history() - track_ref = stored_history[0][1] - self.assertEqual(track_ref.uri, track.uri) - self.assertIn(track.name, track_ref.name) - if track.artists: - for artist in track.artists: - self.assertIn(artist.name, track_ref.name) + + result = self.history.get_history() + (timestamp, ref) = result[0] + + self.assertIsInstance(timestamp, int) + self.assertEqual(track.uri, ref.uri) + self.assertIn(track.name, ref.name) + for artist in track.artists: + self.assertIn(artist.name, ref.name) From 5317834baf62d04e3a844a1804af1ea5782ddbf1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:47:54 +0200 Subject: [PATCH 113/495] history: Change size property to get_length() method For consistency with tracklist.get_length() and our goal of aligning Python and JS APIs by using less properties in the core API. --- mopidy/core/history.py | 3 +-- tests/core/test_history.py | 8 ++++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 021ee34d..6711bcf4 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -37,8 +37,7 @@ class HistoryController(object): self._history.insert(0, (timestamp, ref)) - @property - def size(self): + def get_length(self): """Get the number of tracks in the history. :returns: the history length diff --git a/tests/core/test_history.py b/tests/core/test_history.py index e0f607f9..75b4dc76 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -19,15 +19,19 @@ class PlaybackHistoryTest(unittest.TestCase): def test_add_track(self): self.history.add(self.tracks[0]) + self.assertEqual(self.history.get_length(), 1) + self.history.add(self.tracks[1]) + self.assertEqual(self.history.get_length(), 2) + self.history.add(self.tracks[2]) - self.assertEqual(self.history.size, 3) + self.assertEqual(self.history.get_length(), 3) def test_non_tracks_are_rejected(self): with self.assertRaises(TypeError): self.history.add(object()) - self.assertEqual(self.history.size, 0) + self.assertEqual(self.history.get_length(), 0) def test_history_entry_contents(self): track = self.tracks[0] From 39bee603fa3f1da21b23f2f891ddcd8296875a6d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:55:37 +0200 Subject: [PATCH 114/495] docs: Add HistoryController to core API --- docs/api/core.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/api/core.rst b/docs/api/core.rst index 38cc0f0a..21ff79f5 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -37,6 +37,15 @@ Manages everything related to the tracks we are currently playing. :members: +History controller +================== + +Keeps record of what tracks have been played. + +.. autoclass:: mopidy.core.HistoryController + :members: + + Playlists controller ==================== From d655be10334d58dfea8feb805abac4b31417f200 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 18:55:57 +0200 Subject: [PATCH 115/495] docs: Update changelog --- docs/changelog.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ad83e0c0..e8f03d50 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,11 @@ This changelog is used to track all major changes to Mopidy. v0.20.0 (UNRELEASED) ==================== +**Core API** + +- Added :class:`mopidy.core.HistoryController` which keeps track of what + tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`) + **Commands** - Make the ``mopidy`` command print a friendly error message if the From 542cd3a3bbc4843eefce70c71586ed9d7b23d57e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 19:02:58 +0200 Subject: [PATCH 116/495] http: Add history controller to JSON-RPC API --- mopidy/http/handlers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 3ef10c3b..3bfc1eff 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -41,6 +41,7 @@ def make_jsonrpc_wrapper(core_actor): objects={ 'core.get_uri_schemes': core.Core.get_uri_schemes, 'core.get_version': core.Core.get_version, + 'core.history': core.HistoryController, 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, @@ -51,6 +52,7 @@ def make_jsonrpc_wrapper(core_actor): 'core.describe': inspector.describe, 'core.get_uri_schemes': core_actor.get_uri_schemes, 'core.get_version': core_actor.get_version, + 'core.history': core_actor.history, 'core.library': core_actor.library, 'core.playback': core_actor.playback, 'core.playlists': core_actor.playlists, From 130b1eb32a50debcfd302184095c760447bfb1ff Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Sep 2014 20:55:16 +0200 Subject: [PATCH 117/495] docs: Define timestamp type --- mopidy/core/history.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 6711bcf4..379e3b34 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -48,7 +48,9 @@ class HistoryController(object): def get_history(self): """Get the track history. + The timestamps are milliseconds since epoch. + :returns: the track history - :rtype: list of (timestamp, mopidy.models.Ref) tuples + :rtype: list of (timestamp, :class:`mopidy.models.Ref`) tuples """ return copy.copy(self._history) From 07d088fc18c8fe8f2586d47e22a892585a957bff Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 24 Sep 2014 18:26:25 +0200 Subject: [PATCH 118/495] models: Use with stmts in exception tests --- tests/test_models.py | 157 ++++++++++++++++++++++++++----------------- 1 file changed, 94 insertions(+), 63 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 56d6c76b..435d4958 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -49,8 +49,8 @@ class GenericCopyTest(unittest.TestCase): self.assertIn(artist2, copy.artists) def test_copying_track_with_invalid_key(self): - test = lambda: Track().copy(invalid_key=True) - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Track().copy(invalid_key=True) def test_copying_track_to_remove(self): track = Track(name='foo').copy(name=None) @@ -62,17 +62,19 @@ class RefTest(unittest.TestCase): uri = 'an_uri' ref = Ref(uri=uri) self.assertEqual(ref.uri, uri) - self.assertRaises(AttributeError, setattr, ref, 'uri', None) + with self.assertRaises(AttributeError): + ref.uri = None def test_name(self): name = 'a name' ref = Ref(name=name) self.assertEqual(ref.name, name) - self.assertRaises(AttributeError, setattr, ref, 'name', None) + with self.assertRaises(AttributeError): + ref.name = None def test_invalid_kwarg(self): - test = lambda: SearchResult(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + SearchResult(foo='baz') def test_repr_without_results(self): self.assertEquals( @@ -133,31 +135,33 @@ class ArtistTest(unittest.TestCase): uri = 'an_uri' artist = Artist(uri=uri) self.assertEqual(artist.uri, uri) - self.assertRaises(AttributeError, setattr, artist, 'uri', None) + with self.assertRaises(AttributeError): + artist.uri = None def test_name(self): name = 'a name' artist = Artist(name=name) self.assertEqual(artist.name, name) - self.assertRaises(AttributeError, setattr, artist, 'name', None) + with self.assertRaises(AttributeError): + artist.name = None def test_musicbrainz_id(self): mb_id = 'mb-id' artist = Artist(musicbrainz_id=mb_id) self.assertEqual(artist.musicbrainz_id, mb_id) - self.assertRaises( - AttributeError, setattr, artist, 'musicbrainz_id', None) + with self.assertRaises(AttributeError): + artist.musicbrainz_id = None def test_invalid_kwarg(self): - test = lambda: Artist(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Artist(foo='baz') def test_invalid_kwarg_with_name_matching_method(self): - test = lambda: Artist(copy='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Artist(copy='baz') - test = lambda: Artist(serialize='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Artist(serialize='baz') def test_repr(self): self.assertEquals( @@ -184,22 +188,22 @@ class ArtistTest(unittest.TestCase): artist = Artist(uri='uri', name='name').serialize() artist['foo'] = 'foo' serialized = json.dumps(artist) - test = lambda: json.loads(serialized, object_hook=model_json_decoder) - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + json.loads(serialized, object_hook=model_json_decoder) def test_to_json_and_back_with_field_matching_method(self): artist = Artist(uri='uri', name='name').serialize() artist['copy'] = 'foo' serialized = json.dumps(artist) - test = lambda: json.loads(serialized, object_hook=model_json_decoder) - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + json.loads(serialized, object_hook=model_json_decoder) def test_to_json_and_back_with_field_matching_internal_field(self): artist = Artist(uri='uri', name='name').serialize() artist['__mro__'] = 'foo' serialized = json.dumps(artist) - test = lambda: json.loads(serialized, object_hook=model_json_decoder) - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + json.loads(serialized, object_hook=model_json_decoder) def test_eq_name(self): artist1 = Artist(name='name') @@ -261,19 +265,22 @@ class AlbumTest(unittest.TestCase): uri = 'an_uri' album = Album(uri=uri) self.assertEqual(album.uri, uri) - self.assertRaises(AttributeError, setattr, album, 'uri', None) + with self.assertRaises(AttributeError): + album.uri = None def test_name(self): name = 'a name' album = Album(name=name) self.assertEqual(album.name, name) - self.assertRaises(AttributeError, setattr, album, 'name', None) + with self.assertRaises(AttributeError): + album.name = None def test_artists(self): artist = Artist() album = Album(artists=[artist]) self.assertIn(artist, album.artists) - self.assertRaises(AttributeError, setattr, album, 'artists', None) + with self.assertRaises(AttributeError): + album.artists = None def test_artists_none(self): self.assertEqual(set(), Album(artists=None).artists) @@ -282,39 +289,43 @@ class AlbumTest(unittest.TestCase): num_tracks = 11 album = Album(num_tracks=num_tracks) self.assertEqual(album.num_tracks, num_tracks) - self.assertRaises(AttributeError, setattr, album, 'num_tracks', None) + with self.assertRaises(AttributeError): + album.num_tracks = None def test_num_discs(self): num_discs = 2 album = Album(num_discs=num_discs) self.assertEqual(album.num_discs, num_discs) - self.assertRaises(AttributeError, setattr, album, 'num_discs', None) + with self.assertRaises(AttributeError): + album.num_discs = None def test_date(self): date = '1977-01-01' album = Album(date=date) self.assertEqual(album.date, date) - self.assertRaises(AttributeError, setattr, album, 'date', None) + with self.assertRaises(AttributeError): + album.date = None def test_musicbrainz_id(self): mb_id = 'mb-id' album = Album(musicbrainz_id=mb_id) self.assertEqual(album.musicbrainz_id, mb_id) - self.assertRaises( - AttributeError, setattr, album, 'musicbrainz_id', None) + with self.assertRaises(AttributeError): + album.musicbrainz_id = None def test_images(self): image = 'data:foobar' album = Album(images=[image]) self.assertIn(image, album.images) - self.assertRaises(AttributeError, setattr, album, 'images', None) + with self.assertRaises(AttributeError): + album.images = None def test_images_none(self): self.assertEqual(set(), Album(images=None).images) def test_invalid_kwarg(self): - test = lambda: Album(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Album(foo='baz') def test_repr_without_artists(self): self.assertEquals( @@ -466,19 +477,22 @@ class TrackTest(unittest.TestCase): uri = 'an_uri' track = Track(uri=uri) self.assertEqual(track.uri, uri) - self.assertRaises(AttributeError, setattr, track, 'uri', None) + with self.assertRaises(AttributeError): + track.uri = None def test_name(self): name = 'a name' track = Track(name=name) self.assertEqual(track.name, name) - self.assertRaises(AttributeError, setattr, track, 'name', None) + with self.assertRaises(AttributeError): + track.name = None def test_artists(self): artists = [Artist(name='name1'), Artist(name='name2')] track = Track(artists=artists) self.assertEqual(set(track.artists), set(artists)) - self.assertRaises(AttributeError, setattr, track, 'artists', None) + with self.assertRaises(AttributeError): + track.artists = None def test_artists_none(self): self.assertEqual(set(), Track(artists=None).artists) @@ -487,7 +501,8 @@ class TrackTest(unittest.TestCase): artists = [Artist(name='name1'), Artist(name='name2')] track = Track(composers=artists) self.assertEqual(set(track.composers), set(artists)) - self.assertRaises(AttributeError, setattr, track, 'composers', None) + with self.assertRaises(AttributeError): + track.composers = None def test_composers_none(self): self.assertEqual(set(), Track(composers=None).composers) @@ -496,7 +511,8 @@ class TrackTest(unittest.TestCase): artists = [Artist(name='name1'), Artist(name='name2')] track = Track(performers=artists) self.assertEqual(set(track.performers), set(artists)) - self.assertRaises(AttributeError, setattr, track, 'performers', None) + with self.assertRaises(AttributeError): + track.performers = None def test_performers_none(self): self.assertEqual(set(), Track(performers=None).performers) @@ -505,48 +521,54 @@ class TrackTest(unittest.TestCase): album = Album() track = Track(album=album) self.assertEqual(track.album, album) - self.assertRaises(AttributeError, setattr, track, 'album', None) + with self.assertRaises(AttributeError): + track.album = None def test_track_no(self): track_no = 7 track = Track(track_no=track_no) self.assertEqual(track.track_no, track_no) - self.assertRaises(AttributeError, setattr, track, 'track_no', None) + with self.assertRaises(AttributeError): + track.track_no = None def test_disc_no(self): disc_no = 2 track = Track(disc_no=disc_no) self.assertEqual(track.disc_no, disc_no) - self.assertRaises(AttributeError, setattr, track, 'disc_no', None) + with self.assertRaises(AttributeError): + track.disc_no = None def test_date(self): date = '1977-01-01' track = Track(date=date) self.assertEqual(track.date, date) - self.assertRaises(AttributeError, setattr, track, 'date', None) + with self.assertRaises(AttributeError): + track.date = None def test_length(self): length = 137000 track = Track(length=length) self.assertEqual(track.length, length) - self.assertRaises(AttributeError, setattr, track, 'length', None) + with self.assertRaises(AttributeError): + track.length = None def test_bitrate(self): bitrate = 160 track = Track(bitrate=bitrate) self.assertEqual(track.bitrate, bitrate) - self.assertRaises(AttributeError, setattr, track, 'bitrate', None) + with self.assertRaises(AttributeError): + track.bitrate = None def test_musicbrainz_id(self): mb_id = 'mb-id' track = Track(musicbrainz_id=mb_id) self.assertEqual(track.musicbrainz_id, mb_id) - self.assertRaises( - AttributeError, setattr, track, 'musicbrainz_id', None) + with self.assertRaises(AttributeError): + track.musicbrainz_id = None def test_invalid_kwarg(self): - test = lambda: Track(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Track(foo='baz') def test_repr_without_artists(self): self.assertEquals( @@ -753,17 +775,19 @@ class TlTrackTest(unittest.TestCase): tlid = 123 tl_track = TlTrack(tlid=tlid) self.assertEqual(tl_track.tlid, tlid) - self.assertRaises(AttributeError, setattr, tl_track, 'tlid', None) + with self.assertRaises(AttributeError): + tl_track.tlid = None def test_track(self): track = Track() tl_track = TlTrack(track=track) self.assertEqual(tl_track.track, track) - self.assertRaises(AttributeError, setattr, tl_track, 'track', None) + with self.assertRaises(AttributeError): + tl_track.track = None def test_invalid_kwarg(self): - test = lambda: TlTrack(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + TlTrack(foo='baz') def test_positional_args(self): tlid = 123 @@ -829,19 +853,22 @@ class PlaylistTest(unittest.TestCase): uri = 'an_uri' playlist = Playlist(uri=uri) self.assertEqual(playlist.uri, uri) - self.assertRaises(AttributeError, setattr, playlist, 'uri', None) + with self.assertRaises(AttributeError): + playlist.uri = None def test_name(self): name = 'a name' playlist = Playlist(name=name) self.assertEqual(playlist.name, name) - self.assertRaises(AttributeError, setattr, playlist, 'name', None) + with self.assertRaises(AttributeError): + playlist.name = None def test_tracks(self): tracks = [Track(), Track(), Track()] playlist = Playlist(tracks=tracks) self.assertEqual(list(playlist.tracks), tracks) - self.assertRaises(AttributeError, setattr, playlist, 'tracks', None) + with self.assertRaises(AttributeError): + playlist.tracks = None def test_length(self): tracks = [Track(), Track(), Track()] @@ -852,8 +879,8 @@ class PlaylistTest(unittest.TestCase): last_modified = 1390942873000 playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) - self.assertRaises( - AttributeError, setattr, playlist, 'last_modified', None) + with self.assertRaises(AttributeError): + playlist.last_modified = None def test_with_new_uri(self): tracks = [Track()] @@ -906,8 +933,8 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(new_playlist.last_modified, new_last_modified) def test_invalid_kwarg(self): - test = lambda: Playlist(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + Playlist(foo='baz') def test_repr_without_tracks(self): self.assertEquals( @@ -1017,25 +1044,29 @@ class SearchResultTest(unittest.TestCase): uri = 'an_uri' result = SearchResult(uri=uri) self.assertEqual(result.uri, uri) - self.assertRaises(AttributeError, setattr, result, 'uri', None) + with self.assertRaises(AttributeError): + result.uri = None def test_tracks(self): tracks = [Track(), Track(), Track()] result = SearchResult(tracks=tracks) self.assertEqual(list(result.tracks), tracks) - self.assertRaises(AttributeError, setattr, result, 'tracks', None) + with self.assertRaises(AttributeError): + result.tracks = None def test_artists(self): artists = [Artist(), Artist(), Artist()] result = SearchResult(artists=artists) self.assertEqual(list(result.artists), artists) - self.assertRaises(AttributeError, setattr, result, 'artists', None) + with self.assertRaises(AttributeError): + result.artists = None def test_albums(self): albums = [Album(), Album(), Album()] result = SearchResult(albums=albums) self.assertEqual(list(result.albums), albums) - self.assertRaises(AttributeError, setattr, result, 'albums', None) + with self.assertRaises(AttributeError): + result.albums = None def test_invalid_kwarg(self): test = lambda: SearchResult(foo='baz') From 7856c14b26e10aeefb8c217bf06a1f54605063b2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Sep 2014 23:03:35 +0200 Subject: [PATCH 119/495] tests: Use with stmt for assertRaises --- tests/core/test_tracklist.py | 6 +- tests/local/test_library.py | 96 ++++++++++++------------ tests/local/test_playback.py | 4 +- tests/local/test_tracklist.py | 24 +++--- tests/test_commands.py | 15 ++-- tests/test_models.py | 4 +- tests/utils/network/test_lineprotocol.py | 30 +++++--- tests/utils/network/test_server.py | 33 ++++---- tests/utils/test_jsonrpc.py | 8 +- tests/utils/test_path.py | 3 +- 10 files changed, 116 insertions(+), 107 deletions(-) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 963a4bb7..b681e097 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -67,9 +67,11 @@ class TracklistTest(unittest.TestCase): self.assertListEqual(self.tl_tracks[:2], tl_tracks) def test_filter_fails_if_values_isnt_iterable(self): - self.assertRaises(ValueError, self.core.tracklist.filter, tlid=3) + with self.assertRaises(ValueError): + self.core.tracklist.filter(tlid=3) def test_filter_fails_if_values_is_a_string(self): - self.assertRaises(ValueError, self.core.tracklist.filter, uri='a') + with self.assertRaises(ValueError): + self.core.tracklist.filter(uri='a') # TODO Extract tracklist tests from the local backend tests diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 148c82e5..c1f2bcbd 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -335,42 +335,42 @@ class LocalLibraryProviderTest(unittest.TestCase): self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_find_exact_wrong_type(self): - test = lambda: self.library.find_exact(wrong=['test']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(wrong=['test']) def test_find_exact_with_empty_query(self): - test = lambda: self.library.find_exact(artist=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(artist=['']) - test = lambda: self.library.find_exact(albumartist=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(albumartist=['']) - test = lambda: self.library.find_exact(track_name=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(track_name=['']) - test = lambda: self.library.find_exact(composer=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(composer=['']) - test = lambda: self.library.find_exact(performer=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(performer=['']) - test = lambda: self.library.find_exact(album=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(album=['']) - test = lambda: self.library.find_exact(track_no=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(track_no=['']) - test = lambda: self.library.find_exact(genre=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(genre=['']) - test = lambda: self.library.find_exact(date=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(date=['']) - test = lambda: self.library.find_exact(comment=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(comment=['']) - test = lambda: self.library.find_exact(any=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.find_exact(any=['']) def test_search_no_hits(self): result = self.library.search(track_name=['unknown track']) @@ -544,39 +544,39 @@ class LocalLibraryProviderTest(unittest.TestCase): self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): - test = lambda: self.library.search(wrong=['test']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(wrong=['test']) def test_search_with_empty_query(self): - test = lambda: self.library.search(artist=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(artist=['']) - test = lambda: self.library.search(albumartist=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(albumartist=['']) - test = lambda: self.library.search(composer=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(composer=['']) - test = lambda: self.library.search(performer=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(performer=['']) - test = lambda: self.library.search(track_name=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(track_name=['']) - test = lambda: self.library.search(album=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(album=['']) - test = lambda: self.library.search(genre=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(genre=['']) - test = lambda: self.library.search(date=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(date=['']) - test = lambda: self.library.search(comment=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(comment=['']) - test = lambda: self.library.search(uri=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(uri=['']) - test = lambda: self.library.search(any=['']) - self.assertRaises(LookupError, test) + with self.assertRaises(LookupError): + self.library.search(any=['']) diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 7b64e495..ba051fa0 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -1081,5 +1081,5 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_playing_track_that_isnt_in_playlist(self): - test = lambda: self.playback.play((17, Track())) - self.assertRaises(AssertionError, test) + with self.assertRaises(AssertionError): + self.playback.play((17, Track())) diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index af07a4e6..9c1d09d7 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -198,25 +198,25 @@ class LocalTracklistProviderTest(unittest.TestCase): @populate_tracklist 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) + with self.assertRaises(AssertionError): + self.controller.move(0, 0, tracks + 5) @populate_tracklist 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) + with self.assertRaises(AssertionError): + self.controller.move(0, 2, tracks + 5) @populate_tracklist 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) + with self.assertRaises(AssertionError): + self.controller.move(tracks + 2, tracks + 3, 0) @populate_tracklist def test_move_group_invalid_group(self): - test = lambda: self.controller.move(2, 1, 0) - self.assertRaises(AssertionError, test) + with self.assertRaises(AssertionError): + self.controller.move(2, 1, 0) def test_tracks_attribute_is_immutable(self): tracks1 = self.controller.tracks @@ -275,14 +275,14 @@ class LocalTracklistProviderTest(unittest.TestCase): @populate_tracklist def test_shuffle_invalid_subset(self): - test = lambda: self.controller.shuffle(3, 1) - self.assertRaises(AssertionError, test) + with self.assertRaises(AssertionError): + self.controller.shuffle(3, 1) @populate_tracklist def test_shuffle_superset(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.shuffle(1, tracks + 5) - self.assertRaises(AssertionError, test) + with self.assertRaises(AssertionError): + self.controller.shuffle(1, tracks + 5) @populate_tracklist def test_shuffle_open_subset(self): diff --git a/tests/test_commands.py b/tests/test_commands.py index 9d3d0bda..570647c2 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -35,15 +35,12 @@ class ConfigOverrideTypeTest(unittest.TestCase): expected, commands.config_override_type(b'section/key= ')) def test_invalid_override(self): - self.assertRaises( - argparse.ArgumentTypeError, - commands.config_override_type, b'section/key') - self.assertRaises( - argparse.ArgumentTypeError, - commands.config_override_type, b'section=') - self.assertRaises( - argparse.ArgumentTypeError, - commands.config_override_type, b'section') + with self.assertRaises(argparse.ArgumentTypeError): + commands.config_override_type(b'section/key') + with self.assertRaises(argparse.ArgumentTypeError): + commands.config_override_type(b'section=') + with self.assertRaises(argparse.ArgumentTypeError): + commands.config_override_type(b'section') class CommandParsingTest(unittest.TestCase): diff --git a/tests/test_models.py b/tests/test_models.py index 435d4958..7838a6ba 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1069,8 +1069,8 @@ class SearchResultTest(unittest.TestCase): result.albums = None def test_invalid_kwarg(self): - test = lambda: SearchResult(foo='baz') - self.assertRaises(TypeError, test) + with self.assertRaises(TypeError): + SearchResult(foo='baz') def test_repr_without_results(self): self.assertEquals( diff --git a/tests/utils/network/test_lineprotocol.py b/tests/utils/network/test_lineprotocol.py index d7db67f6..52d6901c 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/utils/network/test_lineprotocol.py @@ -124,14 +124,16 @@ class LineProtocolTest(unittest.TestCase): self.mock.recv_buffer = '' lines = network.LineProtocol.parse_lines(self.mock) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() def test_parse_lines_no_terminator(self): self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data' lines = network.LineProtocol.parse_lines(self.mock) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() def test_parse_lines_termintor(self): self.mock.delimiter = re.compile(r'\n') @@ -139,7 +141,8 @@ class LineProtocolTest(unittest.TestCase): lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_termintor_with_carriage_return(self): @@ -148,7 +151,8 @@ class LineProtocolTest(unittest.TestCase): lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_no_data_before_terminator(self): @@ -157,7 +161,8 @@ class LineProtocolTest(unittest.TestCase): lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_extra_data_after_terminator(self): @@ -166,7 +171,8 @@ class LineProtocolTest(unittest.TestCase): lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data1', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('data2', self.mock.recv_buffer) def test_parse_lines_unicode(self): @@ -175,7 +181,8 @@ class LineProtocolTest(unittest.TestCase): lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('æøå'.encode('utf-8'), lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_multiple_lines(self): @@ -186,7 +193,8 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('abc', lines.next()) self.assertEqual('def', lines.next()) self.assertEqual('ghi', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('jkl', self.mock.recv_buffer) def test_parse_lines_multiple_calls(self): @@ -194,14 +202,16 @@ class LineProtocolTest(unittest.TestCase): self.mock.recv_buffer = 'data1' lines = network.LineProtocol.parse_lines(self.mock) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('data1', self.mock.recv_buffer) self.mock.recv_buffer += '\ndata2' lines = network.LineProtocol.parse_lines(self.mock) self.assertEqual('data1', lines.next()) - self.assertRaises(StopIteration, lines.next) + with self.assertRaises(StopIteration): + lines.next() self.assertEqual('data2', self.mock.recv_buffer) def test_send_lines_called_with_no_lines(self): diff --git a/tests/utils/network/test_server.py b/tests/utils/network/test_server.py index c5b8c41a..eebc9ea2 100644 --- a/tests/utils/network/test_server.py +++ b/tests/utils/network/test_server.py @@ -38,9 +38,9 @@ class ServerTest(unittest.TestCase): sock.fileno.side_effect = socket.error self.mock.create_server_socket.return_value = sock - self.assertRaises( - socket.error, network.Server.__init__, self.mock, sentinel.host, - sentinel.port, sentinel.protocol) + with self.assertRaises(socket.error): + network.Server.__init__( + self.mock, sentinel.host, sentinel.port, sentinel.protocol) def test_init_stores_values_in_attributes(self): # This need to be a mock and no a sentinel as fileno() is called on it @@ -68,27 +68,27 @@ class ServerTest(unittest.TestCase): @patch.object(network, 'create_socket', new=Mock()) def test_create_server_socket_fails(self): network.create_socket.side_effect = socket.error - self.assertRaises( - socket.error, network.Server.create_server_socket, self.mock, - sentinel.host, sentinel.port) + with self.assertRaises(socket.error): + network.Server.create_server_socket( + self.mock, sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_bind_fails(self): sock = network.create_socket.return_value sock.bind.side_effect = socket.error - self.assertRaises( - socket.error, network.Server.create_server_socket, self.mock, - sentinel.host, sentinel.port) + with self.assertRaises(socket.error): + network.Server.create_server_socket( + self.mock, sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_listen_fails(self): sock = network.create_socket.return_value sock.listen.side_effect = socket.error - self.assertRaises( - socket.error, network.Server.create_server_socket, self.mock, - sentinel.host, sentinel.port) + with self.assertRaises(socket.error): + network.Server.create_server_socket( + self.mock, sentinel.host, sentinel.port) @patch.object(gobject, 'io_add_watch', new=Mock()) def test_register_server_socket_sets_up_io_watch(self): @@ -137,17 +137,16 @@ class ServerTest(unittest.TestCase): for error in (errno.EAGAIN, errno.EINTR): sock.accept.side_effect = socket.error(error, '') - self.assertRaises( - network.ShouldRetrySocketCall, - network.Server.accept_connection, self.mock) + with self.assertRaises(network.ShouldRetrySocketCall): + network.Server.accept_connection(self.mock) # FIXME decide if this should be allowed to propegate def test_accept_connection_unrecoverable_error(self): sock = Mock(spec=socket.SocketType) self.mock.server_socket = sock sock.accept.side_effect = socket.error - self.assertRaises( - socket.error, network.Server.accept_connection, self.mock) + with self.assertRaises(socket.error): + network.Server.accept_connection(self.mock) def test_maximum_connections_exceeded(self): self.mock.max_connections = 10 diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index e6f94fb3..8f97f37e 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -62,8 +62,8 @@ class JsonRpcTestBase(unittest.TestCase): class JsonRpcSetupTest(JsonRpcTestBase): def test_empty_object_mounts_is_not_allowed(self): - test = lambda: jsonrpc.JsonRpcWrapper(objects={'': Calculator()}) - self.assertRaises(AttributeError, test) + with self.assertRaises(AttributeError): + jsonrpc.JsonRpcWrapper(objects={'': Calculator()}) class JsonRpcSerializationTest(JsonRpcTestBase): @@ -556,8 +556,8 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): class JsonRpcInspectorTest(JsonRpcTestBase): def test_empty_object_mounts_is_not_allowed(self): - test = lambda: jsonrpc.JsonRpcInspector(objects={'': Calculator}) - self.assertRaises(AttributeError, test) + with self.assertRaises(AttributeError): + jsonrpc.JsonRpcInspector(objects={'': Calculator}) def test_can_describe_method_on_root(self): inspector = jsonrpc.JsonRpcInspector({ diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 078cdb20..b33c6681 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -52,7 +52,8 @@ class GetOrCreateDirTest(unittest.TestCase): conflicting_file = os.path.join(self.parent, b'test') open(conflicting_file, 'w').close() dir_path = os.path.join(self.parent, b'test') - self.assertRaises(OSError, path.get_or_create_dir, dir_path) + with self.assertRaises(OSError): + path.get_or_create_dir(dir_path) def test_create_dir_with_unicode(self): with self.assertRaises(ValueError): From 2665a5521be028ce2300296db87bbe0088e6dd7c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 26 Sep 2014 23:51:33 +0200 Subject: [PATCH 120/495] audio: Add gst.DEBUG_BIN_TO_DOT_FILE pipeline debuging --- docs/troubleshooting.rst | 10 +++++++++- mopidy/audio/actor.py | 5 +++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 883abc3b..51cd8bc4 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -89,4 +89,12 @@ level 3, you can run:: GST_DEBUG=3 mopidy -v This will produce a lot of output, but given some GStreamer knowledge this is -very useful for debugging GStreamer pipeline issues. +very useful for debugging GStreamer pipeline issues. Additionally +:envvar:`GST_DEBUG_FILE=gstreamer.log` can be used to redirect the debug +logging to a file instead of standard out. + +Lastly :envvar:`GST_DEBUG_DUMP_DOT_DIR` can be used to get descriptions of the +current pipeline in dot format. Currently we trigger a dump of the pipeline on +every completed state change:: + + GST_DEBUG_DUMP_DOT_DIR=. mopidy diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 51b30f91..95ccb841 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import logging +import os import gobject @@ -351,6 +352,10 @@ class _Handler(object): logger.debug('Audio event: stream_changed(uri=None)') AudioListener.send('stream_changed', uri=None) + if 'GST_DEBUG_DUMP_DOT_DIR' in os.environ: + gst.DEBUG_BIN_TO_DOT_FILE( + self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy') + def on_buffering(self, percent): gst_logger.debug('Got buffering message: percent=%d%%', percent) From b2c1b9d4db91d72552a2f3f82f730898d4da07e9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 27 Sep 2014 00:17:23 +0200 Subject: [PATCH 121/495] readme: s/Mailing/Announcement/ --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 46880c9d..a70eef7e 100644 --- a/README.rst +++ b/README.rst @@ -56,7 +56,7 @@ To get started with Mopidy, check out - `Development branch tarball `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ -- Mailing list: `mopidy@googlegroups.com `_ +- Announcement list: `mopidy@googlegroups.com `_ - Twitter: `@mopidy `_ .. image:: https://img.shields.io/pypi/v/Mopidy.svg?style=flat From 933cbe1dce9d596116bb20aa51ab9008fe70d0b3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Oct 2014 16:40:10 +0200 Subject: [PATCH 122/495] docs: Update Debian docs with 'mopidyctl' --- docs/debian.rst | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/debian.rst b/docs/debian.rst index e0a4bd45..f026d4a9 100644 --- a/docs/debian.rst +++ b/docs/debian.rst @@ -63,16 +63,16 @@ from a regular Mopidy setup you'll want to know about. - The init script runs Mopidy as the ``mopidy`` user. The ``mopidy`` user will need read access to any local music you want Mopidy to play. -- To run Mopidy subcommands with the same arguments, and thus the same - configuration files, as the init script uses, you can use ``sudo service - mopidy run ``. In other words, where you'll usually run:: +- To run Mopidy subcommands with the same user and config files as the init + script uses, you can use ``sudo mopidyctl ``. In other words, + where you'll usually run:: mopidy config You should instead run the following to inspect the system service's configuration:: - sudo service mopidy run config + sudo mopidyctl config The same applies to scanning your local music collection. Where you'll normally run:: @@ -81,7 +81,12 @@ from a regular Mopidy setup you'll want to know about. You should instead run:: - sudo service mopidy run local scan + sudo mopidyctl local scan + + Previously, you used ``sudo service mopidy run `` instead of + ``mopidyctl``. This was deprecated in Debian package version 0.19.4-3 in + favor of ``mopidyctl``, which also work for systems using systemd instead of + sysvinit and traditional init scripts. - Mopidy is started, stopped, and restarted just like any other system service:: From 31c874b3eb57ac5e26ae0f72455d12c54ba7625e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Oct 2014 11:40:59 +0200 Subject: [PATCH 123/495] docs: Make extensiondev example and API docs for validate_environment() match --- docs/extensiondev.rst | 2 +- mopidy/ext.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index c0d36515..100e5b85 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -317,7 +317,7 @@ This is ``mopidy_soundspot/__init__.py``:: gobject.type_register(SoundspotMixer) gst.element_register( SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) - + # Or nothing to register e.g. command extension pass diff --git a/mopidy/ext.py b/mopidy/ext.py index 3f375a69..3333ec3f 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -52,7 +52,7 @@ class Extension(object): return schema def get_command(self): - """Command to expose to command line users running mopidy. + """Command to expose to command line users running ``mopidy``. :returns: Instance of a :class:`~mopidy.commands.Command` class. @@ -60,12 +60,13 @@ class Extension(object): pass def validate_environment(self): - """Checks if the extension can run in the current environment + """Checks if the extension can run in the current environment. - For example, this method can be used to check if all dependencies that - are needed are installed. If a problem is found, raise - :exc:`~mopidy.exceptions.ExtensionError` with a message explaining the - issue. + Dependencies described by :file:`setup.py` are checked by Mopidy, so + you should not check their presence here. + + If a problem is found, raise :exc:`~mopidy.exceptions.ExtensionError` + with a message explaining the issue. :raises: :exc:`~mopidy.exceptions.ExtensionError` :returns: :class:`None` From a1aad39c137ab92afc17332a3b91e3f60488ea17 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 13 Oct 2014 23:14:44 +0200 Subject: [PATCH 124/495] docs: Replace /etc/mopidy/extensions.d with /usr/share/mopidy/conf.d --- docs/debian.rst | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/debian.rst b/docs/debian.rst index f026d4a9..37d19d04 100644 --- a/docs/debian.rst +++ b/docs/debian.rst @@ -55,11 +55,18 @@ from a regular Mopidy setup you'll want to know about. You can do all your changes in this file. - Mopidy extensions installed from Debian packages will sometimes install - additional configuration files in :file:`/etc/mopidy/extensions.d/`. These + additional configuration files in :file:`/usr/share/mopidy/conf.d/`. These files just provide different defaults for the extension when run as a system - service. You can override anything from :file:`/etc/mopidy/extensions.d/` in + service. You can override anything from :file:`/usr/share/mopidy/conf.d/` in the :file:`/etc/mopidy/mopidy.conf` configuration file. + Previously, the extension's default config was installed in + :file:`/etc/mopidy/extensions.d/`. This was removed with the Debian + package mopidy 0.19.4-3. If you have modified any files in + :file:`/etc/mopidy/extensions.d/`, you should redo your modifications in + :file:`/etc/mopidy/mopidy.conf` and delete the + :etc:`/etc/mopidy/extensions.d/` directory. + - The init script runs Mopidy as the ``mopidy`` user. The ``mopidy`` user will need read access to any local music you want Mopidy to play. From c7a6a2abd9a941da6accf9ffa0d03094c9fd9f6b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 14 Oct 2014 21:09:46 +0200 Subject: [PATCH 125/495] docs: Fix typo --- docs/debian.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/debian.rst b/docs/debian.rst index 37d19d04..f37c0673 100644 --- a/docs/debian.rst +++ b/docs/debian.rst @@ -65,7 +65,7 @@ from a regular Mopidy setup you'll want to know about. package mopidy 0.19.4-3. If you have modified any files in :file:`/etc/mopidy/extensions.d/`, you should redo your modifications in :file:`/etc/mopidy/mopidy.conf` and delete the - :etc:`/etc/mopidy/extensions.d/` directory. + :file:`/etc/mopidy/extensions.d/` directory. - The init script runs Mopidy as the ``mopidy`` user. The ``mopidy`` user will need read access to any local music you want Mopidy to play. From 2447e2fa40f0945471e58d0958416e5262122d74 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 5 Oct 2014 23:38:19 +0200 Subject: [PATCH 126/495] util/path: Expose errors to callers of find helper --- mopidy/local/commands.py | 4 +++- mopidy/utils/path.py | 3 ++- tests/audio/test_scan.py | 3 ++- tests/utils/test_path.py | 3 ++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index f2a7ec24..a182aa25 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -74,9 +74,11 @@ class ScanCommand(commands.Command): uris_to_update = set() uris_to_remove = set() - file_mtimes = path.find_mtimes(media_dir) + file_mtimes, file_errors = path.find_mtimes(media_dir) logger.info('Found %d files in media_dir.', len(file_mtimes)) + # TODO: log file errors + num_tracks = library.load() logger.info('Checking %d tracks from library.', num_tracks) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index bad3b6c4..a9187d8f 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -184,7 +184,8 @@ def _find(root, thread_count=10, hidden=True, relative=False): def find_mtimes(root): results, errors = _find(root, hidden=False, relative=False) - return dict((f, int(st.st_mtime)) for f, st in results.iteritems()) + mtimes = dict((f, int(st.st_mtime)) for f, st in results.iteritems()) + return mtimes, errors def check_file_path_is_inside_base_dir(file_path, base_path): diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 1e352991..2e91ce32 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -295,7 +295,8 @@ class ScannerTest(unittest.TestCase): def find(self, path): media_dir = path_to_data_dir(path) - for path in path_lib.find_mtimes(media_dir): + result, errors = path_lib.find_mtimes(media_dir) + for path in result: yield os.path.join(media_dir, path) def scan(self, paths): diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index b33c6681..51dad2cb 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -215,7 +215,8 @@ class FindMTimesTest(unittest.TestCase): maxDiff = None def find(self, value): - return path.find_mtimes(path_to_data_dir(value)) + result, errors = path.find_mtimes(path_to_data_dir(value)) + return result def test_basic_dir(self): self.assert_(self.find('')) From de4bdbec03d96ec8d5c2aeec7690e70b51325e6f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Oct 2014 22:52:41 +0200 Subject: [PATCH 127/495] tests: Minor cleanup of the existing find tests --- mopidy/utils/path.py | 2 +- tests/utils/test_path.py | 37 ++++++++++++++++++++----------------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index a9187d8f..a633a041 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -142,7 +142,7 @@ def _find_worker(relative, hidden, done, work, results, errors): else: errors[path] = 'Not a file or directory' except os.error as e: - errors[path] = str(e) + errors[path] = e finally: work.task_done() diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 51dad2cb..fa2972fd 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -11,7 +11,7 @@ import glib from mopidy.utils import path -from tests import any_int, path_to_data_dir +import tests class GetOrCreateDirTest(unittest.TestCase): @@ -214,32 +214,35 @@ class ExpandPathTest(unittest.TestCase): class FindMTimesTest(unittest.TestCase): maxDiff = None - def find(self, value): - result, errors = path.find_mtimes(path_to_data_dir(value)) - return result + DOES_NOT_EXIST = tests.path_to_data_dir('does-no-exist') + SINGLE_FILE = tests.path_to_data_dir('blank.mp3') + DATA_DIR = tests.path_to_data_dir('') + FIND_DIR = tests.path_to_data_dir('find') def test_basic_dir(self): - self.assert_(self.find('')) + result, errors = path.find_mtimes(self.DATA_DIR) + self.assert_(result) def test_nonexistant_dir(self): - self.assertEqual(self.find('does-not-exist'), {}) + result, errors = path.find_mtimes(self.DOES_NOT_EXIST) + self.assertEqual(result, {}) + self.assertEqual(errors, {self.DOES_NOT_EXIST: tests.IsA(OSError)}) def test_file(self): - self.assertEqual({path_to_data_dir('blank.mp3'): any_int}, - self.find('blank.mp3')) + result, errors = path.find_mtimes(self.SINGLE_FILE) + self.assertEqual({self.SINGLE_FILE: tests.any_int}, result) def test_files(self): - mtimes = self.find('find') - expected_files = [ - b'find/foo/bar/file', b'find/foo/file', b'find/baz/file'] - expected = {path_to_data_dir(p): any_int for p in expected_files} - self.assertEqual(expected, mtimes) + result, errors = path.find_mtimes(self.FIND_DIR) + expected = { + tests.path_to_data_dir(b'find/foo/bar/file'): tests.any_int, + tests.path_to_data_dir(b'find/foo/file'): tests.any_int, + tests.path_to_data_dir(b'find/baz/file'): tests.any_int} + self.assertEqual(expected, result) def test_names_are_bytestrings(self): - is_bytes = lambda f: isinstance(f, bytes) - for name in self.find(''): - self.assert_( - is_bytes(name), '%s is not bytes object' % repr(name)) + for name in path.find_mtimes(self.DATA_DIR)[0]: + self.assertEqual(name, tests.IsA(bytes)) # TODO: kill this in favour of just os.path.getmtime + mocks From a8017dfef1c7456614d90ce3f34d21ce61b9f416 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Oct 2014 23:34:37 +0200 Subject: [PATCH 128/495] tests: Start adding checks for find errors --- tests/utils/test_path.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index fa2972fd..f19b337b 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -222,6 +222,7 @@ class FindMTimesTest(unittest.TestCase): def test_basic_dir(self): result, errors = path.find_mtimes(self.DATA_DIR) self.assert_(result) + self.assertEqual(errors, {}) def test_nonexistant_dir(self): result, errors = path.find_mtimes(self.DOES_NOT_EXIST) @@ -230,7 +231,8 @@ class FindMTimesTest(unittest.TestCase): def test_file(self): result, errors = path.find_mtimes(self.SINGLE_FILE) - self.assertEqual({self.SINGLE_FILE: tests.any_int}, result) + self.assertEqual(result, {self.SINGLE_FILE: tests.any_int}) + self.assertEqual(errors, {}) def test_files(self): result, errors = path.find_mtimes(self.FIND_DIR) @@ -239,6 +241,7 @@ class FindMTimesTest(unittest.TestCase): tests.path_to_data_dir(b'find/foo/file'): tests.any_int, tests.path_to_data_dir(b'find/baz/file'): tests.any_int} self.assertEqual(expected, result) + self.assertEqual(errors, {}) def test_names_are_bytestrings(self): for name in path.find_mtimes(self.DATA_DIR)[0]: From de5fe5ebab40aedca5566777f5bc7a56f8b529a9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Oct 2014 23:51:57 +0200 Subject: [PATCH 129/495] tests: Add test for current find symlink handling --- mopidy/utils/path.py | 2 +- tests/data/find2/bar | 1 + tests/data/find2/foo | 0 tests/utils/test_path.py | 9 ++++++++- 4 files changed, 10 insertions(+), 2 deletions(-) create mode 120000 tests/data/find2/bar create mode 100644 tests/data/find2/foo diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index a633a041..0730fa06 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -140,7 +140,7 @@ def _find_worker(relative, hidden, done, work, results, errors): elif stat.S_ISREG(st.st_mode): results[path] = st else: - errors[path] = 'Not a file or directory' + errors[path] = Exception('Not a file or directory') except os.error as e: errors[path] = e finally: diff --git a/tests/data/find2/bar b/tests/data/find2/bar new file mode 120000 index 00000000..19102815 --- /dev/null +++ b/tests/data/find2/bar @@ -0,0 +1 @@ +foo \ No newline at end of file diff --git a/tests/data/find2/foo b/tests/data/find2/foo new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index f19b337b..19573003 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -216,11 +216,13 @@ class FindMTimesTest(unittest.TestCase): DOES_NOT_EXIST = tests.path_to_data_dir('does-no-exist') SINGLE_FILE = tests.path_to_data_dir('blank.mp3') + SINGLE_SYMLINK = tests.path_to_data_dir('find2/bar') DATA_DIR = tests.path_to_data_dir('') FIND_DIR = tests.path_to_data_dir('find') + FIND2_DIR = tests.path_to_data_dir('find2') def test_basic_dir(self): - result, errors = path.find_mtimes(self.DATA_DIR) + result, errors = path.find_mtimes(self.FIND_DIR) self.assert_(result) self.assertEqual(errors, {}) @@ -247,6 +249,11 @@ class FindMTimesTest(unittest.TestCase): for name in path.find_mtimes(self.DATA_DIR)[0]: self.assertEqual(name, tests.IsA(bytes)) + def test_symlinks_are_ignored(self): + result, errors = path.find_mtimes(self.SINGLE_SYMLINK) + self.assertEqual({}, result) + self.assertEqual({self.SINGLE_SYMLINK: tests.IsA(Exception)}, errors) + # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): From d219bab0b29a30b8bbac68a3eac28eb7caae01d1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Oct 2014 00:06:42 +0200 Subject: [PATCH 130/495] tests: Add test for directory without permission behaviour --- tests/utils/test_path.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 19573003..8c9d8255 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -220,6 +220,7 @@ class FindMTimesTest(unittest.TestCase): DATA_DIR = tests.path_to_data_dir('') FIND_DIR = tests.path_to_data_dir('find') FIND2_DIR = tests.path_to_data_dir('find2') + NO_PERMISSION_DIR = tests.path_to_data_dir('no-permission') def test_basic_dir(self): result, errors = path.find_mtimes(self.FIND_DIR) @@ -254,6 +255,11 @@ class FindMTimesTest(unittest.TestCase): self.assertEqual({}, result) self.assertEqual({self.SINGLE_SYMLINK: tests.IsA(Exception)}, errors) + def test_missing_permission_to_directory(self): + result, errors = path.find_mtimes(self.NO_PERMISSION_DIR) + self.assertEqual({}, result) + self.assertEqual({self.NO_PERMISSION_DIR: tests.IsA(OSError)}, errors) + # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): From 682af273485b86c1fbba4fcae905148ac2acd2a0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Oct 2014 00:17:59 +0200 Subject: [PATCH 131/495] tests: Add test case for file without permissions. --- tests/utils/test_path.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 8c9d8255..f5684049 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -216,6 +216,7 @@ class FindMTimesTest(unittest.TestCase): DOES_NOT_EXIST = tests.path_to_data_dir('does-no-exist') SINGLE_FILE = tests.path_to_data_dir('blank.mp3') + NO_PERMISSION_FILE = tests.path_to_data_dir('no-permission-file') SINGLE_SYMLINK = tests.path_to_data_dir('find2/bar') DATA_DIR = tests.path_to_data_dir('') FIND_DIR = tests.path_to_data_dir('find') @@ -255,6 +256,12 @@ class FindMTimesTest(unittest.TestCase): self.assertEqual({}, result) self.assertEqual({self.SINGLE_SYMLINK: tests.IsA(Exception)}, errors) + def test_missing_permission_to_file(self): + # Note that we cannot know if we have access, but the stat will succeed + result, errors = path.find_mtimes(self.NO_PERMISSION_FILE) + self.assertEqual({self.NO_PERMISSION_FILE: tests.any_int}, result) + self.assertEqual({}, errors) + def test_missing_permission_to_directory(self): result, errors = path.find_mtimes(self.NO_PERMISSION_DIR) self.assertEqual({}, result) From ebb62885cd028f3d4894cc2b5adcffb619a6ce41 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Oct 2014 00:22:13 +0200 Subject: [PATCH 132/495] util/path: Add basic support for following symlinks --- mopidy/utils/path.py | 20 +++++++++++++------- tests/utils/test_path.py | 5 +++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 0730fa06..02152fd4 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -110,11 +110,12 @@ def expand_path(path): return path -def _find_worker(relative, hidden, done, work, results, errors): +def _find_worker(relative, hidden, follow, done, work, results, errors): """Worker thread for collecting stat() results. :param str relative: directory to make results relative to :param bool hidden: whether to include files and dirs starting with '.' + :param bool follow: if symlinks should be followed :param threading.Event done: event indicating that all work has been done :param queue.Queue work: queue of paths to process :param dict results: shared dictionary for storing all the stat() results @@ -132,7 +133,11 @@ def _find_worker(relative, hidden, done, work, results, errors): path = entry try: - st = os.lstat(entry) + if follow: + st = os.stat(entry) + else: + st = os.lstat(entry) + if stat.S_ISDIR(st.st_mode): for e in os.listdir(entry): if hidden or not e.startswith(b'.'): @@ -147,7 +152,7 @@ def _find_worker(relative, hidden, done, work, results, errors): work.task_done() -def _find(root, thread_count=10, hidden=True, relative=False): +def _find(root, thread_count=10, hidden=True, relative=False, follow=False): """Threaded find implementation that provides stat results for files. Note that we do _not_ handle loops from bad sym/hardlinks in any way. @@ -157,6 +162,7 @@ def _find(root, thread_count=10, hidden=True, relative=False): mitigate network lag when scanning on NFS etc. :param bool hidden: whether to include files and dirs starting with '.' :param bool relative: if results should be relative to root or absolute + :param bool follow: if symlinks should be followed """ threads = [] results = {} @@ -168,9 +174,9 @@ def _find(root, thread_count=10, hidden=True, relative=False): if not relative: root = None + args = (root, hidden, follow, done, work, results, errors) for i in range(thread_count): - t = threading.Thread(target=_find_worker, - args=(root, hidden, done, work, results, errors)) + t = threading.Thread(target=_find_worker, args=args) t.daemon = True t.start() threads.append(t) @@ -182,8 +188,8 @@ def _find(root, thread_count=10, hidden=True, relative=False): return results, errors -def find_mtimes(root): - results, errors = _find(root, hidden=False, relative=False) +def find_mtimes(root, follow=False): + results, errors = _find(root, hidden=False, relative=False, follow=follow) mtimes = dict((f, int(st.st_mtime)) for f, st in results.iteritems()) return mtimes, errors diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index f5684049..1fdf0b27 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -267,6 +267,11 @@ class FindMTimesTest(unittest.TestCase): self.assertEqual({}, result) self.assertEqual({self.NO_PERMISSION_DIR: tests.IsA(OSError)}, errors) + def test_basic_symlink(self): + result, errors = path.find_mtimes(self.SINGLE_SYMLINK, follow=True) + self.assertEqual({self.SINGLE_SYMLINK: tests.any_int}, result) + self.assertEqual({}, errors) + # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): From b2419f98144b4552466000593e601216e7d4a4d4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Oct 2014 00:26:39 +0200 Subject: [PATCH 133/495] tests: Add test case for a symlink pointing at itself --- tests/data/symlink-loop | 1 + tests/utils/test_path.py | 6 ++++++ 2 files changed, 7 insertions(+) create mode 120000 tests/data/symlink-loop diff --git a/tests/data/symlink-loop b/tests/data/symlink-loop new file mode 120000 index 00000000..c83815d9 --- /dev/null +++ b/tests/data/symlink-loop @@ -0,0 +1 @@ +symlink-loop \ No newline at end of file diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 1fdf0b27..ca913f86 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -222,6 +222,7 @@ class FindMTimesTest(unittest.TestCase): FIND_DIR = tests.path_to_data_dir('find') FIND2_DIR = tests.path_to_data_dir('find2') NO_PERMISSION_DIR = tests.path_to_data_dir('no-permission') + SYMLINK_LOOP = tests.path_to_data_dir('symlink-loop') def test_basic_dir(self): result, errors = path.find_mtimes(self.FIND_DIR) @@ -272,6 +273,11 @@ class FindMTimesTest(unittest.TestCase): self.assertEqual({self.SINGLE_SYMLINK: tests.any_int}, result) self.assertEqual({}, errors) + def test_direct_symlink_loop(self): + result, errors = path.find_mtimes(self.SYMLINK_LOOP, follow=True) + self.assertEqual({}, result) + self.assertEqual({self.SYMLINK_LOOP: tests.IsA(OSError)}, errors) + # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): From 93c0d6cc4405dcf38ab433eacbabf8dcf8503a39 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Oct 2014 00:32:12 +0200 Subject: [PATCH 134/495] tests: Update no permission test to use tempfile. --- tests/utils/test_path.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index ca913f86..91a1345b 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -214,9 +214,11 @@ class ExpandPathTest(unittest.TestCase): class FindMTimesTest(unittest.TestCase): maxDiff = None + # TODO: Consider if more of these directory structures should be created by + # the test. This would make it more obvious what our expected result is. + DOES_NOT_EXIST = tests.path_to_data_dir('does-no-exist') SINGLE_FILE = tests.path_to_data_dir('blank.mp3') - NO_PERMISSION_FILE = tests.path_to_data_dir('no-permission-file') SINGLE_SYMLINK = tests.path_to_data_dir('find2/bar') DATA_DIR = tests.path_to_data_dir('') FIND_DIR = tests.path_to_data_dir('find') @@ -259,9 +261,11 @@ class FindMTimesTest(unittest.TestCase): def test_missing_permission_to_file(self): # Note that we cannot know if we have access, but the stat will succeed - result, errors = path.find_mtimes(self.NO_PERMISSION_FILE) - self.assertEqual({self.NO_PERMISSION_FILE: tests.any_int}, result) - self.assertEqual({}, errors) + with tempfile.NamedTemporaryFile() as tmp: + os.chmod(tmp.name, 0) + result, errors = path.find_mtimes(tmp.name) + self.assertEqual({tmp.name: tests.any_int}, result) + self.assertEqual({}, errors) def test_missing_permission_to_directory(self): result, errors = path.find_mtimes(self.NO_PERMISSION_DIR) From 69fa6f4674326f880985c82d8a3017ac486e2821 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Oct 2014 00:44:19 +0200 Subject: [PATCH 135/495] tests: Test symlink that points to it's own parent. --- tests/data/find3/loop | 1 + 1 file changed, 1 insertion(+) create mode 120000 tests/data/find3/loop diff --git a/tests/data/find3/loop b/tests/data/find3/loop new file mode 120000 index 00000000..ba817196 --- /dev/null +++ b/tests/data/find3/loop @@ -0,0 +1 @@ +../find3 \ No newline at end of file From 9b1d20677d7e84f44320d663f88494408166e556 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Oct 2014 01:49:06 +0200 Subject: [PATCH 136/495] tests: Cleanup find tests to use tempfile all over. This should make it more clear what structure we expect. --- tests/data/find/.blank.mp3 | Bin 9360 -> 0 bytes tests/data/find/.hidden/.gitignore | 0 tests/data/find/baz/file | 0 tests/data/find/foo/bar/file | 0 tests/data/find/foo/file | 0 tests/data/find2/bar | 1 - tests/data/find2/foo | 0 tests/data/find3/loop | 1 - tests/data/symlink-loop | 1 - tests/utils/test_path.py | 167 +++++++++++++++++++---------- 10 files changed, 113 insertions(+), 57 deletions(-) delete mode 100644 tests/data/find/.blank.mp3 delete mode 100644 tests/data/find/.hidden/.gitignore delete mode 100644 tests/data/find/baz/file delete mode 100644 tests/data/find/foo/bar/file delete mode 100644 tests/data/find/foo/file delete mode 120000 tests/data/find2/bar delete mode 100644 tests/data/find2/foo delete mode 120000 tests/data/find3/loop delete mode 120000 tests/data/symlink-loop diff --git a/tests/data/find/.blank.mp3 b/tests/data/find/.blank.mp3 deleted file mode 100644 index ef159a700449f6a2bf4c03fc206be8f2ff1c7469..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9360 zcmeHtXH*ki+ivJZ5Ru+Ok=~nhm4HBifRu!;0@ADWrcwn0(tGG7l+Zf_5J99X9qEc9 z3JQt{B0P{Y=(()ZEx8_x67N`Ez!DP5|CTjer?w$w?|J`H~8QSR{au!br(a zZo~^A!hkq*7Z)Ca8~+a$;Unu1grtkSjZGbRyAR?2t`i;sw{*{L&h)N%$F4P<_2x9> zpWk9tZ(6_K4_-;ERUdNe@AG->ra70nifVq<&9zo9fW5m z{j7b|XIZbQm*nksQ}atkRnw6&IR;uw#Y9G&48*3U*#%bgGMej5T(wUe%T7xjh^%0U z5*Zl0&t>3DL$gbjnof~=jf4MMd|FVH)~sv`TJk(fo?oMc=Y8yeFA;$f&=Y{0)EKoA1BfC*GJTS8cv+ z7__{E%OV@XU3W(3b%$d!AtbKzosYf3mO(u9?0>vQpu)4a?{&w)D)Mw?{tR@==!F!G z4TL2y1-N!ck5Mntsy(Vz4PukeAa|N~e?0#2iQx;F;JjqS;ULw`+dI6XBlAY+*00`g zXJRvJL+d_tZ%m!bt8!|T4k~#RjyD_|n+1iSt5e!DAw;##!F7Y&#BLBe7P4I5FsAm? z@y@OoX-u`VFsnE%h)0;SCUgXYyAv}}84-BZsg7>yZAIckHDINjSmRpn^kblT1oqm)zF{ET4G-)a9S zg9Gshaf}^LKuxrfwuW@^58$M;%(hUqQ&nPS!4lIzDefchKIfe_5gSdAVJHF}rFPH-?cY`dGT;THyrmQah**zTU zOuR0ZJjals$9PjYu6RdCBx>NmUUPo(N`=6&1AI`P^lO*eHIQc&R-fkp&#_7j`2=s~ zUmRNV4qplLJK^GaV8`VrA&p#Evv>?{A|;{=LUKC!wYJ~f zI3hiY45{>tcjK29_4nSrXc2|?MjDAch7YjtUvqp$KYS~Gd2#$6=P%a z`I;(WBRlANgE$6Vpdv1AaTy8~)~a>)s793~vIS6`24JmJPqTB@CXg9{vsi37=b=CK zB+e-$50s09E$zt^RB2MY7GJ!d3n5aytKwpLELv9=ruJ$`w!Y(~0X9;EW9un>^P=z$ zW+z>KOSMXA*o3(5oBOYBsnhZMMx1>@sRv_>n$yw@43yCMgj@I4-|3M$v$hA!3ZI2v z^y8~YIh=%dERSMwW+CxF2@nQHA-C~-P zyp=0f_oy85Jvz5PG;bN%@Tsp<#uQ7>Ma{|;_Ucwrn?JTu zlJj8aCf{UZn|qp{vdb8Ud@-0x9c(8h1Zm~OY^h$s)Q1xYgZ0eL2A+#%pj<>8J5d17 z2+H_8faznvyx!d(eY2fZ$}IH$XMUjM)<%Tq(5#>OQ?df-_b0YIp@IR*wrAMQ*9T$@ zmtlK9-p+pB{wj0&#j$K_^Af4nmGrAVB(!~#)@z+KIEjZt2~Cz?`X)2Rc6{u;EOm7wN8dEPb`~7a6 ztLk|WPWaPnbWpG-%^;pG=^T%)#T6g_10m|vnHU)_W&6dvzvb;|#)RE@s9~v9${dk; zX%D-}b#+^UA12YR4dRjJjtm6tkhXxG(w9A~x%CtIX}&)5{GPz%i;QR1w&`>au|{DC zy5;-l`FAhY{t)`>!==7+-tP@O`l-7fWAfR#@qqpF^1EoEKcSpTvr=d6PpGd>F*?Pg z^YJN1OMqd2t~~x$Wrlw%SEolsmckL2VWD0;-@UF%CO-J_GEo{dsC-8F=TiC9C_Sk6 z>+EP>5RWwX6L&c#B{U+KOggfL0>Sn0kN=P~llW;RC06R}+uN$J#rqpkv_d!dNFKf) zr0mzMN3q*uC z2759G$aUp9Pe^UK^VFi?>{*ia>afZfi=Ng>Eq^3qGMQCPR+6E_HD`%s`(p2NAZ%H* z4-Je+qhCv?zlvle`IkMjQAL4rsRaku;8QBV7>4_encpk2J)ay|seg8O#MB%hOxh9n z-S@lzy(Gh>-NNCYVFF*;w>r579ax#@bIB=EH08@4u38bSpX;Lt7sZY@ zin1T1lqD8h6?4u+85{eHnga%fPdQ$KkR0#TX4EJ*c|sA{^K$BX{lcrd+LIv9+?)fT z{mhEv`laVQv%Sb!GT8^|+kNH}4TG3;^R^$HpRBLlJeHp+EzzHnF-7zt*n@rFOMjtR z>0CK%=q>ekTaycJ=xE-Bdl5gbwVnyPmV}V-^Aen`a1cp03(CcGr?de=c5c>;nc8z1 zPIpK=Z$;Wue6&`zjAOWOGC{7M*R~m(9p7~FX#m6{#o+_+1Nggxqe&il-UU1DJ7tU4 zWW^-%n;$fjGx}WUqywt6Y$YLlmo9ZJPJhr1y1Tmm`&aIf{dm9~-vXgIwfH|*ON5Ke z{#JeMz^nUR{LxV9k>pg*>_-W2Cl7Gy^V1zVK2-2IG&$$h0Bo5ytST4HudW`~9wd^` z`0jpq^fLw}?qPK!(4KFz?}YxvBVA$x;iVx;XDoKd#HBWhc9uFV)J}io>Tfb#K6)NQ z!zuad#r|`GqYFB(cA^;IoQVH*!$_N4@vbo_7X$0eJ-~iu8L4I^V;gMji1JwvC`MrI zo-v-(G3jb|sj9oiT@I8d0@Gn;30d>yNrh_irOb(@SJ>7pwdtDNXM!nC8}4ffKR=HT zjIV0T@mL&R?=NrlPsnmcC*ZnMj(Z_Qf>rS6$73kN#u8NS-bPEBT1h|4GuLY-y~J4^ zb7OY0@D1g0C=Ff%#3Rd@b`Q+&oJ*Yua*FlPN~)oVljQ=$Z*nBkDAnu6OzG3^gxvO1 z))9%Cr)(#=)EWWOJfjZ5iq}Mk=(MmO(`Rcpgsx4=0Q z75(H=aUX26hqcQa&$L^u+FrH(_a5wP z>dqT8+xRr{Tt{MC4ih1j0NCd`c*phmPg#Ha#gfL9*}~`9SleI>5KDfoL5Ng9xh`=w zgK@)z#`TXnGNQ1FR5YSI6_>z(UQ`iHSobHxE0B-UU@P_f-5VO3GMuj&r>9!pdfq83 zhq;H?QODnZQ69g+CC|YKFG}NW?>4IOv2Iu4C3G&ACKZ(%i=KSa+ zwpIiyKhf}_;>8|Ao7UG9j7O1n)NSpyxi_821#QDL^qFg@hW8WmyC=N*vd()hKKFP| zJ!&!Ovq^0}x)eyKo@CBuH_Z7rD}g3XB-Qq_^DTckPu5~hp6CL1%)?Hr)YdtEB>l@7 z8f}A?!+WgVswW^GDJTrYGfCSz%2dmrcj45x?dTdQz!un;;ym!qyF33$9fz05?{kz# z@^YfrlySe*H|C&KLsU{;t%x#Lz;g$kU*0D@ZYjUdPFFUnTAXj?@teoz;UVP1+8WgI5N*>rQ1<3FspXa1Q`!h<PZ@wNQaz)WPksB3O{2>*{ zL5{wb*Am>#HXAiC#c@&Rkh}tXaXl+g_6p@QZ8A12#J{1>Z~d0pJHqS1OG63b;bCI~ z>%&>b6(uyuCDDhQoaReBHODy%=YtE*D}FF#H`?rpM+!9pn_CR_W~@w>X>JoQkjaX* z=AFO9tY!k_!wF{_n4Rp-sQ!$7tf>muvjV}b``6dTF5XY1zY33z{5~+*B>PrTjM<0ll z{a`>UQ?H-*{C1OZ?5XDn%kbH(6?{KWxg`4 z`##1kCYh(>_8T{+bU1R5GeF8|DW!uxF>`|IJ$a*Txl!S38Rt^H_}tHziPZOkv#VH` zNve%eR3^^B3Apj%eV`^%J>63XC3ICMkB3EPgLqgtctb(GzxVLVlwncPHjJO;lND)D zp|Rt=QKEfNs`p(L{(+j$@l({I>lbTz<5u137yrzV2rbczi)Zp2SE%90O`U(gl6>IZ zd?PlTaPpIAd{NCJB2S9*CoQok*x>*+R-X$Yx_%~g$rVFw3@LGH5RU{f2lyN3#rwX{pLh2?#h5H-V*imSd{g7=&D?gHf|`iei`!sL zoK#M>@>*EvIQ^Y7?deHqwmO#-lNZ3>h>IHDN9M&WXaR*n9$1Kx{JNOV*N(5;7H#(L zxSKKn(VL! za85!AMO8sVqBI?$R;QNzFZ+}RDKVOo4tBJSZW(A{-)SfG4OfSK z&@|9}^b47BxkJrtW5pk*jn2j6i@yOqPQcz|k0S9_rDyl>-N{m7(ycNc;yDr!4-YHo zU&52(1(VyD_<;%|VOU(6N^EYr)%QQgTTdp3g3NB}&-+b8CqGVVN{vw?6Gi+Qo5rl} zeOQ+woc9wh#$5sv_x0m=naqIjg%2+QeCt*x?tph7=(5f)2U_7jtvU9(n|iZ)ah0Vk zRhOmJzXWiLhsYT#MemMr!e3NBvL9%U4I&-Xwfqe7tifuj0{Ry}u@P^cvn8uWdjEMi z;o_~=z11geeUEAK!ipB#zi5!SnFYn;YEuH|Aw+GnsOC)NimZ4e8I!p(vk+=fE)FhB zd*C?;8a^pxz+n5@GxbmGchv5|81G%B=S_f-4chuhsOr5Xp<|+=j)z^3d?T)oqc2|) zkFtx4OQ!Jq5Cz+wZt`6UN`ySM@yCzmKe9PQYVb=(+Xn9YRv$|jy*!Hwa0XLm@kK9$ z1XoDgqcCRE8Cv<%q*n8%Au7L;d(@L*g4dU?mR1qOBMkKc<_RVUBmRd%>F^Jl+IO@& zE;1-T`j`7-U7^ls;oX*$NK8(XiHp42dz)p`S8N<~H^gn>tg;jqWtfoRjT#*hY>;E- z`9-CN_jsgkfiL%xw6UCvxVWy_-!1?wg*y&ISKji!^h)fxx@Ce=Y-dU5vDBJ~L?dHH z-^U}hyk)fb}3GawiQdQ0w=zSQ;tU1DG&GU3A$Wv2ABAup(dNah} z^~^6n;X;J?`AFnL-<^5+NL}kkj_ALI9M}71H=JzlCR_m`8xG(2unR(b?W|DJ=7$+K zW3=SuM{5q7&|M+!>v96ivcC%%Z}7+{aR=6FN?1|0>HYC=OE{UcUq( zDhjPiaDA(b&PD6%I)QSrKnI*gfH+oBfv>23HG0u9{;Wws&#-%rbYI9S{>OZuC_C$T z22ItSUy~B5YhJE;Ob;KWmRMyGX;VSzZR;F-*SK1u%csx8zJ!D=z_`u$RNlr!J5iSt z6DjZ?8HL)~uf;S;a$I3%eTW#CGz$&}%2JT|p3h#0h~_b%ze@5?kwWg62nWyRf_S(& zm+=74Ml{N5p`*8@b!5H`T#{U{)fVUY%)6cJ^ASgGZtX0xGU3VPdWSL2cRJy2^Au$q z+)HgbA_OKOxMJs}ys^yx+NiJAP7j}Kl){65h1vX;40BB2X(grB2?0xnW5C11oi!)% zqBTV4#B9nY?OL&_|OA6KgE!2H*{OnKL)9|+%ECIc> zmp;PvSmyNFEwzD)p*p+{sE6As4qY6bSBqu2B~}vu>YaU;{3SR~5cF{pZ%$nW*R-{NjP@lEn?v_FTb^ zN7lcycC9SzClDUH+O{H6zCTt-wFg`bhc7Nf8B zTfU5n5WFJnOA+%tB_D`KlGAPwtiw}3kZ0L0evY9Zl(o~tz&s6as*^Lr53CRU#0B{5 zU~eOT%hXU)XfresnEfQwv?SAK8(R859ET~oNHH=cX?lc-C*U!_K>|Q`6D#m~x4mw) zdrN$?f$G*InOT?c=@h1^;e&K>y3^@V!#uZJXIm(q-7>xdwI{D@`4gF{6ii7A^i8Zo zw^-N=fjVkZTpBg0v7nTQ!M^S{D9TFzu15gZa;uD~r^d?#RB#B;(SwyR{z)N&kiGWd z$}cF3pG*r2U$Ph|7Z)e&Z(c@ph!)ha$r)j`oyvuysuMqSAGBUg>WovAt)fDvC%)Wb zGD2!mMa~aVcfeP*cA7crmQo?3D4CIHPiCk3pd!I7T)WC}qf&`6Z)Sat-s)wOya#Zd zV4z4!0nC|*pxR!$@1W9}$2!cRY!!rBzV2nOP@ax7pj<_`7OJ<<$GFY@&C9?X63Abo z+SO!Yypd_5&;Jn;sDkU}MAN!)K4g@qE2Wh;NVFW)62qi>!KMc~##S)iIu-=N1xy5e z5?d)!^HRXX8I+5N**FlK{}?3z?S119fPkKzH?v*RIZsh|1d>g*8OEPI$isjkMZA3K zSuZ_YgqcRGLt=erZc*vm@6ch-B`plbeRB(4pUNRDkKh@r=jFi6rXF=nC@{H zX#vKI3-<&{g7ch15r?Z+7U%_iMmSib%MG0rCo(rK8|0?4k*Lo=|Mr!l9LouR&t(`q z`3=<~EvN^qQq~T+p^^+I%VL+}XE#ktZEfY>WLrAwdR-r3$1E12^+ro;YlA&&t%K$# zWa_U|Xo&mN`bxq}{@NAx%foFGoMQa)? z_1S8o6;?g1??3)n&`cnlFa7+=qIr9_P*FTs90?pop>f^CKqW*B=nLp8IR?@vxZ!XJ zLGftHMl$szhp5K6Qhq?hD{6^lSLkfVDzN0ezol91RnD$^BnGjf|bLL z!|x5bT{rJWTI*_aSakMHO3(GIliOf}d6j=;%j5NdfbUn#6K9HqUq#o@jb9TMfAliA@YULa~E>?KDHn zHZ2C27EJ_k`{~=qS&UgUe=bnDLr*kErDWH@gQ>bcb4}BJh?apBj=NN?b_GU8oh3-e zF6P{gtEALnZ^zwvar+4q=(A|I6e4e_CYQ zZxJT#zl7c*5Voa+6HEIEgoUJsjPNgxq6xtN{&{Am3H0x*Luw2R?~;n6q#z0N)e#Tm zfGxs_rX*LZTh{+R(3X@%{f7lUM-=#i22S(>0{IX3{&(Bpa~#3vfM?|Y-HrcY4++!x i`}Ggczgz$M7U3;$Kn@+<3 diff --git a/tests/data/find/.hidden/.gitignore b/tests/data/find/.hidden/.gitignore deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/data/find/baz/file b/tests/data/find/baz/file deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/data/find/foo/bar/file b/tests/data/find/foo/bar/file deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/data/find/foo/file b/tests/data/find/foo/file deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/data/find2/bar b/tests/data/find2/bar deleted file mode 120000 index 19102815..00000000 --- a/tests/data/find2/bar +++ /dev/null @@ -1 +0,0 @@ -foo \ No newline at end of file diff --git a/tests/data/find2/foo b/tests/data/find2/foo deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/data/find3/loop b/tests/data/find3/loop deleted file mode 120000 index ba817196..00000000 --- a/tests/data/find3/loop +++ /dev/null @@ -1 +0,0 @@ -../find3 \ No newline at end of file diff --git a/tests/data/symlink-loop b/tests/data/symlink-loop deleted file mode 120000 index c83815d9..00000000 --- a/tests/data/symlink-loop +++ /dev/null @@ -1 +0,0 @@ -symlink-loop \ No newline at end of file diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 91a1345b..3dcc8ac8 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -214,73 +214,132 @@ class ExpandPathTest(unittest.TestCase): class FindMTimesTest(unittest.TestCase): maxDiff = None - # TODO: Consider if more of these directory structures should be created by - # the test. This would make it more obvious what our expected result is. + def setUp(self): + self.tmpdir = tempfile.mkdtemp(b'.mopidy-tests') - DOES_NOT_EXIST = tests.path_to_data_dir('does-no-exist') - SINGLE_FILE = tests.path_to_data_dir('blank.mp3') - SINGLE_SYMLINK = tests.path_to_data_dir('find2/bar') - DATA_DIR = tests.path_to_data_dir('') - FIND_DIR = tests.path_to_data_dir('find') - FIND2_DIR = tests.path_to_data_dir('find2') - NO_PERMISSION_DIR = tests.path_to_data_dir('no-permission') - SYMLINK_LOOP = tests.path_to_data_dir('symlink-loop') + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) - def test_basic_dir(self): - result, errors = path.find_mtimes(self.FIND_DIR) - self.assert_(result) - self.assertEqual(errors, {}) + def mkdir(self, *args): + name = os.path.join(self.tmpdir, *args) + os.mkdir(name) + return name - def test_nonexistant_dir(self): - result, errors = path.find_mtimes(self.DOES_NOT_EXIST) - self.assertEqual(result, {}) - self.assertEqual(errors, {self.DOES_NOT_EXIST: tests.IsA(OSError)}) - - def test_file(self): - result, errors = path.find_mtimes(self.SINGLE_FILE) - self.assertEqual(result, {self.SINGLE_FILE: tests.any_int}) - self.assertEqual(errors, {}) - - def test_files(self): - result, errors = path.find_mtimes(self.FIND_DIR) - expected = { - tests.path_to_data_dir(b'find/foo/bar/file'): tests.any_int, - tests.path_to_data_dir(b'find/foo/file'): tests.any_int, - tests.path_to_data_dir(b'find/baz/file'): tests.any_int} - self.assertEqual(expected, result) - self.assertEqual(errors, {}) + def touch(self, *args): + name = os.path.join(self.tmpdir, *args) + open(name, 'w').close() + return name def test_names_are_bytestrings(self): - for name in path.find_mtimes(self.DATA_DIR)[0]: + """We shouldn't be mixing in unicode for paths.""" + result, errors = path.find_mtimes(tests.path_to_data_dir('')) + for name in result.keys() + errors.keys(): self.assertEqual(name, tests.IsA(bytes)) - def test_symlinks_are_ignored(self): - result, errors = path.find_mtimes(self.SINGLE_SYMLINK) - self.assertEqual({}, result) - self.assertEqual({self.SINGLE_SYMLINK: tests.IsA(Exception)}, errors) + def test_nonexistent_dir(self): + """Non existent search roots are an error""" + missing = os.path.join(self.tmpdir, 'does-not-exist') + result, errors = path.find_mtimes(missing) + self.assertEqual(result, {}) + self.assertEqual(errors, {missing: tests.IsA(OSError)}) + + def test_empty_dir(self): + """Empty directories should not show up in results""" + self.mkdir('empty') + + result, errors = path.find_mtimes(self.tmpdir) + self.assertEqual(result, {}) + self.assertEqual(errors, {}) + + def test_file_as_the_root(self): + """Specifying a file as the root should just return the file""" + single = self.touch('single') + + result, errors = path.find_mtimes(single) + self.assertEqual(result, {single: tests.any_int}) + self.assertEqual(errors, {}) + + def test_hidden_directories_are_skipped(self): + pass + + def test_hidden_files_are_skipped(self): + pass + + def test_nested_directories(self): + """Searching nested directories should find all files""" + + # Setup foo/bar and baz directories + self.mkdir('foo') + self.mkdir('foo', 'bar') + self.mkdir('baz') + + # Touch foo/file foo/bar/file and baz/file + foo_file = self.touch('foo', 'file') + foo_bar_file = self.touch('foo', 'bar', 'file') + baz_file = self.touch('baz', 'file') + + result, errors = path.find_mtimes(self.tmpdir) + self.assertEqual(result, {foo_file: tests.any_int, + foo_bar_file: tests.any_int, + baz_file: tests.any_int}) + self.assertEqual(errors, {}) def test_missing_permission_to_file(self): - # Note that we cannot know if we have access, but the stat will succeed - with tempfile.NamedTemporaryFile() as tmp: - os.chmod(tmp.name, 0) - result, errors = path.find_mtimes(tmp.name) - self.assertEqual({tmp.name: tests.any_int}, result) - self.assertEqual({}, errors) + """Missing permissions to a file is not a search error""" + target = self.touch('no-permission') + os.chmod(target, 0) - def test_missing_permission_to_directory(self): - result, errors = path.find_mtimes(self.NO_PERMISSION_DIR) - self.assertEqual({}, result) - self.assertEqual({self.NO_PERMISSION_DIR: tests.IsA(OSError)}, errors) - - def test_basic_symlink(self): - result, errors = path.find_mtimes(self.SINGLE_SYMLINK, follow=True) - self.assertEqual({self.SINGLE_SYMLINK: tests.any_int}, result) + result, errors = path.find_mtimes(self.tmpdir) + self.assertEqual({target: tests.any_int}, result) self.assertEqual({}, errors) - def test_direct_symlink_loop(self): - result, errors = path.find_mtimes(self.SYMLINK_LOOP, follow=True) + def test_missing_permission_to_directory(self): + """Missing permissions to a directory is an error""" + directory = self.mkdir('no-permission') + os.chmod(directory, 0) + + result, errors = path.find_mtimes(self.tmpdir) self.assertEqual({}, result) - self.assertEqual({self.SYMLINK_LOOP: tests.IsA(OSError)}, errors) + self.assertEqual({directory: tests.IsA(OSError)}, errors) + + def test_symlinks_are_ignored(self): + """By default symlinks should be treated as an error""" + target = self.touch('target') + link = os.path.join(self.tmpdir, 'link') + os.symlink(target, link) + + result, errors = path.find_mtimes(self.tmpdir) + self.assertEqual(result, {target: tests.any_int}) + self.assertEqual(errors, {link: tests.IsA(Exception)}) + + def test_symlink_to_file_as_root_is_followed(self): + """Passing a symlink as the root should be followed when follow=True""" + target = self.touch('target') + link = os.path.join(self.tmpdir, 'link') + os.symlink(target, link) + + result, errors = path.find_mtimes(link, follow=True) + self.assertEqual({link: tests.any_int}, result) + self.assertEqual({}, errors) + + def test_symlink_to_directory_is_followed(self): + pass + + def test_symlink_pointing_at_itself_fails(self): + link = os.path.join(self.tmpdir, 'link') + os.symlink(link, link) + + result, errors = path.find_mtimes(link, follow=True) + self.assertEqual({}, result) + self.assertEqual({link: tests.IsA(OSError)}, errors) + + def test_symlink_pointing_at_parent_fails(self): + os.symlink(self.tmpdir, os.path.join(self.tmpdir, 'link')) + + result, errors = path.find_mtimes(self.tmpdir, follow=True) + self.assertEqual({}, result) + self.assertEqual(1, len(errors)) + self.assertEqual(tests.IsA(OSError), errors.values()[0]) # TODO: kill this in favour of just os.path.getmtime + mocks From 54a89038d3e596025d5b361234cbb228127000d1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Oct 2014 23:00:12 +0200 Subject: [PATCH 137/495] utils/path: Don't skip hidden files and folders in generic find code Updates the local scan code to do this instead. --- mopidy/local/commands.py | 6 ++++++ mopidy/utils/path.py | 13 +++++-------- tests/utils/test_path.py | 6 ------ 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index a182aa25..098bd9a9 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -75,6 +75,12 @@ class ScanCommand(commands.Command): uris_to_remove = set() file_mtimes, file_errors = path.find_mtimes(media_dir) + + # TODO: Not sure if we want to keep this, but for now lets filter these + for name in file_mtimes.keys(): + if name.startswith('.'): + del file_mtimes[name] + logger.info('Found %d files in media_dir.', len(file_mtimes)) # TODO: log file errors diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 02152fd4..34a7f75e 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -110,11 +110,10 @@ def expand_path(path): return path -def _find_worker(relative, hidden, follow, done, work, results, errors): +def _find_worker(relative, follow, done, work, results, errors): """Worker thread for collecting stat() results. :param str relative: directory to make results relative to - :param bool hidden: whether to include files and dirs starting with '.' :param bool follow: if symlinks should be followed :param threading.Event done: event indicating that all work has been done :param queue.Queue work: queue of paths to process @@ -140,8 +139,7 @@ def _find_worker(relative, hidden, follow, done, work, results, errors): if stat.S_ISDIR(st.st_mode): for e in os.listdir(entry): - if hidden or not e.startswith(b'.'): - work.put(os.path.join(entry, e)) + work.put(os.path.join(entry, e)) elif stat.S_ISREG(st.st_mode): results[path] = st else: @@ -152,7 +150,7 @@ def _find_worker(relative, hidden, follow, done, work, results, errors): work.task_done() -def _find(root, thread_count=10, hidden=True, relative=False, follow=False): +def _find(root, thread_count=10, relative=False, follow=False): """Threaded find implementation that provides stat results for files. Note that we do _not_ handle loops from bad sym/hardlinks in any way. @@ -160,7 +158,6 @@ def _find(root, thread_count=10, hidden=True, relative=False, follow=False): :param str root: root directory to search from, may not be a file :param int thread_count: number of workers to use, mainly useful to mitigate network lag when scanning on NFS etc. - :param bool hidden: whether to include files and dirs starting with '.' :param bool relative: if results should be relative to root or absolute :param bool follow: if symlinks should be followed """ @@ -174,7 +171,7 @@ def _find(root, thread_count=10, hidden=True, relative=False, follow=False): if not relative: root = None - args = (root, hidden, follow, done, work, results, errors) + args = (root, follow, done, work, results, errors) for i in range(thread_count): t = threading.Thread(target=_find_worker, args=args) t.daemon = True @@ -189,7 +186,7 @@ def _find(root, thread_count=10, hidden=True, relative=False, follow=False): def find_mtimes(root, follow=False): - results, errors = _find(root, hidden=False, relative=False, follow=follow) + results, errors = _find(root, relative=False, follow=follow) mtimes = dict((f, int(st.st_mtime)) for f, st in results.iteritems()) return mtimes, errors diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 3dcc8ac8..0e91e008 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -259,12 +259,6 @@ class FindMTimesTest(unittest.TestCase): self.assertEqual(result, {single: tests.any_int}) self.assertEqual(errors, {}) - def test_hidden_directories_are_skipped(self): - pass - - def test_hidden_files_are_skipped(self): - pass - def test_nested_directories(self): """Searching nested directories should find all files""" From 063c757570f0c11678e9ade47811c3daeba26233 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Oct 2014 23:21:11 +0200 Subject: [PATCH 138/495] utils/path: Add support for handling sym/hardlink loops --- mopidy/utils/path.py | 15 +++++++++++---- tests/utils/test_path.py | 39 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 34a7f75e..01b97a50 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -122,7 +122,7 @@ def _find_worker(relative, follow, done, work, results, errors): """ while not done.is_set(): try: - entry = work.get(block=False) + entry, parents = work.get(block=False) except queue.Empty: continue @@ -137,13 +137,19 @@ def _find_worker(relative, follow, done, work, results, errors): else: st = os.lstat(entry) + if (st.st_dev, st.st_ino) in parents: + errors[path] = Exception('Sym/hardlink loop found.') + continue + + parents = parents + [(st.st_dev, st.st_ino)] if stat.S_ISDIR(st.st_mode): for e in os.listdir(entry): - work.put(os.path.join(entry, e)) + work.put((os.path.join(entry, e), parents)) elif stat.S_ISREG(st.st_mode): results[path] = st else: errors[path] = Exception('Not a file or directory') + except os.error as e: errors[path] = e finally: @@ -153,7 +159,8 @@ def _find_worker(relative, follow, done, work, results, errors): def _find(root, thread_count=10, relative=False, follow=False): """Threaded find implementation that provides stat results for files. - Note that we do _not_ handle loops from bad sym/hardlinks in any way. + Tries to protect against sym/hardlink loops by keeping an eye on parent + (st_dev, st_ino) pairs. :param str root: root directory to search from, may not be a file :param int thread_count: number of workers to use, mainly useful to @@ -166,7 +173,7 @@ def _find(root, thread_count=10, relative=False, follow=False): errors = {} done = threading.Event() work = queue.Queue() - work.put(os.path.abspath(root)) + work.put((os.path.abspath(root), [])) if not relative: root = None diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 0e91e008..8dafd951 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -221,12 +221,12 @@ class FindMTimesTest(unittest.TestCase): shutil.rmtree(self.tmpdir, ignore_errors=True) def mkdir(self, *args): - name = os.path.join(self.tmpdir, *args) + name = os.path.join(self.tmpdir, *[bytes(a) for a in args]) os.mkdir(name) return name def touch(self, *args): - name = os.path.join(self.tmpdir, *args) + name = os.path.join(self.tmpdir, *[bytes(a) for a in args]) open(name, 'w').close() return name @@ -320,6 +320,7 @@ class FindMTimesTest(unittest.TestCase): pass def test_symlink_pointing_at_itself_fails(self): + """Symlink pointing at itself should give as an OS error""" link = os.path.join(self.tmpdir, 'link') os.symlink(link, link) @@ -328,12 +329,44 @@ class FindMTimesTest(unittest.TestCase): self.assertEqual({link: tests.IsA(OSError)}, errors) def test_symlink_pointing_at_parent_fails(self): + """We should detect a loop via the parent and give up on the branch""" os.symlink(self.tmpdir, os.path.join(self.tmpdir, 'link')) result, errors = path.find_mtimes(self.tmpdir, follow=True) self.assertEqual({}, result) self.assertEqual(1, len(errors)) - self.assertEqual(tests.IsA(OSError), errors.values()[0]) + self.assertEqual(tests.IsA(Exception), errors.values()[0]) + + def test_indirect_symlink_loop(self): + """More indirect loops should also be detected""" + # Setup tmpdir/directory/loop where loop points to tmpdir + directory = os.path.join(self.tmpdir, b'directory') + loop = os.path.join(directory, b'loop') + + os.mkdir(directory) + os.symlink(self.tmpdir, loop) + + result, errors = path.find_mtimes(self.tmpdir, follow=True) + self.assertEqual({}, result) + self.assertEqual({loop: tests.IsA(Exception)}, errors) + + def test_symlink_branches_are_not_excluded(self): + """Using symlinks to make a file show up multiple times should work""" + self.mkdir('directory') + target = self.touch('directory', 'target') + link1 = os.path.join(self.tmpdir, b'link1') + link2 = os.path.join(self.tmpdir, b'link2') + + os.symlink(target, link1) + os.symlink(target, link2) + + expected = {target: tests.any_int, + link1: tests.any_int, + link2: tests.any_int} + + result, errors = path.find_mtimes(self.tmpdir, follow=True) + self.assertEqual(expected, result) + self.assertEqual({}, errors) # TODO: kill this in favour of just os.path.getmtime + mocks From 3dc0a06ffefcbdcc61f6608253ae4925c4788f99 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Oct 2014 23:53:56 +0200 Subject: [PATCH 139/495] local: Fix skipping of hidden file/directories --- mopidy/local/commands.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 098bd9a9..5cf9f64f 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -77,8 +77,10 @@ class ScanCommand(commands.Command): file_mtimes, file_errors = path.find_mtimes(media_dir) # TODO: Not sure if we want to keep this, but for now lets filter these + # Could be replaced with passing in a predicate to the find function? for name in file_mtimes.keys(): - if name.startswith('.'): + if b'/.' in name: + logger.debug('Skipping hidden file/directory: %r', name) del file_mtimes[name] logger.info('Found %d files in media_dir.', len(file_mtimes)) From 5bf6b779acb6afcf47f6cc3ddca5d8c308bba728 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Oct 2014 23:56:59 +0200 Subject: [PATCH 140/495] local: Add basic logging of scan errors --- mopidy/local/commands.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 5cf9f64f..d72a4477 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -85,7 +85,11 @@ class ScanCommand(commands.Command): logger.info('Found %d files in media_dir.', len(file_mtimes)) - # TODO: log file errors + if file_errors: + logger.warning('Encountered %d errors while scanning media_dir.', + len(file_errors)) + for name in file_errors: + logger.debug('Scan error %r for %r', file_errors[name], name) num_tracks = library.load() logger.info('Checking %d tracks from library.', num_tracks) From 43d8062094a4ae3ffe500a308495ace0180b594a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 17 Oct 2014 16:46:33 +0200 Subject: [PATCH 141/495] util/path: s/os.error/OSError/ --- mopidy/utils/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 01b97a50..f60eff59 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -150,7 +150,7 @@ def _find_worker(relative, follow, done, work, results, errors): else: errors[path] = Exception('Not a file or directory') - except os.error as e: + except OSError as e: errors[path] = e finally: work.task_done() From d4f47a34c2a3563a0abbe101c3e099fb59c37d2e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 17 Oct 2014 16:52:01 +0200 Subject: [PATCH 142/495] local: Move Hidden file/directory check to excluded extensions check --- mopidy/local/commands.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index d72a4477..04eff795 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -76,13 +76,6 @@ class ScanCommand(commands.Command): file_mtimes, file_errors = path.find_mtimes(media_dir) - # TODO: Not sure if we want to keep this, but for now lets filter these - # Could be replaced with passing in a predicate to the find function? - for name in file_mtimes.keys(): - if b'/.' in name: - logger.debug('Skipping hidden file/directory: %r', name) - del file_mtimes[name] - logger.info('Found %d files in media_dir.', len(file_mtimes)) if file_errors: @@ -111,11 +104,13 @@ class ScanCommand(commands.Command): relpath = os.path.relpath(abspath, media_dir) uri = translator.path_to_local_track_uri(relpath) - if relpath.lower().endswith(excluded_file_extensions): + # TODO: move these to a "predicate" check in the finder? + if b'/.' in relpath: + logger.debug('Skipped %s: Hidden directory/file.', uri) + elif relpath.lower().endswith(excluded_file_extensions): logger.debug('Skipped %s: File extension excluded.', uri) - continue - - uris_to_update.add(uri) + else: + uris_to_update.add(uri) logger.info( 'Found %d tracks which need to be updated.', len(uris_to_update)) From 369edab76d884f3a1abae22ac16977a762ffe106 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 17 Oct 2014 20:08:12 +0200 Subject: [PATCH 143/495] utils/path: Make it more clear that we are not following symlinks --- mopidy/utils/path.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index f60eff59..be735ae5 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -147,8 +147,10 @@ def _find_worker(relative, follow, done, work, results, errors): work.put((os.path.join(entry, e), parents)) elif stat.S_ISREG(st.st_mode): results[path] = st + elif stat.S_ISLNK(st.st_mode): + errors[path] = Exception('Not following symlinks.') else: - errors[path] = Exception('Not a file or directory') + errors[path] = Exception('Not a file or directory.') except OSError as e: errors[path] = e From b9a7a9d2b6844e5204cacfbb645755a01a84b20a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 17 Oct 2014 20:08:33 +0200 Subject: [PATCH 144/495] local: Add follow symlinks setting --- docs/ext/local.rst | 4 ++++ mopidy/local/__init__.py | 1 + mopidy/local/commands.py | 3 ++- mopidy/local/ext.conf | 1 + 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 31d00d66..18f66adc 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -86,6 +86,10 @@ See :ref:`config` for general help on configuring Mopidy. Number of milliseconds before giving up scanning a file and moving on to the next file. +.. confval:: local/scan_follow_symlinks + + If we should follow symlinks found in :confval:`local/media_dir` + .. confval:: local/scan_flush_threshold Number of tracks to wait before telling library it should try and store diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index fc1035fe..a0bb7bc4 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -29,6 +29,7 @@ class Extension(ext.Extension): schema['scan_timeout'] = config.Integer( minimum=1000, maximum=1000*60*60) schema['scan_flush_threshold'] = config.Integer(minimum=0) + schema['scan_follow_symlinks'] = config.Boolean() schema['excluded_file_extensions'] = config.List(optional=True) return schema diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 04eff795..1b8981df 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -74,7 +74,8 @@ class ScanCommand(commands.Command): uris_to_update = set() uris_to_remove = set() - file_mtimes, file_errors = path.find_mtimes(media_dir) + file_mtimes, file_errors = path.find_mtimes( + media_dir, follow=config['local']['scan_follow_symlinks']) logger.info('Found %d files in media_dir.', len(file_mtimes)) diff --git a/mopidy/local/ext.conf b/mopidy/local/ext.conf index 9a0f19f1..535f4806 100644 --- a/mopidy/local/ext.conf +++ b/mopidy/local/ext.conf @@ -6,6 +6,7 @@ data_dir = $XDG_DATA_DIR/mopidy/local playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists scan_timeout = 1000 scan_flush_threshold = 1000 +scan_follow_symlinks = false excluded_file_extensions = .directory .html From f499dafe13185a3f90a3a99390c44555708309c2 Mon Sep 17 00:00:00 2001 From: Lukas Vogel Date: Thu, 23 Oct 2014 02:57:57 +0200 Subject: [PATCH 145/495] Escape newline characters when dispatching data --- mopidy/mpd/dispatcher.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 9c2f3471..c48db43a 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -196,12 +196,18 @@ class MpdDispatcher(object): def _format_lines(self, line): if isinstance(line, dict): - return ['%s: %s' % (key, value) for (key, value) in line.items()] + result = [] + for (key, value) in line.items(): + result.extend(self._escape_newlines('%s: %s' % (key, value))) + return [result] if isinstance(line, tuple): (key, value) = line - return ['%s: %s' % (key, value)] + return [self._escape_newlines('%s: %s' % (key, value))] return [line] + def _escape_newlines(self, text): + return text.replace('\n', '\\n') + class MpdContext(object): """ From f0574d1c8e8dd38c6e5f6a296abb47174e829ae1 Mon Sep 17 00:00:00 2001 From: Lukas Vogel Date: Thu, 23 Oct 2014 03:24:18 +0200 Subject: [PATCH 146/495] fixed embarassing bug left over from refactoring to meet coding standards --- mopidy/mpd/dispatcher.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index c48db43a..ad99327a 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -198,8 +198,8 @@ class MpdDispatcher(object): if isinstance(line, dict): result = [] for (key, value) in line.items(): - result.extend(self._escape_newlines('%s: %s' % (key, value))) - return [result] + result.append(self._escape_newlines('%s: %s' % (key, value))) + return result if isinstance(line, tuple): (key, value) = line return [self._escape_newlines('%s: %s' % (key, value))] From c8bc0afc930c9328d6f14fb8c2f728741ed05175 Mon Sep 17 00:00:00 2001 From: Lukas Vogel Date: Thu, 23 Oct 2014 12:31:55 +0200 Subject: [PATCH 147/495] reintroduce generator expression --- mopidy/mpd/dispatcher.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index ad99327a..d063637e 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -196,10 +196,9 @@ class MpdDispatcher(object): def _format_lines(self, line): if isinstance(line, dict): - result = [] - for (key, value) in line.items(): - result.append(self._escape_newlines('%s: %s' % (key, value))) - return result + return [self._escape_newlines('%s: %s' % (key, value)) + for (key, value) + in line.items()] if isinstance(line, tuple): (key, value) = line return [self._escape_newlines('%s: %s' % (key, value))] From 1c5b07a37451e9835b4a129756669f82ca2f39a3 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 24 Oct 2014 12:47:58 +0200 Subject: [PATCH 148/495] Make everything that is not a Track browseable --- mopidy/mpd/dispatcher.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 9c2f3471..c947c13d 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -299,8 +299,7 @@ class MpdContext(object): uri = None for part in path_parts: for ref in self.core.library.browse(uri).get(): - if (ref.type in (ref.DIRECTORY, ref.ALBUM, ref.PLAYLIST) - and ref.name == part): + if ref.type != ref.TRACK and ref.name == part: uri = ref.uri break else: @@ -320,13 +319,13 @@ class MpdContext(object): path = '/'.join([base_path, ref.name.replace('/', '')]) path = self.insert_name_uri_mapping(path, ref.uri) - if ref.type in (ref.DIRECTORY, ref.ALBUM, ref.PLAYLIST): - yield (path, None) - if recursive: - path_and_futures.append( - (path, self.core.library.browse(ref.uri))) - elif ref.type == ref.TRACK: + if ref.type == ref.TRACK: if lookup: yield (path, self.core.library.lookup(ref.uri)) else: yield (path, ref) + else: + yield (path, None) + if recursive: + path_and_futures.append( + (path, self.core.library.browse(ref.uri))) From 63277c4db3c9348e1db50e5e1971ee88311b74b6 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Tue, 4 Nov 2014 19:22:45 +0100 Subject: [PATCH 149/495] Add unit tests for artist browsing --- tests/mpd/protocol/test_music_db.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index e6712fef..ff39c081 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -139,6 +139,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo'), Ref.album(uri='dummy:/album', name='album'), + Ref.artist(uri='dummy:/artist', name='artist'), Ref.playlist(uri='dummy:/pl', name='pl')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} @@ -147,6 +148,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('file: dummy:/a') self.assertInResponse('directory: /dummy/foo') self.assertInResponse('directory: /dummy/album') + self.assertInResponse('directory: /dummy/artist') self.assertInResponse('directory: /dummy/pl') self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('OK') @@ -207,6 +209,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo'), Ref.album(uri='dummy:/album', name='album'), + Ref.artist(uri='dummy:/artist', name='artist'), Ref.playlist(uri='dummy:/pl', name='pl')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} @@ -216,6 +219,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('Title: a') self.assertInResponse('directory: /dummy/foo') self.assertInResponse('directory: /dummy/album') + self.assertInResponse('directory: /dummy/artist') self.assertInResponse('directory: /dummy/pl') self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('Title: b') From cfc644448a6d8739522575fa304d5c304c366b58 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 5 Nov 2014 09:30:19 +0100 Subject: [PATCH 150/495] docs: Update changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b6c19cb2..0e652401 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -37,6 +37,9 @@ v0.20.0 (UNRELEASED) - In stored playlist names, replace "/", which are illegal, with "|" instead of a whitespace. Pipes are more similar to forward slash. +- Enable browsing of artist references, in addition to albums and playlists. + (PR: :issue:`884`) + **Audio** - Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a From 496142c201655f9d54ea3e93250337b9bc58831b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 19:35:15 +0100 Subject: [PATCH 151/495] py3: Use absolute imports by default --- docs/api/http-server.rst | 6 +++--- docs/conf.py | 2 +- docs/extensiondev.rst | 6 +++--- mopidy/__init__.py | 2 +- mopidy/__main__.py | 2 +- mopidy/audio/__init__.py | 2 +- mopidy/audio/actor.py | 2 +- mopidy/audio/constants.py | 2 +- mopidy/audio/dummy.py | 2 +- mopidy/audio/listener.py | 2 +- mopidy/audio/playlists.py | 2 +- mopidy/audio/scan.py | 2 +- mopidy/audio/utils.py | 2 +- mopidy/backend/__init__.py | 2 +- mopidy/backend/dummy.py | 2 +- mopidy/commands.py | 2 +- mopidy/config/__init__.py | 2 +- mopidy/config/keyring.py | 2 +- mopidy/config/schemas.py | 2 +- mopidy/config/types.py | 2 +- mopidy/config/validators.py | 2 +- mopidy/core/__init__.py | 2 +- mopidy/core/actor.py | 2 +- mopidy/core/history.py | 2 +- mopidy/core/library.py | 2 +- mopidy/core/listener.py | 2 +- mopidy/core/playback.py | 2 +- mopidy/core/playlists.py | 2 +- mopidy/core/tracklist.py | 2 +- mopidy/exceptions.py | 2 +- mopidy/ext.py | 2 +- mopidy/http/__init__.py | 2 +- mopidy/http/actor.py | 2 +- mopidy/http/handlers.py | 2 +- mopidy/listener.py | 2 +- mopidy/local/__init__.py | 2 +- mopidy/local/actor.py | 2 +- mopidy/local/commands.py | 2 +- mopidy/local/json.py | 2 +- mopidy/local/library.py | 2 +- mopidy/local/playback.py | 2 +- mopidy/local/playlists.py | 2 +- mopidy/local/search.py | 2 +- mopidy/local/storage.py | 2 +- mopidy/local/translator.py | 2 +- mopidy/mixer.py | 2 +- mopidy/models.py | 2 +- mopidy/mpd/__init__.py | 2 +- mopidy/mpd/actor.py | 2 +- mopidy/mpd/dispatcher.py | 2 +- mopidy/mpd/exceptions.py | 2 +- mopidy/mpd/protocol/__init__.py | 2 +- mopidy/mpd/protocol/audio_output.py | 2 +- mopidy/mpd/protocol/channels.py | 2 +- mopidy/mpd/protocol/command_list.py | 2 +- mopidy/mpd/protocol/connection.py | 2 +- mopidy/mpd/protocol/current_playlist.py | 2 +- mopidy/mpd/protocol/music_db.py | 2 +- mopidy/mpd/protocol/playback.py | 2 +- mopidy/mpd/protocol/reflection.py | 2 +- mopidy/mpd/protocol/status.py | 2 +- mopidy/mpd/protocol/stickers.py | 2 +- mopidy/mpd/protocol/stored_playlists.py | 2 +- mopidy/mpd/session.py | 2 +- mopidy/mpd/tokenize.py | 2 +- mopidy/mpd/translator.py | 2 +- mopidy/softwaremixer/__init__.py | 2 +- mopidy/softwaremixer/mixer.py | 2 +- mopidy/stream/__init__.py | 2 +- mopidy/stream/actor.py | 2 +- mopidy/utils/__init__.py | 2 +- mopidy/utils/deps.py | 2 +- mopidy/utils/encoding.py | 2 +- mopidy/utils/formatting.py | 2 +- mopidy/utils/jsonrpc.py | 2 +- mopidy/utils/log.py | 2 +- mopidy/utils/network.py | 2 +- mopidy/utils/path.py | 2 +- mopidy/utils/process.py | 2 +- mopidy/utils/versioning.py | 2 +- mopidy/zeroconf.py | 2 +- setup.py | 2 +- tests/__init__.py | 2 +- tests/__main__.py | 2 +- tests/audio/test_actor.py | 2 +- tests/audio/test_listener.py | 2 +- tests/audio/test_playlists.py | 2 +- tests/audio/test_scan.py | 2 +- tests/backend/__init__.py | 2 +- tests/backend/test_listener.py | 2 +- tests/config/test_config.py | 2 +- tests/config/test_schemas.py | 2 +- tests/config/test_types.py | 2 +- tests/config/test_validator.py | 2 +- tests/core/__init__.py | 2 +- tests/core/test_actor.py | 2 +- tests/core/test_events.py | 2 +- tests/core/test_history.py | 2 +- tests/core/test_library.py | 2 +- tests/core/test_listener.py | 2 +- tests/core/test_playback.py | 2 +- tests/core/test_playlists.py | 2 +- tests/core/test_tracklist.py | 2 +- tests/http/test_events.py | 2 +- tests/http/test_handlers.py | 2 +- tests/http/test_server.py | 2 +- tests/local/__init__.py | 2 +- tests/local/test_events.py | 2 +- tests/local/test_json.py | 2 +- tests/local/test_library.py | 2 +- tests/local/test_playback.py | 2 +- tests/local/test_playlists.py | 2 +- tests/local/test_tracklist.py | 2 +- tests/local/test_translator.py | 2 +- tests/mpd/__init__.py | 2 +- tests/mpd/protocol/__init__.py | 2 +- tests/mpd/protocol/test_audio_output.py | 2 +- tests/mpd/protocol/test_authentication.py | 2 +- tests/mpd/protocol/test_channels.py | 2 +- tests/mpd/protocol/test_command_list.py | 2 +- tests/mpd/protocol/test_connection.py | 2 +- tests/mpd/protocol/test_current_playlist.py | 2 +- tests/mpd/protocol/test_idle.py | 2 +- tests/mpd/protocol/test_music_db.py | 2 +- tests/mpd/protocol/test_playback.py | 2 +- tests/mpd/protocol/test_reflection.py | 2 +- tests/mpd/protocol/test_regression.py | 2 +- tests/mpd/protocol/test_status.py | 2 +- tests/mpd/protocol/test_stickers.py | 2 +- tests/mpd/protocol/test_stored_playlists.py | 2 +- tests/mpd/test_commands.py | 2 +- tests/mpd/test_dispatcher.py | 2 +- tests/mpd/test_exceptions.py | 2 +- tests/mpd/test_status.py | 2 +- tests/mpd/test_tokenizer.py | 2 +- tests/mpd/test_translator.py | 2 +- tests/test_commands.py | 2 +- tests/test_exceptions.py | 2 +- tests/test_ext.py | 2 +- tests/test_help.py | 2 +- tests/test_mixer.py | 2 +- tests/test_models.py | 2 +- tests/test_version.py | 2 +- tests/utils/__init__.py | 2 +- tests/utils/network/__init__.py | 2 +- tests/utils/network/test_connection.py | 2 +- tests/utils/network/test_lineprotocol.py | 2 +- tests/utils/network/test_server.py | 2 +- tests/utils/network/test_utils.py | 2 +- tests/utils/test_deps.py | 2 +- tests/utils/test_encoding.py | 2 +- tests/utils/test_jsonrpc.py | 2 +- tests/utils/test_path.py | 2 +- 153 files changed, 157 insertions(+), 157 deletions(-) diff --git a/docs/api/http-server.rst b/docs/api/http-server.rst index ee6f55fb..317a77c5 100644 --- a/docs/api/http-server.rst +++ b/docs/api/http-server.rst @@ -43,7 +43,7 @@ available at http://localhost:6680/mywebclient/foo.html. :: - from __future__ import unicode_literals + from __future__ import absolute_import, unicode_literals import os @@ -95,7 +95,7 @@ Mopidy $version``. :: - from __future__ import unicode_literals + from __future__ import absolute_import, unicode_literals import os @@ -149,7 +149,7 @@ http://localhost:6680/mywebclient/. :: - from __future__ import unicode_literals + from __future__ import absolute_import, unicode_literals import os diff --git a/docs/conf.py b/docs/conf.py index 1eb6dd33..0715f326 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,7 +2,7 @@ """Mopidy documentation build configuration file""" -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import sys diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 100e5b85..c6a88619 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -159,7 +159,7 @@ class that will connect the rest of the dots. :: - from __future__ import unicode_literals + from __future__ import absolute_import, unicode_literals import re from setuptools import setup, find_packages @@ -255,7 +255,7 @@ default config in documentation without duplicating it. This is ``mopidy_soundspot/__init__.py``:: - from __future__ import unicode_literals + from __future__ import absolute_import, unicode_literals import logging import os @@ -449,7 +449,7 @@ Python conventions In general, it would be nice if Mopidy extensions followed the same :ref:`codestyle` as Mopidy itself, as they're part of the same ecosystem. Among other things, the code style guide explains why all the above examples start -with ``from __future__ import unicode_literals``. +with ``from __future__ import absolute_import, unicode_literals``. Use of Mopidy APIs diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 8b5b8e18..42da1ccd 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import platform import sys diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 9620b936..1aa85c07 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,4 +1,4 @@ -from __future__ import print_function, unicode_literals +from __future__ import print_function, absolute_import, unicode_literals import logging import os diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index fd6d41c9..1d47e682 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals # flake8: noqa from .actor import Audio diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 95ccb841..190895dc 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import os diff --git a/mopidy/audio/constants.py b/mopidy/audio/constants.py index 08ad9768..718fde1b 100644 --- a/mopidy/audio/constants.py +++ b/mopidy/audio/constants.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals class PlaybackState(object): diff --git a/mopidy/audio/dummy.py b/mopidy/audio/dummy.py index fe749888..f7fa9f0d 100644 --- a/mopidy/audio/dummy.py +++ b/mopidy/audio/dummy.py @@ -4,7 +4,7 @@ This class implements the audio API in the simplest way possible. It is used in tests of the core and backends. """ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import pykka diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index b272d15a..6beb4444 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy import listener diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 37ef2569..e091d8ff 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import ConfigParser as configparser import io diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 53f00ac0..b3fe2c4d 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import datetime import os diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index f9036748..0fcb2978 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import pygst pygst.require('0.10') diff --git a/mopidy/backend/__init__.py b/mopidy/backend/__init__.py index 712bfafe..016f2575 100644 --- a/mopidy/backend/__init__.py +++ b/mopidy/backend/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import copy diff --git a/mopidy/backend/dummy.py b/mopidy/backend/dummy.py index 94b01433..529124c4 100644 --- a/mopidy/backend/dummy.py +++ b/mopidy/backend/dummy.py @@ -4,7 +4,7 @@ This backend implements the backend API in the simplest way possible. It is used in tests of the frontends. """ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import pykka diff --git a/mopidy/commands.py b/mopidy/commands.py index 237ec86b..16c606c8 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -1,4 +1,4 @@ -from __future__ import print_function, unicode_literals +from __future__ import print_function, absolute_import, unicode_literals import argparse import collections diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 7c0898aa..b9a27eb7 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import ConfigParser as configparser import io diff --git a/mopidy/config/keyring.py b/mopidy/config/keyring.py index 4d251f52..cce15bd9 100644 --- a/mopidy/config/keyring.py +++ b/mopidy/config/keyring.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 3d997ffe..56826a53 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import collections diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 4498cb67..ebca1a78 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import re diff --git a/mopidy/config/validators.py b/mopidy/config/validators.py index a0ca25d9..d0549659 100644 --- a/mopidy/config/validators.py +++ b/mopidy/config/validators.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals # TODO: add validate regexp? diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 019034fc..7fa7e299 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals # flake8: noqa from .actor import Core diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index edf13679..75c06f69 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import collections import itertools diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 379e3b34..9d7cf59f 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import copy import logging diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 50d7df19..2ada23d4 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import collections import operator diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index f0bb1ea3..2c027e1b 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy import listener diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 8d5f05da..15a487b2 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import urlparse diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index d5c03bb3..a6ab654f 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import itertools import urlparse diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 4c567086..e3e90de5 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import collections import logging diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 532f6853..85169794 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals class MopidyException(Exception): diff --git a/mopidy/ext.py b/mopidy/ext.py index 3333ec3f..7666bb3a 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import collections import logging diff --git a/mopidy/http/__init__.py b/mopidy/http/__init__.py index 95675386..3fa4bcd6 100644 --- a/mopidy/http/__init__.py +++ b/mopidy/http/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import os diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index 57e2f46a..d37a5672 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import json import logging diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 3bfc1eff..721e419c 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import os diff --git a/mopidy/listener.py b/mopidy/listener.py index c8ecfa53..41f8e8e0 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index a0bb7bc4..73d07f75 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import os diff --git a/mopidy/local/actor.py b/mopidy/local/actor.py index 590d7867..f315607a 100644 --- a/mopidy/local/actor.py +++ b/mopidy/local/actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 1b8981df..cfea7ef9 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -1,4 +1,4 @@ -from __future__ import print_function, unicode_literals +from __future__ import absolute_import, print_function, unicode_literals import logging import os diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 30fbb562..6ebe36b7 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import absolute_import, absolute_import, unicode_literals import collections import gzip diff --git a/mopidy/local/library.py b/mopidy/local/library.py index ec5f4869..f3828f1b 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging diff --git a/mopidy/local/playback.py b/mopidy/local/playback.py index aa0e5b3a..92dc6e15 100644 --- a/mopidy/local/playback.py +++ b/mopidy/local/playback.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py index f22c6fde..1496867c 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/local/playlists.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import glob import logging diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 68d0a1f5..375c0daa 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.models import Album, SearchResult diff --git a/mopidy/local/storage.py b/mopidy/local/storage.py index d83bdf77..9cdcd12e 100644 --- a/mopidy/local/storage.py +++ b/mopidy/local/storage.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import os diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 33b67775..2bfe8ead 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import os diff --git a/mopidy/mixer.py b/mopidy/mixer.py index be793a7c..e277fe55 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging diff --git a/mopidy/models.py b/mopidy/models.py index bedf8ca5..5508d4de 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import json diff --git a/mopidy/mpd/__init__.py b/mopidy/mpd/__init__.py index 77aaf83f..05c83baa 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 49d9556e..c8123c32 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index c947c13d..52b57258 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import re diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index 6738b4c9..e7ab0068 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.exceptions import MopidyException diff --git a/mopidy/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py index f0ae814b..38fcb33a 100644 --- a/mopidy/mpd/protocol/__init__.py +++ b/mopidy/mpd/protocol/__init__.py @@ -10,7 +10,7 @@ implement our own MPD server which is compatible with the numerous existing `MPD clients `_. """ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import inspect diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 2c7aea16..4a5310f5 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol diff --git a/mopidy/mpd/protocol/channels.py b/mopidy/mpd/protocol/channels.py index 4ae00622..7699abe3 100644 --- a/mopidy/mpd/protocol/channels.py +++ b/mopidy/mpd/protocol/channels.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol diff --git a/mopidy/mpd/protocol/command_list.py b/mopidy/mpd/protocol/command_list.py index d8551105..028134a9 100644 --- a/mopidy/mpd/protocol/command_list.py +++ b/mopidy/mpd/protocol/command_list.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol diff --git a/mopidy/mpd/protocol/connection.py b/mopidy/mpd/protocol/connection.py index 41896acf..f087847a 100644 --- a/mopidy/mpd/protocol/connection.py +++ b/mopidy/mpd/protocol/connection.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index a2d60e96..33c090e3 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import warnings diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index a5757915..8dfc1d2c 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import functools import itertools diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 5b63c561..07102492 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import warnings diff --git a/mopidy/mpd/protocol/reflection.py b/mopidy/mpd/protocol/reflection.py index 4308c560..7feccca1 100644 --- a/mopidy/mpd/protocol/reflection.py +++ b/mopidy/mpd/protocol/reflection.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index 8f97c2e4..9dae635e 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import pykka diff --git a/mopidy/mpd/protocol/stickers.py b/mopidy/mpd/protocol/stickers.py index 4d535423..30b917c6 100644 --- a/mopidy/mpd/protocol/stickers.py +++ b/mopidy/mpd/protocol/stickers.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index f4d48ff0..fcfc69ce 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -1,4 +1,4 @@ -from __future__ import division, unicode_literals +from __future__ import division, absolute_import, unicode_literals import datetime diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index f0317ede..0e606c8f 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging diff --git a/mopidy/mpd/tokenize.py b/mopidy/mpd/tokenize.py index bc0d6b3f..70208ae9 100644 --- a/mopidy/mpd/tokenize.py +++ b/mopidy/mpd/tokenize.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import re diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index f3264a46..5c37977c 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import re diff --git a/mopidy/softwaremixer/__init__.py b/mopidy/softwaremixer/__init__.py index 242069eb..9e08a719 100644 --- a/mopidy/softwaremixer/__init__.py +++ b/mopidy/softwaremixer/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os diff --git a/mopidy/softwaremixer/mixer.py b/mopidy/softwaremixer/mixer.py index 71d178f5..dadbbec8 100644 --- a/mopidy/softwaremixer/mixer.py +++ b/mopidy/softwaremixer/mixer.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging diff --git a/mopidy/stream/__init__.py b/mopidy/stream/__init__.py index 2cb77365..de01cb84 100644 --- a/mopidy/stream/__init__.py +++ b/mopidy/stream/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index b17dfcea..b6336fbe 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import fnmatch import logging diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index baffc488..01e6d4f4 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index ea64a0a0..886b8818 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import functools import os diff --git a/mopidy/utils/encoding.py b/mopidy/utils/encoding.py index a21b3384..af781838 100644 --- a/mopidy/utils/encoding.py +++ b/mopidy/utils/encoding.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import locale diff --git a/mopidy/utils/formatting.py b/mopidy/utils/formatting.py index 3c313eae..9cef7afe 100644 --- a/mopidy/utils/formatting.py +++ b/mopidy/utils/formatting.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import re import unicodedata diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 85565262..4eb85e9b 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import inspect import json diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index c461b434..396c05b9 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import logging.config diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 4ea25026..ce02ef0e 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import errno import logging diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index be735ae5..4efcfa20 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import Queue as queue import logging diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 2887e705..cf9cbd0a 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import signal diff --git a/mopidy/utils/versioning.py b/mopidy/utils/versioning.py index 94578121..db1aa949 100644 --- a/mopidy/utils/versioning.py +++ b/mopidy/utils/versioning.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import subprocess diff --git a/mopidy/zeroconf.py b/mopidy/zeroconf.py index 9f726957..0c42dd74 100644 --- a/mopidy/zeroconf.py +++ b/mopidy/zeroconf.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import socket diff --git a/setup.py b/setup.py index 900fcf38..384aaec5 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import re diff --git a/tests/__init__.py b/tests/__init__.py index a384669e..6ae07bcc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os diff --git a/tests/__main__.py b/tests/__main__.py index 164f1e66..ae7a18e6 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import nose diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 8db7f61f..ab897595 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import threading import unittest diff --git a/tests/audio/test_listener.py b/tests/audio/test_listener.py index 56574411..08b03e6c 100644 --- a/tests/audio/test_listener.py +++ b/tests/audio/test_listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/audio/test_playlists.py b/tests/audio/test_playlists.py index 51c36eac..eb79cfeb 100644 --- a/tests/audio/test_playlists.py +++ b/tests/audio/test_playlists.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import io import unittest diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 2e91ce32..45a4aa6a 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import unittest diff --git a/tests/backend/__init__.py b/tests/backend/__init__.py index baffc488..01e6d4f4 100644 --- a/tests/backend/__init__.py +++ b/tests/backend/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/tests/backend/test_listener.py b/tests/backend/test_listener.py index 9e080d31..6ec39308 100644 --- a/tests/backend/test_listener.py +++ b/tests/backend/test_listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/config/test_config.py b/tests/config/test_config.py index da0e5192..cd97d9a8 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/config/test_schemas.py b/tests/config/test_schemas.py index 86cd69f1..910b5004 100644 --- a/tests/config/test_schemas.py +++ b/tests/config/test_schemas.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import unittest diff --git a/tests/config/test_types.py b/tests/config/test_types.py index dfb439be..713eb4c0 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging import socket diff --git a/tests/config/test_validator.py b/tests/config/test_validator.py index ce773340..8172df0c 100644 --- a/tests/config/test_validator.py +++ b/tests/config/test_validator.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/core/__init__.py b/tests/core/__init__.py index baffc488..01e6d4f4 100644 --- a/tests/core/__init__.py +++ b/tests/core/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index 79d778af..a3cb93da 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/core/test_events.py b/tests/core/test_events.py index ab7906a8..ebe099f3 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 75b4dc76..eb1404b5 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 9eac3ebd..cbbea2e3 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index c0075450..22bb9146 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index ce6c8571..18b73b17 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 49f617b5..20577763 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index b681e097..38885912 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/http/test_events.py b/tests/http/test_events.py index d03778a6..43d9db58 100644 --- a/tests/http/test_events.py +++ b/tests/http/test_events.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import json import unittest diff --git a/tests/http/test_handlers.py b/tests/http/test_handlers.py index 28e53855..5c958d9a 100644 --- a/tests/http/test_handlers.py +++ b/tests/http/test_handlers.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os diff --git a/tests/http/test_server.py b/tests/http/test_server.py index b3cfa92c..3c7d7c88 100644 --- a/tests/http/test_server.py +++ b/tests/http/test_server.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os diff --git a/tests/local/__init__.py b/tests/local/__init__.py index f408139f..b1520768 100644 --- a/tests/local/__init__.py +++ b/tests/local/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals def generate_song(i): diff --git a/tests/local/test_events.py b/tests/local/test_events.py index f6ae5360..7a85731e 100644 --- a/tests/local/test_events.py +++ b/tests/local/test_events.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/local/test_json.py b/tests/local/test_json.py index 54afefe7..2da13632 100644 --- a/tests/local/test_json.py +++ b/tests/local/test_json.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/local/test_library.py b/tests/local/test_library.py index c1f2bcbd..3be41333 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import shutil diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index ba051fa0..67e49178 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import time import unittest diff --git a/tests/local/test_playlists.py b/tests/local/test_playlists.py index f054ffc9..4210f248 100644 --- a/tests/local/test_playlists.py +++ b/tests/local/test_playlists.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import shutil diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index 9c1d09d7..69dd6400 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import random import unittest diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py index b7ffd5cf..a473a0ff 100644 --- a/tests/local/test_translator.py +++ b/tests/local/test_translator.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import tempfile diff --git a/tests/mpd/__init__.py b/tests/mpd/__init__.py index baffc488..01e6d4f4 100644 --- a/tests/mpd/__init__.py +++ b/tests/mpd/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index f4776f4f..813d91fe 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index 643682ef..4815c2db 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol diff --git a/tests/mpd/protocol/test_authentication.py b/tests/mpd/protocol/test_authentication.py index 4937c04f..6785ff98 100644 --- a/tests/mpd/protocol/test_authentication.py +++ b/tests/mpd/protocol/test_authentication.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol diff --git a/tests/mpd/protocol/test_channels.py b/tests/mpd/protocol/test_channels.py index be3b96a8..1c04974e 100644 --- a/tests/mpd/protocol/test_channels.py +++ b/tests/mpd/protocol/test_channels.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol diff --git a/tests/mpd/protocol/test_command_list.py b/tests/mpd/protocol/test_command_list.py index 9d66bd5d..330af176 100644 --- a/tests/mpd/protocol/test_command_list.py +++ b/tests/mpd/protocol/test_command_list.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol diff --git a/tests/mpd/protocol/test_connection.py b/tests/mpd/protocol/test_connection.py index 34cce6a0..2a21a1c3 100644 --- a/tests/mpd/protocol/test_connection.py +++ b/tests/mpd/protocol/test_connection.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mock import patch diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index e9898dd9..6501e5c7 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.models import Ref, Track diff --git a/tests/mpd/protocol/test_idle.py b/tests/mpd/protocol/test_idle.py index cc937119..4c987647 100644 --- a/tests/mpd/protocol/test_idle.py +++ b/tests/mpd/protocol/test_idle.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mock import patch diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index ff39c081..3d8eefbf 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 67b4e787..d13cf65f 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/mpd/protocol/test_reflection.py b/tests/mpd/protocol/test_reflection.py index 160c9876..e721d799 100644 --- a/tests/mpd/protocol/test_reflection.py +++ b/tests/mpd/protocol/test_reflection.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index 3389573f..b0e9d450 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import random diff --git a/tests/mpd/protocol/test_status.py b/tests/mpd/protocol/test_status.py index 7d30ea89..87e63a1a 100644 --- a/tests/mpd/protocol/test_status.py +++ b/tests/mpd/protocol/test_status.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.models import Track diff --git a/tests/mpd/protocol/test_stickers.py b/tests/mpd/protocol/test_stickers.py index c3ce264a..9eae1ac6 100644 --- a/tests/mpd/protocol/test_stickers.py +++ b/tests/mpd/protocol/test_stickers.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from tests.mpd import protocol diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index 4dc7dbbb..bc66387a 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from mopidy.models import Playlist, Track diff --git a/tests/mpd/test_commands.py b/tests/mpd/test_commands.py index 2b4205fe..4699dfe0 100644 --- a/tests/mpd/test_commands.py +++ b/tests/mpd/test_commands.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index cee4531a..24d03bf1 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index 7f50c41b..a54f9e20 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 6a455136..57b2d4d4 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/mpd/test_tokenizer.py b/tests/mpd/test_tokenizer.py index 01ecd17d..b4a1df09 100644 --- a/tests/mpd/test_tokenizer.py +++ b/tests/mpd/test_tokenizer.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 2bd6cff6..b38a93e8 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import datetime import unittest diff --git a/tests/test_commands.py b/tests/test_commands.py index 570647c2..58f681be 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import argparse import unittest diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 19127aaa..fc19f60a 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/test_ext.py b/tests/test_ext.py index 428f3712..0e850e60 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/test_help.py b/tests/test_help.py index 6499cac1..d8058cb7 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import subprocess diff --git a/tests/test_mixer.py b/tests/test_mixer.py index 53c10292..d0f1b0f2 100644 --- a/tests/test_mixer.py +++ b/tests/test_mixer.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/test_models.py b/tests/test_models.py index 7838a6ba..ed1586da 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import json import unittest diff --git a/tests/test_version.py b/tests/test_version.py index 96063a1b..32b40fca 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest from distutils.version import StrictVersion as SV diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index baffc488..01e6d4f4 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/tests/utils/network/__init__.py b/tests/utils/network/__init__.py index baffc488..01e6d4f4 100644 --- a/tests/utils/network/__init__.py +++ b/tests/utils/network/__init__.py @@ -1 +1 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals diff --git a/tests/utils/network/test_connection.py b/tests/utils/network/test_connection.py index c3200689..031ea385 100644 --- a/tests/utils/network/test_connection.py +++ b/tests/utils/network/test_connection.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import errno import logging diff --git a/tests/utils/network/test_lineprotocol.py b/tests/utils/network/test_lineprotocol.py index 52d6901c..9fb703ca 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/utils/network/test_lineprotocol.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import re import unittest diff --git a/tests/utils/network/test_server.py b/tests/utils/network/test_server.py index eebc9ea2..f5f61101 100644 --- a/tests/utils/network/test_server.py +++ b/tests/utils/network/test_server.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import errno import socket diff --git a/tests/utils/network/test_utils.py b/tests/utils/network/test_utils.py index d0886cfc..d5f558b4 100644 --- a/tests/utils/network/test_utils.py +++ b/tests/utils/network/test_utils.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import socket import unittest diff --git a/tests/utils/test_deps.py b/tests/utils/test_deps.py index 103f478c..3144fe30 100644 --- a/tests/utils/test_deps.py +++ b/tests/utils/test_deps.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import platform import unittest diff --git a/tests/utils/test_encoding.py b/tests/utils/test_encoding.py index 912f38c0..68634855 100644 --- a/tests/utils/test_encoding.py +++ b/tests/utils/test_encoding.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import unittest diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index 8f97f37e..bf7da541 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import json import unittest diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 8dafd951..4a31739c 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -1,6 +1,6 @@ # encoding: utf-8 -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import os import shutil From 98ca748996fe462cedf284ad91a74bdd30eb81f3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 19:36:23 +0100 Subject: [PATCH 152/495] py3: Use print function instead of print statement --- mopidy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 42da1ccd..18ecb22d 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import absolute_import, print_function, unicode_literals import platform import sys From b9a5192d5fab2d08bd0ab54ea9e81e6f48728ccd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 20:08:01 +0100 Subject: [PATCH 153/495] py3: Add Python 2/3 compat module Keep all the hacks in a single place. This looks like all we need, so no need to depend on six. --- mopidy/utils/compat.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 mopidy/utils/compat.py diff --git a/mopidy/utils/compat.py b/mopidy/utils/compat.py new file mode 100644 index 00000000..d0e4aeb2 --- /dev/null +++ b/mopidy/utils/compat.py @@ -0,0 +1,24 @@ +import sys + +PY2 = sys.version_info[0] == 2 +PY3 = sys.version_info[0] == 3 + +if PY2: + import ConfigParser as configparser # noqa + import Queue as queue # noqa + import thread # noqa + + string_types = basestring + text_type = unicode + + input = raw_input + +else: + import configparser # noqa + import queue # noqa + import _thread as thread # noqa + + string_types = (str,) + text_type = str + + input = input From 58527406c19974833796fb9af3299ee0d2c2c9b8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 20:12:24 +0100 Subject: [PATCH 154/495] py3: Use compat.text_type instead of unicode --- mopidy/audio/utils.py | 4 +++- mopidy/config/__init__.py | 4 ++-- mopidy/config/keyring.py | 4 +++- mopidy/config/types.py | 8 ++++---- mopidy/core/tracklist.py | 3 ++- mopidy/local/translator.py | 5 +++-- mopidy/utils/encoding.py | 6 ++++-- mopidy/utils/jsonrpc.py | 8 +++++--- mopidy/utils/path.py | 6 ++++-- tests/__init__.py | 4 +++- tests/config/test_types.py | 5 +++-- tests/utils/network/test_lineprotocol.py | 6 +++--- tests/utils/test_path.py | 6 +++--- 13 files changed, 42 insertions(+), 27 deletions(-) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 0fcb2978..cb60af89 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -4,6 +4,8 @@ import pygst pygst.require('0.10') import gst # noqa +from mopidy.utils import compat + def calculate_duration(num_samples, sample_rate): """Determine duration of samples using GStreamer helper for precise @@ -18,7 +20,7 @@ def create_buffer(data, capabilites=None, timestamp=None, duration=None): """ buffer_ = gst.Buffer(data) if capabilites: - if isinstance(capabilites, basestring): + if isinstance(capabilites, compat.string_types): capabilites = gst.caps_from_string(capabilites) buffer_.set_caps(capabilites) if timestamp: diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index b9a27eb7..82c30b96 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -10,7 +10,7 @@ import re from mopidy.config import keyring from mopidy.config.schemas import * # noqa from mopidy.config.types import * # noqa -from mopidy.utils import path, versioning +from mopidy.utils import compat, path, versioning logger = logging.getLogger(__name__) @@ -108,7 +108,7 @@ def _load(files, defaults, overrides): # all in the same way? logger.info('Loading config from builtin defaults') for default in defaults: - if isinstance(default, unicode): + if isinstance(default, compat.text_type): default = default.encode('utf-8') parser.readfp(io.BytesIO(default)) diff --git a/mopidy/config/keyring.py b/mopidy/config/keyring.py index cce15bd9..d0c8d9ed 100644 --- a/mopidy/config/keyring.py +++ b/mopidy/config/keyring.py @@ -9,6 +9,8 @@ try: except ImportError: dbus = None +from mopidy.utils import compat + # XXX: Hack to workaround introspection bug caused by gnome-keyring, should be # fixed by version 3.5 per: @@ -92,7 +94,7 @@ def set(section, key, value): if not collection: return False - if isinstance(value, unicode): + if isinstance(value, compat.text_type): value = value.encode('utf-8') session = service.OpenSession('plain', EMPTY_STRING)[1] diff --git a/mopidy/config/types.py b/mopidy/config/types.py index ebca1a78..1dbbe39f 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -5,18 +5,18 @@ import re import socket from mopidy.config import validators -from mopidy.utils import path +from mopidy.utils import compat, path def decode(value): - if isinstance(value, unicode): + if isinstance(value, compat.text_type): return value # TODO: only unescape \n \t and \\? return value.decode('string-escape').decode('utf-8') def encode(value): - if not isinstance(value, unicode): + if not isinstance(value, compat.text_type): return value for char in ('\\', '\n', '\t'): # TODO: more escapes? value = value.replace(char, char.encode('unicode-escape')) @@ -278,7 +278,7 @@ class Path(ConfigValue): return ExpandedPath(value, expanded) def serialize(self, value, display=False): - if isinstance(value, unicode): + if isinstance(value, compat.text_type): raise ValueError('paths should always be bytes') if isinstance(value, ExpandedPath): return value.original diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index e3e90de5..d9c08c3d 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -6,6 +6,7 @@ import random from mopidy.core import listener from mopidy.models import TlTrack +from mopidy.utils import compat logger = logging.getLogger(__name__) @@ -329,7 +330,7 @@ class TracklistController(object): matches = self._tl_tracks for (key, values) in criteria.iteritems(): if (not isinstance(values, collections.Iterable) - or isinstance(values, basestring)): + or isinstance(values, compat.string_types)): # Fail hard if anyone is using the <0.17 calling style raise ValueError('Filter values must be iterable: %r' % values) if key == 'tlid': diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 2bfe8ead..ca8b5e94 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -7,6 +7,7 @@ import urllib import urlparse from mopidy.models import Track +from mopidy.utils import compat from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri, uri_to_path @@ -29,14 +30,14 @@ def local_track_uri_to_path(uri, media_dir): def path_to_local_track_uri(relpath): """Convert path releative to media_dir to local track URI.""" - if isinstance(relpath, unicode): + if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') return b'local:track:%s' % urllib.quote(relpath) def path_to_local_directory_uri(relpath): """Convert path relative to :confval:`local/media_dir` directory URI.""" - if isinstance(relpath, unicode): + if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') return b'local:directory:%s' % urllib.quote(relpath) diff --git a/mopidy/utils/encoding.py b/mopidy/utils/encoding.py index af781838..be8d5a7f 100644 --- a/mopidy/utils/encoding.py +++ b/mopidy/utils/encoding.py @@ -2,9 +2,11 @@ from __future__ import absolute_import, unicode_literals import locale +from mopidy.utils import compat + def locale_decode(bytestr): try: - return unicode(bytestr) + return compat.text_type(bytestr) except UnicodeError: - return str(bytestr).decode(locale.getpreferredencoding()) + return bytes(bytestr).decode(locale.getpreferredencoding()) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 4eb85e9b..113edcab 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -6,6 +6,8 @@ import traceback import pykka +from mopidy.utils import compat + class JsonRpcWrapper(object): """ @@ -137,13 +139,13 @@ class JsonRpcWrapper(object): except TypeError as error: raise JsonRpcInvalidParamsError(data={ 'type': error.__class__.__name__, - 'message': unicode(error), + 'message': compat.text_type(error), 'traceback': traceback.format_exc(), }) except Exception as error: raise JsonRpcApplicationError(data={ 'type': error.__class__.__name__, - 'message': unicode(error), + 'message': compat.text_type(error), 'traceback': traceback.format_exc(), }) except JsonRpcError as error: @@ -164,7 +166,7 @@ class JsonRpcWrapper(object): if 'method' not in request: raise JsonRpcInvalidRequestError( data='"method" member must be included') - if not isinstance(request['method'], unicode): + if not isinstance(request['method'], compat.text_type): raise JsonRpcInvalidRequestError( data='"method" must be a string') diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 4efcfa20..48e47735 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -11,6 +11,8 @@ import urlparse import glib +from mopidy.utils import compat + logger = logging.getLogger(__name__) @@ -64,7 +66,7 @@ def path_to_uri(path): Returns a file:// URI as an unicode string. """ - if isinstance(path, unicode): + if isinstance(path, compat.text_type): path = path.encode('utf-8') path = urllib.quote(path) return urlparse.urlunsplit((b'file', b'', path, b'', b'')) @@ -81,7 +83,7 @@ def uri_to_path(uri): look up the matching dir or file on your file system because the exact path would be lost by ignoring its encoding. """ - if isinstance(uri, unicode): + if isinstance(uri, compat.text_type): uri = uri.encode('utf-8') return urllib.unquote(urlparse.urlsplit(uri).path) diff --git a/tests/__init__.py b/tests/__init__.py index 6ae07bcc..c664a5fa 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, unicode_literals import os +from mopidy.utils import compat + def path_to_data_dir(name): if not isinstance(name, bytes): @@ -31,4 +33,4 @@ class IsA(object): any_int = IsA(int) any_str = IsA(str) -any_unicode = IsA(unicode) +any_unicode = IsA(compat.text_type) diff --git a/tests/config/test_types.py b/tests/config/test_types.py index 713eb4c0..f113fb28 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -9,6 +9,7 @@ import unittest import mock from mopidy.config import types +from mopidy.utils import compat # TODO: DecodeTest and EncodeTest @@ -48,7 +49,7 @@ class StringTest(unittest.TestCase): def test_deserialize_conversion_success(self): value = types.String() self.assertEqual('foo', value.deserialize(b' foo ')) - self.assertIsInstance(value.deserialize(b'foo'), unicode) + self.assertIsInstance(value.deserialize(b'foo'), compat.text_type) def test_deserialize_decodes_utf8(self): value = types.String() @@ -119,7 +120,7 @@ class SecretTest(unittest.TestCase): def test_deserialize_decodes_utf8(self): value = types.Secret() result = value.deserialize('æøå'.encode('utf-8')) - self.assertIsInstance(result, unicode) + self.assertIsInstance(result, compat.text_type) self.assertEqual('æøå', result) def test_deserialize_enforces_required(self): diff --git a/tests/utils/network/test_lineprotocol.py b/tests/utils/network/test_lineprotocol.py index 9fb703ca..5c6a5ad4 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/utils/network/test_lineprotocol.py @@ -7,7 +7,7 @@ import unittest from mock import Mock, sentinel -from mopidy.utils import network +from mopidy.utils import compat, network from tests import any_unicode @@ -259,13 +259,13 @@ class LineProtocolTest(unittest.TestCase): def test_decode_plain_ascii(self): result = network.LineProtocol.decode(self.mock, 'abc') self.assertEqual('abc', result) - self.assertEqual(unicode, type(result)) + self.assertEqual(compat.text_type, type(result)) def test_decode_utf8(self): result = network.LineProtocol.decode( self.mock, 'æøå'.encode('utf-8')) self.assertEqual('æøå', result) - self.assertEqual(unicode, type(result)) + self.assertEqual(compat.text_type, type(result)) def test_decode_invalid_data(self): string = Mock() diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 4a31739c..b15fd1b5 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -9,7 +9,7 @@ import unittest import glib -from mopidy.utils import path +from mopidy.utils import compat, path import tests @@ -57,7 +57,7 @@ class GetOrCreateDirTest(unittest.TestCase): def test_create_dir_with_unicode(self): with self.assertRaises(ValueError): - dir_path = unicode(os.path.join(self.parent, b'test')) + dir_path = compat.text_type(os.path.join(self.parent, b'test')) path.get_or_create_dir(dir_path) def test_create_dir_with_none(self): @@ -108,7 +108,7 @@ class GetOrCreateFileTest(unittest.TestCase): def test_create_dir_with_unicode(self): with self.assertRaises(ValueError): - file_path = unicode(os.path.join(self.parent, b'test')) + file_path = compat.text_type(os.path.join(self.parent, b'test')) path.get_or_create_file(file_path) def test_create_file_with_none(self): From 1d26c2d63c69be95cdb7fe0d420025015dc06321 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 20:13:30 +0100 Subject: [PATCH 155/495] py3: Use .item() and .values() As .iteritem() and .itervalues() doesn't exist in Python 3. --- mopidy/config/keyring.py | 4 ++-- mopidy/core/playlists.py | 2 +- mopidy/core/tracklist.py | 2 +- mopidy/local/json.py | 2 +- mopidy/local/search.py | 6 +++--- mopidy/utils/jsonrpc.py | 4 ++-- mopidy/utils/path.py | 2 +- mopidy/utils/process.py | 3 ++- 8 files changed, 13 insertions(+), 12 deletions(-) diff --git a/mopidy/config/keyring.py b/mopidy/config/keyring.py index d0c8d9ed..75fa8dcf 100644 --- a/mopidy/config/keyring.py +++ b/mopidy/config/keyring.py @@ -59,7 +59,7 @@ def fetch(): result = [] secrets = service.GetSecrets(items, session, byte_arrays=True) - for item_path, values in secrets.iteritems(): + for item_path, values in secrets.items(): session_path, parameters, value, content_type = values attrs = _item_attributes(bus, item_path) result.append((attrs['section'], attrs['key'], bytes(value))) @@ -163,7 +163,7 @@ def _prompt(bus, path): def _item_attributes(bus, path): item = _interface(bus, path, 'org.freedesktop.DBus.Properties') result = item.Get('org.freedesktop.Secret.Item', 'Attributes') - return dict((bytes(k), bytes(v)) for k, v in result.iteritems()) + return dict((bytes(k), bytes(v)) for k, v in result.items()) def _interface(bus, path, interface): diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index a6ab654f..c896bfa7 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -53,7 +53,7 @@ class PlaylistsController(object): backend = self.backends.with_playlists[uri_scheme] else: # TODO: this fallback looks suspicious - backend = self.backends.with_playlists.values()[0] + backend = list(self.backends.with_playlists.values())[0] playlist = backend.playlists.create(name).get() listener.CoreListener.send('playlist_changed', playlist=playlist) return playlist diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index d9c08c3d..816e570a 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -328,7 +328,7 @@ class TracklistController(object): """ criteria = criteria or kwargs matches = self._tl_tracks - for (key, values) in criteria.iteritems(): + for (key, values) in criteria.items(): if (not isinstance(values, collections.Iterable) or isinstance(values, compat.string_types)): # Fail hard if anyone is using the <0.17 calling style diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 6ebe36b7..48bd373a 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -164,7 +164,7 @@ class JsonLibrary(local.Library): return search.search(tracks, query=query, uris=uris) def begin(self): - return self._tracks.itervalues() + return self._tracks.values() def add(self, track): self._tracks[track.uri] = track diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 375c0daa..947902ed 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -11,7 +11,7 @@ def find_exact(tracks, query=None, uris=None): _validate_query(query) - for (field, values) in query.iteritems(): + for (field, values) in query.items(): if not hasattr(values, '__iter__'): values = [values] # FIXME this is bound to be slow for large libraries @@ -91,7 +91,7 @@ def search(tracks, query=None, uris=None): _validate_query(query) - for (field, values) in query.iteritems(): + for (field, values) in query.items(): if not hasattr(values, '__iter__'): values = [values] # FIXME this is bound to be slow for large libraries @@ -165,7 +165,7 @@ def search(tracks, query=None, uris=None): def _validate_query(query): - for (_, values) in query.iteritems(): + for (_, values) in query.items(): if not values: raise LookupError('Missing query') for value in values: diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 113edcab..7990586b 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -322,12 +322,12 @@ class JsonRpcInspector(object): available properties and methods. """ methods = {} - for mount, obj in self.objects.iteritems(): + for mount, obj in self.objects.items(): if inspect.isroutine(obj): methods[mount] = self._describe_method(obj) else: obj_methods = self._get_methods(obj) - for name, description in obj_methods.iteritems(): + for name, description in obj_methods.items(): if mount: name = '%s.%s' % (mount, name) methods[name] = description diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 48e47735..de03247c 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -198,7 +198,7 @@ def _find(root, thread_count=10, relative=False, follow=False): def find_mtimes(root, follow=False): results, errors = _find(root, relative=False, follow=follow) - mtimes = dict((f, int(st.st_mtime)) for f, st in results.iteritems()) + mtimes = dict((f, int(st.st_mtime)) for f, st in results.items()) return mtimes, errors diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index cf9cbd0a..c86deb12 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -13,8 +13,9 @@ from pykka.registry import ActorRegistry logger = logging.getLogger(__name__) + SIGNALS = dict( - (k, v) for v, k in signal.__dict__.iteritems() + (k, v) for v, k in signal.__dict__.items() if v.startswith('SIG') and not v.startswith('SIG_')) From 95df66865e9e63e1cfe6abfebecad4a01925f4a5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 20:14:53 +0100 Subject: [PATCH 156/495] py3: Use list comprehensions instead of filter() This is just a stylistic change, and is not strictly required for Python 3 compat. --- mopidy/backend/dummy.py | 2 +- mopidy/core/tracklist.py | 6 +++--- mopidy/mpd/translator.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/backend/dummy.py b/mopidy/backend/dummy.py index 529124c4..dfddf5ae 100644 --- a/mopidy/backend/dummy.py +++ b/mopidy/backend/dummy.py @@ -44,7 +44,7 @@ class DummyLibraryProvider(backend.LibraryProvider): return self.dummy_find_exact_result def lookup(self, uri): - return filter(lambda t: uri == t.uri, self.dummy_library) + return [t for t in self.dummy_library if uri == t.uri] def refresh(self, uri=None): pass diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 816e570a..65c04e05 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -334,10 +334,10 @@ class TracklistController(object): # Fail hard if anyone is using the <0.17 calling style raise ValueError('Filter values must be iterable: %r' % values) if key == 'tlid': - matches = filter(lambda ct: ct.tlid in values, matches) + matches = [ct for ct in matches if ct.tlid in values] else: - matches = filter( - lambda ct: getattr(ct.track, key) in values, matches) + matches = [ + ct for ct in matches if getattr(ct.track, key) in values] return matches def move(self, start, end, to_position): diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 5c37977c..ec3a270b 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -59,13 +59,13 @@ def track_to_mpd_format(track, position=None): if track.album is not None and track.album.artists: artists = artists_to_mpd_format(track.album.artists) result.append(('AlbumArtist', artists)) - artists = filter( - lambda a: a.musicbrainz_id is not None, track.album.artists) + artists = [ + a for a in track.album.artists if a.musicbrainz_id is not None] if artists: result.append( ('MUSICBRAINZ_ALBUMARTISTID', artists[0].musicbrainz_id)) if track.artists: - artists = filter(lambda a: a.musicbrainz_id is not None, track.artists) + artists = [a for a in track.artists if a.musicbrainz_id is not None] if artists: result.append(('MUSICBRAINZ_ARTISTID', artists[0].musicbrainz_id)) From 01c7f12976c3bc2215e88d9e1efa1a30f2bdf3a6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 20:15:48 +0100 Subject: [PATCH 157/495] py3: Import moved modules via compat --- mopidy/audio/playlists.py | 3 ++- mopidy/config/__init__.py | 2 +- mopidy/utils/path.py | 2 +- mopidy/utils/process.py | 7 +++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index e091d8ff..a7ec7791 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, unicode_literals -import ConfigParser as configparser import io import gobject @@ -9,6 +8,8 @@ import pygst pygst.require('0.10') import gst # noqa +from mopidy.utils.compat import configparser + try: import xml.etree.cElementTree as elementtree except ImportError: diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 82c30b96..e41269c9 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, unicode_literals -import ConfigParser as configparser import io import itertools import logging @@ -11,6 +10,7 @@ from mopidy.config import keyring from mopidy.config.schemas import * # noqa from mopidy.config.types import * # noqa from mopidy.utils import compat, path, versioning +from mopidy.utils.compat import configparser logger = logging.getLogger(__name__) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index de03247c..9e5df36a 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, unicode_literals -import Queue as queue import logging import os import stat @@ -12,6 +11,7 @@ import urlparse import glib from mopidy.utils import compat +from mopidy.utils.compat import queue logger = logging.getLogger(__name__) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index c86deb12..02c60ef8 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -2,15 +2,14 @@ from __future__ import absolute_import, unicode_literals import logging import signal -try: - import _thread as thread # Python 3 -except ImportError: - import thread # Python 2 import threading from pykka import ActorDeadError from pykka.registry import ActorRegistry +from mopidy.utils.compat import thread + + logger = logging.getLogger(__name__) From 7124226fc7d67ea12bde626a433c60d273e192c6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 20:16:12 +0100 Subject: [PATCH 158/495] py3: Use renamed function via compat --- mopidy/local/commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index cfea7ef9..702626f2 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -7,7 +7,7 @@ import time from mopidy import commands, exceptions from mopidy.audio import scan from mopidy.local import translator -from mopidy.utils import path +from mopidy.utils import compat, path logger = logging.getLogger(__name__) @@ -39,7 +39,7 @@ class ClearCommand(commands.Command): library = _get_library(args, config) prompt = '\nAre you sure you want to clear the library? [y/N] ' - if raw_input(prompt).lower() != 'y': + if compat.input(prompt).lower() != 'y': print('Clearing library aborted.') return 0 From e35a066d5e9380acc8dea71efcaedd80acd19c98 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 20:25:51 +0100 Subject: [PATCH 159/495] py3: Use explicit float or integer division --- mopidy/audio/scan.py | 4 ++-- mopidy/local/commands.py | 5 +++-- mopidy/local/playlists.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index b3fe2c4d..55ad6426 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import absolute_import, division, unicode_literals import datetime import os @@ -81,7 +81,7 @@ class Scanner(object): def _collect(self): """Polls for messages to collect data.""" start = time.time() - timeout_s = self._timeout_ms / float(1000) + timeout_s = self._timeout_ms / 1000. tags = {} while time.time() - start < timeout_s: diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 702626f2..5dacca32 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -1,4 +1,5 @@ -from __future__ import absolute_import, print_function, unicode_literals +from __future__ import ( + absolute_import, division, print_function, unicode_literals) import logging import os @@ -163,6 +164,6 @@ class _Progress(object): logger.info('Scanned %d of %d files in %ds.', self.count, self.total, duration) else: - remainder = duration / self.count * (self.total - self.count) + remainder = duration // self.count * (self.total - self.count) logger.info('Scanned %d of %d files in %ds, ~%ds left.', self.count, self.total, duration, remainder) diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py index 1496867c..deeae2b5 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/local/playlists.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import absolute_import, division, unicode_literals import glob import logging @@ -92,7 +92,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): def _write_m3u_extinf(self, file_handle, track): title = track.name.encode('latin-1', 'replace') - runtime = track.length / 1000 if track.length else -1 + runtime = track.length // 1000 if track.length else -1 file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n') def _save_m3u(self, playlist): From 7a1bb224f73c4107bc1532577d44fd8d46f09ffc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 20:26:07 +0100 Subject: [PATCH 160/495] py3: Avoid indexing exception objects It doesn't work on Python 3. --- tests/mpd/test_exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index a54f9e20..d055ef7e 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -13,7 +13,7 @@ class MpdExceptionsTest(unittest.TestCase): try: raise KeyError('Track X not found') except KeyError as e: - raise MpdAckError(e[0]) + raise MpdAckError(e.message) except MpdAckError as e: self.assertEqual(e.message, 'Track X not found') From 57cdab586a7adcc4579b83677658889bedce13e8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 20:39:39 +0100 Subject: [PATCH 161/495] Fix flake8 warnings --- mopidy/__main__.py | 2 +- mopidy/commands.py | 2 +- mopidy/mpd/protocol/stored_playlists.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 1aa85c07..96e10e18 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,4 +1,4 @@ -from __future__ import print_function, absolute_import, unicode_literals +from __future__ import absolute_import, print_function, unicode_literals import logging import os diff --git a/mopidy/commands.py b/mopidy/commands.py index 16c606c8..4b00a685 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -1,4 +1,4 @@ -from __future__ import print_function, absolute_import, unicode_literals +from __future__ import absolute_import, print_function, unicode_literals import argparse import collections diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index fcfc69ce..f273e9b9 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -1,4 +1,4 @@ -from __future__ import division, absolute_import, unicode_literals +from __future__ import absolute_import, division, unicode_literals import datetime From 4f428b86019d0ba2d013405248c0a6fa0cd45c80 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 21:36:15 +0100 Subject: [PATCH 162/495] py3: Move compat module out of utils --- mopidy/audio/playlists.py | 2 +- mopidy/audio/utils.py | 2 +- mopidy/{utils => }/compat.py | 0 mopidy/config/__init__.py | 5 +++-- mopidy/config/keyring.py | 2 +- mopidy/config/types.py | 3 ++- mopidy/core/tracklist.py | 2 +- mopidy/local/commands.py | 4 ++-- mopidy/local/translator.py | 2 +- mopidy/utils/encoding.py | 2 +- mopidy/utils/jsonrpc.py | 2 +- mopidy/utils/path.py | 4 ++-- mopidy/utils/process.py | 2 +- tests/__init__.py | 2 +- tests/config/test_types.py | 2 +- tests/utils/network/test_lineprotocol.py | 3 ++- tests/utils/test_path.py | 3 ++- 17 files changed, 23 insertions(+), 19 deletions(-) rename mopidy/{utils => }/compat.py (100%) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index a7ec7791..5688db4b 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -8,7 +8,7 @@ import pygst pygst.require('0.10') import gst # noqa -from mopidy.utils.compat import configparser +from mopidy.compat import configparser try: import xml.etree.cElementTree as elementtree diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index cb60af89..a94e4551 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -4,7 +4,7 @@ import pygst pygst.require('0.10') import gst # noqa -from mopidy.utils import compat +from mopidy import compat def calculate_duration(num_samples, sample_rate): diff --git a/mopidy/utils/compat.py b/mopidy/compat.py similarity index 100% rename from mopidy/utils/compat.py rename to mopidy/compat.py diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index e41269c9..6edca51c 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -6,11 +6,12 @@ import logging import os.path import re +from mopidy import compat +from mopidy.compat import configparser from mopidy.config import keyring from mopidy.config.schemas import * # noqa from mopidy.config.types import * # noqa -from mopidy.utils import compat, path, versioning -from mopidy.utils.compat import configparser +from mopidy.utils import path, versioning logger = logging.getLogger(__name__) diff --git a/mopidy/config/keyring.py b/mopidy/config/keyring.py index 75fa8dcf..fb6eded3 100644 --- a/mopidy/config/keyring.py +++ b/mopidy/config/keyring.py @@ -9,7 +9,7 @@ try: except ImportError: dbus = None -from mopidy.utils import compat +from mopidy import compat # XXX: Hack to workaround introspection bug caused by gnome-keyring, should be diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 1dbbe39f..cd3905ac 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -4,8 +4,9 @@ import logging import re import socket +from mopidy import compat from mopidy.config import validators -from mopidy.utils import compat, path +from mopidy.utils import path def decode(value): diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 65c04e05..4b378a02 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -4,9 +4,9 @@ import collections import logging import random +from mopidy import compat from mopidy.core import listener from mopidy.models import TlTrack -from mopidy.utils import compat logger = logging.getLogger(__name__) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 5dacca32..0110a6dd 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -5,10 +5,10 @@ import logging import os import time -from mopidy import commands, exceptions +from mopidy import commands, compat, exceptions from mopidy.audio import scan from mopidy.local import translator -from mopidy.utils import compat, path +from mopidy.utils import path logger = logging.getLogger(__name__) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index ca8b5e94..7d7f0601 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -6,8 +6,8 @@ import re import urllib import urlparse +from mopidy import compat from mopidy.models import Track -from mopidy.utils import compat from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri, uri_to_path diff --git a/mopidy/utils/encoding.py b/mopidy/utils/encoding.py index be8d5a7f..27506816 100644 --- a/mopidy/utils/encoding.py +++ b/mopidy/utils/encoding.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import locale -from mopidy.utils import compat +from mopidy import compat def locale_decode(bytestr): diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 7990586b..13199b26 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -6,7 +6,7 @@ import traceback import pykka -from mopidy.utils import compat +from mopidy import compat class JsonRpcWrapper(object): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 9e5df36a..54f480b4 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -10,8 +10,8 @@ import urlparse import glib -from mopidy.utils import compat -from mopidy.utils.compat import queue +from mopidy import compat +from mopidy.compat import queue logger = logging.getLogger(__name__) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 02c60ef8..5b2bb9c0 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -7,7 +7,7 @@ import threading from pykka import ActorDeadError from pykka.registry import ActorRegistry -from mopidy.utils.compat import thread +from mopidy.compat import thread logger = logging.getLogger(__name__) diff --git a/tests/__init__.py b/tests/__init__.py index c664a5fa..82759578 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import os -from mopidy.utils import compat +from mopidy import compat def path_to_data_dir(name): diff --git a/tests/config/test_types.py b/tests/config/test_types.py index f113fb28..939d028b 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -8,8 +8,8 @@ import unittest import mock +from mopidy import compat from mopidy.config import types -from mopidy.utils import compat # TODO: DecodeTest and EncodeTest diff --git a/tests/utils/network/test_lineprotocol.py b/tests/utils/network/test_lineprotocol.py index 5c6a5ad4..28bfbad2 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/utils/network/test_lineprotocol.py @@ -7,7 +7,8 @@ import unittest from mock import Mock, sentinel -from mopidy.utils import compat, network +from mopidy import compat +from mopidy.utils import network from tests import any_unicode diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index b15fd1b5..d82bcbe6 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -9,7 +9,8 @@ import unittest import glib -from mopidy.utils import compat, path +from mopidy import compat +from mopidy.utils import path import tests From 7acf62723afd5ef1ed403797605851edd5c9e4c4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 21:44:35 +0100 Subject: [PATCH 163/495] py3: Use itervalues() for local track database --- mopidy/compat.py | 6 ++++++ mopidy/local/json.py | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/mopidy/compat.py b/mopidy/compat.py index d0e4aeb2..b563f735 100644 --- a/mopidy/compat.py +++ b/mopidy/compat.py @@ -13,6 +13,9 @@ if PY2: input = raw_input + def itervalues(dct, **kwargs): + return iter(dct.itervalues(**kwargs)) + else: import configparser # noqa import queue # noqa @@ -22,3 +25,6 @@ else: text_type = str input = input + + def itervalues(dct, **kwargs): + return iter(dct.values(**kwargs)) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 48bd373a..70dc68c4 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -11,7 +11,7 @@ import tempfile import time import mopidy -from mopidy import local, models +from mopidy import compat, local, models from mopidy.local import search, storage, translator from mopidy.utils import encoding @@ -164,7 +164,7 @@ class JsonLibrary(local.Library): return search.search(tracks, query=query, uris=uris) def begin(self): - return self._tracks.values() + return compat.itervalues(self._tracks) def add(self, track): self._tracks[track.uri] = track From 7d5117c299ec33b66b67b906a9971bcbc77c3133 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Dec 2014 23:36:14 +0100 Subject: [PATCH 164/495] tox: Update tornado dep --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index ed6f0271..b80996e5 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ deps = commands = nosetests -v tests/http deps = {[testenv]deps} - tornado==3.1 + tornado==3.1.1 [testenv:docs] deps = -r{toxinidir}/docs/requirements.txt From dfd897832a952c4a7da655f504dc0c162433c539 Mon Sep 17 00:00:00 2001 From: Thomas Amland Date: Thu, 11 Dec 2014 14:41:37 +0100 Subject: [PATCH 165/495] [local] fix modified files not being updated --- mopidy/local/commands.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 0110a6dd..de0990ef 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -71,7 +71,6 @@ class ScanCommand(commands.Command): library = _get_library(args, config) - uris_in_library = set() uris_to_update = set() uris_to_remove = set() @@ -96,7 +95,7 @@ class ScanCommand(commands.Command): logger.debug('Missing file %s', track.uri) uris_to_remove.add(track.uri) elif mtime > track.last_modified: - uris_in_library.add(track.uri) + uris_to_update.add(track.uri) logger.info('Removing %d missing tracks.', len(uris_to_remove)) for uri in uris_to_remove: From be341fcd0401be82c99f07cc3121a24932a5c1c0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Dec 2014 22:45:02 +0100 Subject: [PATCH 166/495] docs: Fix references --- mopidy/backend/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backend/__init__.py b/mopidy/backend/__init__.py index 016f2575..45268f9f 100644 --- a/mopidy/backend/__init__.py +++ b/mopidy/backend/__init__.py @@ -70,12 +70,12 @@ class LibraryProvider(object): root_directory = None """ - :class:`models.Ref.directory` instance with a URI and name set + :class:`mopidy.models.Ref.directory` instance with a URI and name set representing the root of this library's browse tree. URIs must use one of the schemes supported by the backend, and name should be set to a human friendly value. - *MUST be set by any class that implements :meth:`LibraryProvider.browse`.* + *MUST be set by any class that implements* :meth:`LibraryProvider.browse`. """ def __init__(self, backend): From 33e3fe9173e075e9664da54fe641fe0e736e15a1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Dec 2014 01:26:36 +0100 Subject: [PATCH 167/495] mpd: Add browse() helper docs --- mopidy/mpd/dispatcher.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 52b57258..5d9cecd9 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -292,6 +292,24 @@ class MpdContext(object): return self._name_from_uri[uri] def browse(self, path, recursive=True, lookup=True): + """ + Browse the contents of a given directory path. + + Returns a sequence of two-tuples ``(path, data)``. + + If ``recursive`` is true, it returns results for all entries in the + given path. + + If ``lookup`` is true and the ``path`` is to a track, the returned + ``data`` is a future which will contain the + :class:`mopidy.models.Track` model. If ``lookup`` is false and the + ``path`` is to a track, the returned ``data`` will be a + :class:`mopidy.models.Ref` for the track. + + For all entries that are not tracks, the returned ``data`` will be + :class:`None`. + """ + path_parts = re.findall(r'[^/]+', path or '') root_path = '/'.join([''] + path_parts) From 4e508cd01750bcef9e0e9c7b70798eacd276aef8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Dec 2014 01:26:41 +0100 Subject: [PATCH 168/495] mpd: Enable browsing of empty dirs This was disabled together with a bunch of other changes without any explanation in commit f24ca36e5a5b72c886d6d8e8df88be79fb094dda. I'm guessing that this wasn't intentional, and no test covered the case. --- mopidy/mpd/protocol/music_db.py | 2 -- tests/mpd/protocol/test_music_db.py | 7 +++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 8dfc1d2c..c143df31 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -421,8 +421,6 @@ def lsinfo(context, uri=None): if uri in (None, '', '/'): result.extend(protocol.stored_playlists.listplaylists(context)) - if not result: - raise exceptions.MpdNoExistError('Not found') return result diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 3d8eefbf..30907bce 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -351,6 +351,13 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('directory: dummy/foo') self.assertInResponse('OK') + def test_lsinfo_for_empty_dir_returns_nothing(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': []} + + self.sendRequest('lsinfo "/dummy"') + self.assertInResponse('OK') + def test_lsinfo_for_dir_does_not_recurse(self): self.backend.library.dummy_library = [ Track(uri='dummy:/a', name='a'), From 5015a7ff28b4acea52f50ed13bb9864080519942 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Dec 2014 13:37:13 +0100 Subject: [PATCH 169/495] docs: Update authors and changelog --- AUTHORS | 2 ++ docs/changelog.rst | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/AUTHORS b/AUTHORS index 110a9ec4..ccca6979 100644 --- a/AUTHORS +++ b/AUTHORS @@ -44,3 +44,5 @@ - Arjun Naik - Christopher Schirner - Dmitry Sandalov +- Lukas Vogel +- Thomas Amland diff --git a/docs/changelog.rst b/docs/changelog.rst index 0e652401..c59822ae 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,15 @@ v0.20.0 (UNRELEASED) just like the other ``lookup()`` methods in Mopidy. For now, returning a single track will continue to work. (PR: :issue:`840`) +**File scanner** + +- Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) + +- Add symlink support with loop protection to file finder (Fixes: :issue:`858`, + PR: :isusue:`874`) + +- Fix scanning of modified files. (PR: :issue:`904`) + **MPD frontend** - In stored playlist names, replace "/", which are illegal, with "|" instead of @@ -40,6 +49,11 @@ v0.20.0 (UNRELEASED) - Enable browsing of artist references, in addition to albums and playlists. (PR: :issue:`884`) +- Re-enable browsing of empty directories. (PR: :issue:`906`) + +- Quick workaround for :issue:`881`, which allows for newlines in comments. + (PR: :issue:`882`) + **Audio** - Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a From 2c3217685b16c5a4711d968b93ebbd902ba215d4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Dec 2014 14:08:45 +0100 Subject: [PATCH 170/495] utils: Switch to exceptions.FindError for errors. --- mopidy/exceptions.py | 6 ++++++ mopidy/utils/path.py | 10 +++++----- tests/test_exceptions.py | 10 ++++++++++ tests/utils/test_path.py | 10 +++++----- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 85169794..4c4a0f6d 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -24,6 +24,12 @@ class ExtensionError(MopidyException): pass +class FindError(MopidyException): + def __init__(self, message, errno=None): + super(FindError, self).__init__(message, errno) + self.errno = errno + + class FrontendError(MopidyException): pass diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 54f480b4..4c546ec3 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -10,7 +10,7 @@ import urlparse import glib -from mopidy import compat +from mopidy import compat, exceptions from mopidy.compat import queue @@ -140,7 +140,7 @@ def _find_worker(relative, follow, done, work, results, errors): st = os.lstat(entry) if (st.st_dev, st.st_ino) in parents: - errors[path] = Exception('Sym/hardlink loop found.') + errors[path] = exceptions.FindError('Sym/hardlink loop found.') continue parents = parents + [(st.st_dev, st.st_ino)] @@ -150,12 +150,12 @@ def _find_worker(relative, follow, done, work, results, errors): elif stat.S_ISREG(st.st_mode): results[path] = st elif stat.S_ISLNK(st.st_mode): - errors[path] = Exception('Not following symlinks.') + errors[path] = exceptions.FindError('Not following symlinks.') else: - errors[path] = Exception('Not a file or directory.') + errors[path] = exceptions.FindError('Not a file or directory.') except OSError as e: - errors[path] = e + errors[path] = exceptions.FindError(e.strerror, e.errno) finally: work.task_done() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index fc19f60a..3420891e 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -20,6 +20,16 @@ class ExceptionsTest(unittest.TestCase): self.assert_(issubclass( exceptions.ExtensionError, exceptions.MopidyException)) + def test_find_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.FindError, exceptions.MopidyException)) + + def test_find_error_can_store_an_errno(self): + exc = exceptions.FindError('msg', errno=1234) + + self.assertEqual(exc.message, 'msg') + self.assertEqual(exc.errno, 1234) + def test_frontend_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.FrontendError, exceptions.MopidyException)) diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index d82bcbe6..4467e07e 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -9,7 +9,7 @@ import unittest import glib -from mopidy import compat +from mopidy import compat, exceptions from mopidy.utils import path import tests @@ -242,7 +242,7 @@ class FindMTimesTest(unittest.TestCase): missing = os.path.join(self.tmpdir, 'does-not-exist') result, errors = path.find_mtimes(missing) self.assertEqual(result, {}) - self.assertEqual(errors, {missing: tests.IsA(OSError)}) + self.assertEqual(errors, {missing: tests.IsA(exceptions.FindError)}) def test_empty_dir(self): """Empty directories should not show up in results""" @@ -295,7 +295,7 @@ class FindMTimesTest(unittest.TestCase): result, errors = path.find_mtimes(self.tmpdir) self.assertEqual({}, result) - self.assertEqual({directory: tests.IsA(OSError)}, errors) + self.assertEqual({directory: tests.IsA(exceptions.FindError)}, errors) def test_symlinks_are_ignored(self): """By default symlinks should be treated as an error""" @@ -305,7 +305,7 @@ class FindMTimesTest(unittest.TestCase): result, errors = path.find_mtimes(self.tmpdir) self.assertEqual(result, {target: tests.any_int}) - self.assertEqual(errors, {link: tests.IsA(Exception)}) + self.assertEqual(errors, {link: tests.IsA(exceptions.FindError)}) def test_symlink_to_file_as_root_is_followed(self): """Passing a symlink as the root should be followed when follow=True""" @@ -327,7 +327,7 @@ class FindMTimesTest(unittest.TestCase): result, errors = path.find_mtimes(link, follow=True) self.assertEqual({}, result) - self.assertEqual({link: tests.IsA(OSError)}, errors) + self.assertEqual({link: tests.IsA(exceptions.FindError)}, errors) def test_symlink_pointing_at_parent_fails(self): """We should detect a loop via the parent and give up on the branch""" From 08a8d5c43be271a41af74ce0c7df9d871cf2b532 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Dec 2014 14:16:37 +0100 Subject: [PATCH 171/495] mpd: Remove "Comment" tag type from translator output. Newer versions of the protocol have removed this tag, so we should as well. This also works around the issue of #881 which was breaking things with newlines in comment fields. The readcomments command seems to replace this, but it seems to only care about specific extra tagtypes, not the general comment tag we normally collect when scanning things. --- mopidy/mpd/translator.py | 3 --- tests/mpd/test_translator.py | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index ec3a270b..23fb2874 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -81,9 +81,6 @@ def track_to_mpd_format(track, position=None): if track.disc_no: result.append(('Disc', track.disc_no)) - if track.comment: - result.append(('Comment', track.comment)) - if track.musicbrainz_id is not None: result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id)) return result diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index b38a93e8..82e60d93 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -73,10 +73,10 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertIn(('Track', '7/13'), result) self.assertIn(('Date', datetime.date(1977, 1, 1)), result) self.assertIn(('Disc', '1'), result) - self.assertIn(('Comment', 'a comment'), result) self.assertIn(('Pos', 9), result) self.assertIn(('Id', 122), result) - self.assertEqual(len(result), 15) + self.assertNotIn(('Comment', 'a comment'), result) + self.assertEqual(len(result), 14) def test_track_to_mpd_format_musicbrainz_trackid(self): track = self.track.copy(musicbrainz_id='foo') From 541412dbfc33d80727db5dbba067fdcc9d922470 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Dec 2014 14:35:13 +0100 Subject: [PATCH 172/495] mpd: Remove newline escaping code. This was added for #881, where the correct fix turned out to be to remove comments from the responses. We should still add some sanity checks for verifying that our responses at the very least only contain printable chars. --- mopidy/mpd/dispatcher.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 8167a774..5d9cecd9 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -196,17 +196,12 @@ class MpdDispatcher(object): def _format_lines(self, line): if isinstance(line, dict): - return [self._escape_newlines('%s: %s' % (key, value)) - for (key, value) - in line.items()] + return ['%s: %s' % (key, value) for (key, value) in line.items()] if isinstance(line, tuple): (key, value) = line - return [self._escape_newlines('%s: %s' % (key, value))] + return ['%s: %s' % (key, value)] return [line] - def _escape_newlines(self, text): - return text.replace('\n', '\\n') - class MpdContext(object): """ From f477e9176e393e4ddd2e834649cca0392a827bfe Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Dec 2014 22:23:13 +0100 Subject: [PATCH 173/495] audio: Add helper for converting taglists Goal is simply to avoid leaking gst types to the rest of mopidy. Only part we will be leaking is the tag keys. Which we can live with. --- mopidy/audio/utils.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index a94e4551..9ddd8494 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -1,11 +1,17 @@ from __future__ import absolute_import, unicode_literals +import datetime +import logging +import numbers + import pygst pygst.require('0.10') import gst # noqa from mopidy import compat +logger = logging.getLogger(__name__) + def calculate_duration(num_samples, sample_rate): """Determine duration of samples using GStreamer helper for precise @@ -56,3 +62,47 @@ def supported_uri_schemes(uri_schemes): supported_schemes.add(uri) return supported_schemes + + +def convert_taglist(taglist): + """Convert a :class:`gst.Taglist` to plain python types. + + Knows how to convert: + - Dates + - Buffers + - Numbers + - Strings + - Booleans + + Unknown types will be ignored and debug logged. Tag keys are all strings + defined by GStreamer. + + :param :class:`gst.Taglist` taglist: A GStreamer taglist to be converted. + :rtype: dictionary of tag keys with a list of values. + """ + result = {} + + # Taglists are not really dicts, hence the lack of .items() and + # explicit use of .keys() + for key in taglist.keys(): + result.setdefault(key, []) + + values = taglist[key] + if not isinstance(values, list): + values = [values] + + for value in values: + if isinstance(value, gst.Date): + try: + date = datetime.date(value.year, value.month, value.day) + result[key].append(date) + except ValueError: + logger.debug('Ignoring invalid date: %r = %r', key, value) + elif isinstance(value, gst.Buffer): + result[key].append(bytes(value)) + elif isinstance(value, (basestring, bool, numbers.Number)): + result[key].append(value) + else: + logger.debug('Ignoring unknown data: %r = %r', key, value) + + return result From 671ee5ee6afe3ab5dde473cb4fc07befa48ba3c7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Dec 2014 22:25:52 +0100 Subject: [PATCH 174/495] audio: Switch to using converted taglists instead of raw data. --- mopidy/audio/scan.py | 28 +++++++--------------------- tests/audio/test_scan.py | 16 +++------------- 2 files changed, 10 insertions(+), 34 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 55ad6426..95152c75 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, division, unicode_literals -import datetime import os import time @@ -9,6 +8,7 @@ pygst.require('0.10') import gst # noqa from mopidy import exceptions +from mopidy.audio import utils from mopidy.models import Album, Artist, Track from mopidy.utils import encoding, path @@ -98,16 +98,9 @@ class Scanner(object): if message.src == self._pipe: return tags elif message.type == gst.MESSAGE_TAG: - # Taglists are not really dicts, hence the lack of .items() and - # explicit .keys. We only keep the last tag for each key, as we - # assume this is the best, some formats will produce multiple - # taglists. Lastly we force everything to lists for conformity. taglist = message.parse_tag() - for key in taglist.keys(): - value = taglist[key] - if not isinstance(value, list): - value = [value] - tags[key] = value + # Note that this will only keep the last tag. + tags.update(utils.convert_taglist(taglist)) raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) @@ -140,16 +133,7 @@ def _artists(tags, artist_name, artist_id=None): return [Artist(name=name) for name in tags[artist_name]] -def _date(tags): - if not tags.get(gst.TAG_DATE): - return None - try: - date = tags[gst.TAG_DATE][0] - return datetime.date(date.year, date.month, date.day).isoformat() - except ValueError: - return None - - +# TODO: this doesn't belong in audio, if anything it should be moved to local. def add_musicbrainz_cover_art(track): if track.album and track.album.musicbrainz_id: base = "http://coverartarchive.org/release" @@ -196,7 +180,9 @@ def audio_data_to_track(data): album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] - track_kwargs['date'] = _date(tags) + if tags.get(gst.TAG_DATE) and tags.get(gst.TAG_DATE)[0]: + track_kwargs['date'] = tags[gst.TAG_DATE][0].isoformat() + track_kwargs['last_modified'] = int(data.get('mtime') or 0) track_kwargs['length'] = max( 0, (data.get(gst.TAG_DURATION) or 0)) // gst.MSECOND diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 45a4aa6a..99bfb2c4 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import datetime import os import unittest @@ -14,13 +15,6 @@ from mopidy.utils import path as path_lib from tests import path_to_data_dir -class FakeGstDate(object): - def __init__(self, year, month, day): - self.year = year - self.month = month - self.day = day - - # TODO: keep ids without name? class TranslatorTest(unittest.TestCase): def setUp(self): @@ -39,7 +33,7 @@ class TranslatorTest(unittest.TestCase): 'track-count': [2], 'album-disc-number': [2], 'album-disc-count': [3], - 'date': [FakeGstDate(2006, 1, 1,)], + 'date': [datetime.date(2006, 1, 1,)], 'container-format': ['ID3 tag'], 'genre': ['genre'], 'comment': ['comment'], @@ -140,13 +134,9 @@ class TranslatorTest(unittest.TestCase): self.check(self.track.copy(date=None)) def test_multiple_track_date(self): - self.data['tags']['date'].append(FakeGstDate(2030, 1, 1)) + self.data['tags']['date'].append(datetime.date(2030, 1, 1)) self.check(self.track) - def test_invalid_track_date(self): - self.data['tags']['date'] = [FakeGstDate(65535, 1, 1)] - self.check(self.track.copy(date=None)) - def test_missing_track_comment(self): del self.data['tags']['comment'] self.check(self.track.copy(comment=None)) From 90fdd46109ac252f9a22488b279caee1d2104685 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Dec 2014 23:14:11 +0100 Subject: [PATCH 175/495] local: Cleanup translator tests --- tests/local/test_translator.py | 41 ++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py index a473a0ff..d3ba9e68 100644 --- a/tests/local/test_translator.py +++ b/tests/local/test_translator.py @@ -6,9 +6,9 @@ import os import tempfile import unittest -from mopidy.local.translator import parse_m3u +from mopidy.local import translator from mopidy.models import Track -from mopidy.utils.path import path_to_uri +from mopidy.utils import path from tests import path_to_data_dir @@ -16,9 +16,9 @@ data_dir = path_to_data_dir('') song1_path = path_to_data_dir('song1.mp3') song2_path = path_to_data_dir('song2.mp3') encoded_path = path_to_data_dir('æøå.mp3') -song1_uri = path_to_uri(song1_path) -song2_uri = path_to_uri(song2_path) -encoded_uri = path_to_uri(encoded_path) +song1_uri = path.path_to_uri(song1_path) +song2_uri = path.path_to_uri(song2_path) +encoded_uri = path.path_to_uri(encoded_path) song1_track = Track(uri=song1_uri) song2_track = Track(uri=song2_uri) encoded_track = Track(uri=encoded_uri) @@ -30,23 +30,26 @@ encoded_ext_track = encoded_track.copy(name='æøå') # FIXME use mock instead of tempfile.NamedTemporaryFile class M3UToUriTest(unittest.TestCase): + def parse(self, name): + return translator.parse_m3u(name, data_dir) + def test_empty_file(self): - tracks = parse_m3u(path_to_data_dir('empty.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('empty.m3u')) self.assertEqual([], tracks) def test_basic_file(self): - tracks = parse_m3u(path_to_data_dir('one.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('one.m3u')) self.assertEqual([song1_track], tracks) def test_file_with_comment(self): - tracks = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('comment.m3u')) self.assertEqual([song1_track], tracks) def test_file_is_relative_to_correct_dir(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write('song1.mp3') try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): @@ -56,7 +59,7 @@ class M3UToUriTest(unittest.TestCase): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_path) try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): @@ -68,7 +71,7 @@ class M3UToUriTest(unittest.TestCase): tmp.write('# comment \n') tmp.write(song2_path) try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track, song2_track], tracks) finally: if os.path.exists(tmp.name): @@ -78,38 +81,38 @@ class M3UToUriTest(unittest.TestCase): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_uri) try: - tracks = parse_m3u(tmp.name, data_dir) + tracks = self.parse(tmp.name) self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): os.remove(tmp.name) def test_encoding_is_latin1(self): - tracks = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('encoding.m3u')) self.assertEqual([encoded_track], tracks) def test_open_missing_file(self): - tracks = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('non-existant.m3u')) self.assertEqual([], tracks) def test_empty_ext_file(self): - tracks = parse_m3u(path_to_data_dir('empty-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('empty-ext.m3u')) self.assertEqual([], tracks) def test_basic_ext_file(self): - tracks = parse_m3u(path_to_data_dir('one-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('one-ext.m3u')) self.assertEqual([song1_ext_track], tracks) def test_multi_ext_file(self): - tracks = parse_m3u(path_to_data_dir('two-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('two-ext.m3u')) self.assertEqual([song1_ext_track, song2_ext_track], tracks) def test_ext_file_with_comment(self): - tracks = parse_m3u(path_to_data_dir('comment-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('comment-ext.m3u')) self.assertEqual([song1_ext_track], tracks) def test_ext_encoding_is_latin1(self): - tracks = parse_m3u(path_to_data_dir('encoding-ext.m3u'), data_dir) + tracks = self.parse(path_to_data_dir('encoding-ext.m3u')) self.assertEqual([encoded_ext_track], tracks) From 70829390d18173ab2a74b4d5be71014561f540f5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Dec 2014 23:39:22 +0100 Subject: [PATCH 176/495] docs: Fix typo --- docs/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2995f1a4..a0371840 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -50,7 +50,8 @@ v0.20.0 (UNRELEASED) **Audio** - Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a - :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` end the stream. + :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end the + stream. - Internal code cleanup within audio subsystem: From dcaa0f67321d585690314284cfbeaaa88b845ae8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Dec 2014 23:15:40 +0100 Subject: [PATCH 177/495] local: Move musicbrainz cover art helper to translator --- mopidy/audio/scan.py | 13 ------------- mopidy/local/commands.py | 2 +- mopidy/local/translator.py | 9 +++++++++ tests/audio/test_scan.py | 11 ----------- tests/local/test_translator.py | 15 ++++++++++++++- 5 files changed, 24 insertions(+), 26 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 95152c75..55798f79 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -133,19 +133,6 @@ def _artists(tags, artist_name, artist_id=None): return [Artist(name=name) for name in tags[artist_name]] -# TODO: this doesn't belong in audio, if anything it should be moved to local. -def add_musicbrainz_cover_art(track): - if track.album and track.album.musicbrainz_id: - base = "http://coverartarchive.org/release" - images = frozenset( - ["{}/{}/front".format( - base, - track.album.musicbrainz_id)]) - album = track.album.copy(images=images) - track = track.copy(album=album) - return track - - def audio_data_to_track(data): """Convert taglist data + our extras to a track.""" tags = data['tags'] diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index de0990ef..60f8fc4f 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -128,7 +128,7 @@ class ScanCommand(commands.Command): relpath = translator.local_track_uri_to_path(uri, media_dir) file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) data = scanner.scan(file_uri) - track = scan.add_musicbrainz_cover_art( + track = translator.add_musicbrainz_coverart_to_track( scan.audio_data_to_track(data).copy(uri=uri)).copy(uri=uri) library.add(track) logger.debug('Added %s', track.uri) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 7d7f0601..3cbe2066 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -13,10 +13,19 @@ from mopidy.utils.path import path_to_uri, uri_to_path M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') +COVERART_BASE = 'http://coverartarchive.org/release/%s/front' logger = logging.getLogger(__name__) +def add_musicbrainz_coverart_to_track(track): + if track.album and track.album.musicbrainz_id: + images = [COVERART_BASE % track.album.musicbrainz_id] + album = track.album.copy(images=images) + track = track.copy(album=album) + return track + + def local_track_uri_to_file_uri(uri, media_dir): return path_to_uri(local_track_uri_to_path(uri, media_dir)) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 99bfb2c4..ccf2dc5e 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -65,11 +65,6 @@ class TranslatorTest(unittest.TestCase): actual = scan.audio_data_to_track(self.data) self.assertEqual(expected, actual) - def check_local(self, expected): - actual = scan.add_musicbrainz_cover_art( - scan.audio_data_to_track(self.data)) - self.assertEqual(expected, actual) - def test_track(self): self.check(self.track) @@ -200,12 +195,6 @@ class TranslatorTest(unittest.TestCase): self.data['tags']['musicbrainz-albumid'].append('id') self.check(self.track) - def test_album_musicbrainz_id_cover(self): - album = self.track.album.copy( - images=frozenset( - ['http://coverartarchive.org/release/albumid/front'])) - self.check_local(self.track.copy(album=album)) - def test_missing_album_num_tracks(self): del self.data['tags']['track-count'] album = self.track.album.copy(num_tracks=None) diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py index d3ba9e68..b238c909 100644 --- a/tests/local/test_translator.py +++ b/tests/local/test_translator.py @@ -7,7 +7,7 @@ import tempfile import unittest from mopidy.local import translator -from mopidy.models import Track +from mopidy.models import Album, Track from mopidy.utils import path from tests import path_to_data_dir @@ -118,3 +118,16 @@ class M3UToUriTest(unittest.TestCase): class URItoM3UTest(unittest.TestCase): pass + + +class AddMusicbrainzCoverartTest(unittest.TestCase): + def test_add_cover_for_album(self): + album = Album(musicbrainz_id='someid') + track = Track(album=album) + + expected = album.copy( + images=['http://coverartarchive.org/release/someid/front']) + + self.assertEqual( + track.copy(album=expected), + translator.add_musicbrainz_coverart_to_track(track)) From d9d501cd98ae6ed77f1a3c2069e8477aeab5e02d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 15 Dec 2014 21:13:23 +0100 Subject: [PATCH 178/495] audio: Update scanner to just return tags+duration The caller should know what URI we are talking about. Additionally finding the mtime is never belonged in this class and has been moved out. --- mopidy/audio/scan.py | 22 ++++++++-------------- mopidy/local/commands.py | 11 ++++++++--- mopidy/stream/actor.py | 5 +++-- tests/audio/test_scan.py | 8 +++++--- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 55798f79..e79b038a 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, division, unicode_literals -import os import time import pygst @@ -10,7 +9,7 @@ import gst # noqa from mopidy import exceptions from mopidy.audio import utils from mopidy.models import Album, Artist, Track -from mopidy.utils import encoding, path +from mopidy.utils import encoding class Scanner(object): @@ -49,21 +48,21 @@ class Scanner(object): :param uri: URI of the resource to scan. :type event: string - :return: Dictionary of tags, duration, mtime and uri information. + :return: (tags, duration) pair. tags is a dictionary of lists for all + the tags we found and duration is the length of the URI in + nanoseconds. No duration is indicated by -1 as in GStreamer. """ try: self._setup(uri) tags = self._collect() # Ensure collect before queries. - data = {'uri': uri, 'tags': tags, - 'mtime': self._query_mtime(uri), - 'duration': self._query_duration()} + duration = self._query_duration() finally: self._reset() if self._min_duration_ms is None: - return data - elif data['duration'] >= self._min_duration_ms * gst.MSECOND: - return data + return tags, duration + elif duration >= self._min_duration_ms * gst.MSECOND: + return tags, duration raise exceptions.ScannerError('Rejecting file with less than %dms ' 'audio data.' % self._min_duration_ms) @@ -115,11 +114,6 @@ class Scanner(object): except gst.QueryError: return None - def _query_mtime(self, uri): - if not uri.startswith('file:'): - return None - return os.path.getmtime(path.uri_to_path(uri)) - def _artists(tags, artist_name, artist_id=None): # Name missing, don't set artist diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 60f8fc4f..bebcfd17 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -127,9 +127,14 @@ class ScanCommand(commands.Command): try: relpath = translator.local_track_uri_to_path(uri, media_dir) file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) - data = scanner.scan(file_uri) - track = translator.add_musicbrainz_coverart_to_track( - scan.audio_data_to_track(data).copy(uri=uri)).copy(uri=uri) + tags, duration = scanner.scan(file_uri) + # TODO: reuse mtime from above... + mtime = os.path.getmtime(os.path.join(media_dir, relpath)) + track = scan.audio_data_to_track({'uri': uri, + 'tags': tags, + 'duration': duration, + 'mtime': mtime}) + track = translator.add_musicbrainz_coverart_to_track(track) library.add(track) logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index b6336fbe..9b3b0556 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -44,8 +44,9 @@ class StreamLibraryProvider(backend.LibraryProvider): return [Track(uri=uri)] try: - data = self._scanner.scan(uri) - track = scan.audio_data_to_track(data) + tags, duration = self._scanner.scan(uri) + track = scan.audio_data_to_track({ + 'uri': uri, 'tags': tags, 'duration': duration, 'mtime': None}) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) track = Track(uri=uri) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index ccf2dc5e..c5cab9f0 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -17,7 +17,7 @@ from tests import path_to_data_dir # TODO: keep ids without name? class TranslatorTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa self.data = { 'uri': 'uri', 'duration': 4531000000, @@ -268,7 +268,7 @@ class TranslatorTest(unittest.TestCase): class ScannerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa self.errors = {} self.data = {} @@ -284,7 +284,9 @@ class ScannerTest(unittest.TestCase): uri = path_lib.path_to_uri(path) key = uri[len('file://'):] try: - self.data[key] = scanner.scan(uri) + tags, duration = scanner.scan(uri) + self.data[key] = { + 'uri': uri, 'tags': tags, 'duration': duration} except exceptions.ScannerError as error: self.errors[key] = error From 4948dee4b9cdb4d5d0de549497927fe5ddf4f447 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 15 Dec 2014 22:46:00 +0100 Subject: [PATCH 179/495] audio: Make scanner return duration in milliseconds Also ensures that we normalize unknown duration to None instead of -1. --- mopidy/audio/scan.py | 14 +++++++++----- tests/audio/test_scan.py | 6 +++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index e79b038a..86c66f93 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -50,7 +50,7 @@ class Scanner(object): :type event: string :return: (tags, duration) pair. tags is a dictionary of lists for all the tags we found and duration is the length of the URI in - nanoseconds. No duration is indicated by -1 as in GStreamer. + milliseconds, or :class:`None` if the URI has no duration. """ try: self._setup(uri) @@ -61,7 +61,7 @@ class Scanner(object): if self._min_duration_ms is None: return tags, duration - elif duration >= self._min_duration_ms * gst.MSECOND: + elif duration >= self._min_duration_ms: return tags, duration raise exceptions.ScannerError('Rejecting file with less than %dms ' @@ -110,10 +110,15 @@ class Scanner(object): def _query_duration(self): try: - return self._pipe.query_duration(gst.FORMAT_TIME, None)[0] + duration = self._pipe.query_duration(gst.FORMAT_TIME, None)[0] except gst.QueryError: return None + if duration < 0: + return None + else: + return duration // gst.MSECOND + def _artists(tags, artist_name, artist_id=None): # Name missing, don't set artist @@ -165,8 +170,7 @@ def audio_data_to_track(data): track_kwargs['date'] = tags[gst.TAG_DATE][0].isoformat() track_kwargs['last_modified'] = int(data.get('mtime') or 0) - track_kwargs['length'] = max( - 0, (data.get(gst.TAG_DURATION) or 0)) // gst.MSECOND + track_kwargs['length'] = data.get('duration') # Clear out any empty values we found track_kwargs = {k: v for k, v in track_kwargs.items() if v} diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index c5cab9f0..ccecfbbb 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -20,7 +20,7 @@ class TranslatorTest(unittest.TestCase): def setUp(self): # noqa self.data = { 'uri': 'uri', - 'duration': 4531000000, + 'duration': 4531, 'mtime': 1234, 'tags': { 'album': ['album'], @@ -317,8 +317,8 @@ class ScannerTest(unittest.TestCase): def test_duration_is_set(self): self.scan(self.find('scanner/simple')) - self.check('scanner/simple/song1.mp3', 'duration', 4680000000) - self.check('scanner/simple/song1.ogg', 'duration', 4680000000) + self.check('scanner/simple/song1.mp3', 'duration', 4680) + self.check('scanner/simple/song1.ogg', 'duration', 4680) def test_artist_is_set(self): self.scan(self.find('scanner/simple')) From 4f8244c499bc3ed9bd30af0863d0deb840ac3ec0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 15 Dec 2014 23:05:15 +0100 Subject: [PATCH 180/495] audio: Convert audio_data_to_track to tags_to_track The new function only uses tags as input. In other words we now need to set length, uri and mtime ourselves. Users of scan APIs have been updated. --- mopidy/audio/scan.py | 8 +- mopidy/local/commands.py | 6 +- mopidy/stream/actor.py | 3 +- tests/audio/test_scan.py | 196 +++++++++++++++++---------------------- 4 files changed, 91 insertions(+), 122 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 86c66f93..5de046ce 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -132,9 +132,7 @@ def _artists(tags, artist_name, artist_id=None): return [Artist(name=name) for name in tags[artist_name]] -def audio_data_to_track(data): - """Convert taglist data + our extras to a track.""" - tags = data['tags'] +def tags_to_track(tags): album_kwargs = {} track_kwargs = {} @@ -169,13 +167,9 @@ def audio_data_to_track(data): if tags.get(gst.TAG_DATE) and tags.get(gst.TAG_DATE)[0]: track_kwargs['date'] = tags[gst.TAG_DATE][0].isoformat() - track_kwargs['last_modified'] = int(data.get('mtime') or 0) - track_kwargs['length'] = data.get('duration') - # Clear out any empty values we found track_kwargs = {k: v for k, v in track_kwargs.items() if v} album_kwargs = {k: v for k, v in album_kwargs.items() if v} - track_kwargs['uri'] = data['uri'] track_kwargs['album'] = Album(**album_kwargs) return Track(**track_kwargs) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index bebcfd17..d7765a41 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -130,10 +130,8 @@ class ScanCommand(commands.Command): tags, duration = scanner.scan(file_uri) # TODO: reuse mtime from above... mtime = os.path.getmtime(os.path.join(media_dir, relpath)) - track = scan.audio_data_to_track({'uri': uri, - 'tags': tags, - 'duration': duration, - 'mtime': mtime}) + track = scan.tags_to_track(tags).copy( + uri=uri, length=duration, last_modified=mtime) track = translator.add_musicbrainz_coverart_to_track(track) library.add(track) logger.debug('Added %s', track.uri) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 9b3b0556..5a5b1a2f 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -45,8 +45,7 @@ class StreamLibraryProvider(backend.LibraryProvider): try: tags, duration = self._scanner.scan(uri) - track = scan.audio_data_to_track({ - 'uri': uri, 'tags': tags, 'duration': duration, 'mtime': None}) + track = scan.tags_to_track(tags).copy(uri=uri, length=duration) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) track = Track(uri=uri) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index ccecfbbb..d17a9f3d 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -16,33 +16,28 @@ from tests import path_to_data_dir # TODO: keep ids without name? -class TranslatorTest(unittest.TestCase): +class TagsToTrackTest(unittest.TestCase): def setUp(self): # noqa - self.data = { - 'uri': 'uri', - 'duration': 4531, - 'mtime': 1234, - 'tags': { - 'album': ['album'], - 'track-number': [1], - 'artist': ['artist'], - 'composer': ['composer'], - 'performer': ['performer'], - 'album-artist': ['albumartist'], - 'title': ['track'], - 'track-count': [2], - 'album-disc-number': [2], - 'album-disc-count': [3], - 'date': [datetime.date(2006, 1, 1,)], - 'container-format': ['ID3 tag'], - 'genre': ['genre'], - 'comment': ['comment'], - 'musicbrainz-trackid': ['trackid'], - 'musicbrainz-albumid': ['albumid'], - 'musicbrainz-artistid': ['artistid'], - 'musicbrainz-albumartistid': ['albumartistid'], - 'bitrate': [1000], - }, + self.tags = { + 'album': ['album'], + 'track-number': [1], + 'artist': ['artist'], + 'composer': ['composer'], + 'performer': ['performer'], + 'album-artist': ['albumartist'], + 'title': ['track'], + 'track-count': [2], + 'album-disc-number': [2], + 'album-disc-count': [3], + 'date': [datetime.date(2006, 1, 1,)], + 'container-format': ['ID3 tag'], + 'genre': ['genre'], + 'comment': ['comment'], + 'musicbrainz-trackid': ['trackid'], + 'musicbrainz-albumid': ['albumid'], + 'musicbrainz-artistid': ['artistid'], + 'musicbrainz-albumartistid': ['albumartistid'], + 'bitrate': [1000], } artist = Artist(name='artist', musicbrainz_id='artistid') @@ -54,223 +49,215 @@ class TranslatorTest(unittest.TestCase): album = Album(name='album', num_tracks=2, num_discs=3, musicbrainz_id='albumid', artists=[albumartist]) - self.track = Track(uri='uri', name='track', date='2006-01-01', - genre='genre', track_no=1, disc_no=2, length=4531, + self.track = Track(name='track', date='2006-01-01', + genre='genre', track_no=1, disc_no=2, comment='comment', musicbrainz_id='trackid', - last_modified=1234, album=album, bitrate=1000, - artists=[artist], composers=[composer], - performers=[performer]) + album=album, bitrate=1000, artists=[artist], + composers=[composer], performers=[performer]) def check(self, expected): - actual = scan.audio_data_to_track(self.data) + actual = scan.tags_to_track(self.tags) self.assertEqual(expected, actual) def test_track(self): self.check(self.track) - def test_none_track_length(self): - self.data['duration'] = None - self.check(self.track.copy(length=None)) - - def test_none_track_last_modified(self): - self.data['mtime'] = None - self.check(self.track.copy(last_modified=None)) - def test_missing_track_no(self): - del self.data['tags']['track-number'] + del self.tags['track-number'] self.check(self.track.copy(track_no=None)) def test_multiple_track_no(self): - self.data['tags']['track-number'].append(9) + self.tags['track-number'].append(9) self.check(self.track) def test_missing_track_disc_no(self): - del self.data['tags']['album-disc-number'] + del self.tags['album-disc-number'] self.check(self.track.copy(disc_no=None)) def test_multiple_track_disc_no(self): - self.data['tags']['album-disc-number'].append(9) + self.tags['album-disc-number'].append(9) self.check(self.track) def test_missing_track_name(self): - del self.data['tags']['title'] + del self.tags['title'] self.check(self.track.copy(name=None)) def test_multiple_track_name(self): - self.data['tags']['title'] = ['name1', 'name2'] + self.tags['title'] = ['name1', 'name2'] self.check(self.track.copy(name='name1; name2')) def test_missing_track_musicbrainz_id(self): - del self.data['tags']['musicbrainz-trackid'] + del self.tags['musicbrainz-trackid'] self.check(self.track.copy(musicbrainz_id=None)) def test_multiple_track_musicbrainz_id(self): - self.data['tags']['musicbrainz-trackid'].append('id') + self.tags['musicbrainz-trackid'].append('id') self.check(self.track) def test_missing_track_bitrate(self): - del self.data['tags']['bitrate'] + del self.tags['bitrate'] self.check(self.track.copy(bitrate=None)) def test_multiple_track_bitrate(self): - self.data['tags']['bitrate'].append(1234) + self.tags['bitrate'].append(1234) self.check(self.track) def test_missing_track_genre(self): - del self.data['tags']['genre'] + del self.tags['genre'] self.check(self.track.copy(genre=None)) def test_multiple_track_genre(self): - self.data['tags']['genre'] = ['genre1', 'genre2'] + self.tags['genre'] = ['genre1', 'genre2'] self.check(self.track.copy(genre='genre1; genre2')) def test_missing_track_date(self): - del self.data['tags']['date'] + del self.tags['date'] self.check(self.track.copy(date=None)) def test_multiple_track_date(self): - self.data['tags']['date'].append(datetime.date(2030, 1, 1)) + self.tags['date'].append(datetime.date(2030, 1, 1)) self.check(self.track) def test_missing_track_comment(self): - del self.data['tags']['comment'] + del self.tags['comment'] self.check(self.track.copy(comment=None)) def test_multiple_track_comment(self): - self.data['tags']['comment'] = ['comment1', 'comment2'] + self.tags['comment'] = ['comment1', 'comment2'] self.check(self.track.copy(comment='comment1; comment2')) def test_missing_track_artist_name(self): - del self.data['tags']['artist'] + del self.tags['artist'] self.check(self.track.copy(artists=[])) def test_multiple_track_artist_name(self): - self.data['tags']['artist'] = ['name1', 'name2'] + self.tags['artist'] = ['name1', 'name2'] artists = [Artist(name='name1'), Artist(name='name2')] self.check(self.track.copy(artists=artists)) def test_missing_track_artist_musicbrainz_id(self): - del self.data['tags']['musicbrainz-artistid'] + del self.tags['musicbrainz-artistid'] artist = list(self.track.artists)[0].copy(musicbrainz_id=None) self.check(self.track.copy(artists=[artist])) def test_multiple_track_artist_musicbrainz_id(self): - self.data['tags']['musicbrainz-artistid'].append('id') + self.tags['musicbrainz-artistid'].append('id') self.check(self.track) def test_missing_track_composer_name(self): - del self.data['tags']['composer'] + del self.tags['composer'] self.check(self.track.copy(composers=[])) def test_multiple_track_composer_name(self): - self.data['tags']['composer'] = ['composer1', 'composer2'] + self.tags['composer'] = ['composer1', 'composer2'] composers = [Artist(name='composer1'), Artist(name='composer2')] self.check(self.track.copy(composers=composers)) def test_missing_track_performer_name(self): - del self.data['tags']['performer'] + del self.tags['performer'] self.check(self.track.copy(performers=[])) def test_multiple_track_performe_name(self): - self.data['tags']['performer'] = ['performer1', 'performer2'] + self.tags['performer'] = ['performer1', 'performer2'] performers = [Artist(name='performer1'), Artist(name='performer2')] self.check(self.track.copy(performers=performers)) def test_missing_album_name(self): - del self.data['tags']['album'] + del self.tags['album'] album = self.track.album.copy(name=None) self.check(self.track.copy(album=album)) def test_multiple_album_name(self): - self.data['tags']['album'].append('album2') + self.tags['album'].append('album2') self.check(self.track) def test_missing_album_musicbrainz_id(self): - del self.data['tags']['musicbrainz-albumid'] + del self.tags['musicbrainz-albumid'] album = self.track.album.copy(musicbrainz_id=None, images=[]) self.check(self.track.copy(album=album)) def test_multiple_album_musicbrainz_id(self): - self.data['tags']['musicbrainz-albumid'].append('id') + self.tags['musicbrainz-albumid'].append('id') self.check(self.track) def test_missing_album_num_tracks(self): - del self.data['tags']['track-count'] + del self.tags['track-count'] album = self.track.album.copy(num_tracks=None) self.check(self.track.copy(album=album)) def test_multiple_album_num_tracks(self): - self.data['tags']['track-count'].append(9) + self.tags['track-count'].append(9) self.check(self.track) def test_missing_album_num_discs(self): - del self.data['tags']['album-disc-count'] + del self.tags['album-disc-count'] album = self.track.album.copy(num_discs=None) self.check(self.track.copy(album=album)) def test_multiple_album_num_discs(self): - self.data['tags']['album-disc-count'].append(9) + self.tags['album-disc-count'].append(9) self.check(self.track) def test_missing_album_artist_name(self): - del self.data['tags']['album-artist'] + del self.tags['album-artist'] album = self.track.album.copy(artists=[]) self.check(self.track.copy(album=album)) def test_multiple_album_artist_name(self): - self.data['tags']['album-artist'] = ['name1', 'name2'] + self.tags['album-artist'] = ['name1', 'name2'] artists = [Artist(name='name1'), Artist(name='name2')] album = self.track.album.copy(artists=artists) self.check(self.track.copy(album=album)) def test_missing_album_artist_musicbrainz_id(self): - del self.data['tags']['musicbrainz-albumartistid'] + del self.tags['musicbrainz-albumartistid'] albumartist = list(self.track.album.artists)[0] albumartist = albumartist.copy(musicbrainz_id=None) album = self.track.album.copy(artists=[albumartist]) self.check(self.track.copy(album=album)) def test_multiple_album_artist_musicbrainz_id(self): - self.data['tags']['musicbrainz-albumartistid'].append('id') + self.tags['musicbrainz-albumartistid'].append('id') self.check(self.track) def test_stream_organization_track_name(self): - del self.data['tags']['title'] - self.data['tags']['organization'] = ['organization'] + del self.tags['title'] + self.tags['organization'] = ['organization'] self.check(self.track.copy(name='organization')) def test_multiple_organization_track_name(self): - del self.data['tags']['title'] - self.data['tags']['organization'] = ['organization1', 'organization2'] + del self.tags['title'] + self.tags['organization'] = ['organization1', 'organization2'] self.check(self.track.copy(name='organization1; organization2')) # TODO: combine all comment types? def test_stream_location_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['location'] = ['location'] + del self.tags['comment'] + self.tags['location'] = ['location'] self.check(self.track.copy(comment='location')) def test_multiple_location_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['location'] = ['location1', 'location2'] + del self.tags['comment'] + self.tags['location'] = ['location1', 'location2'] self.check(self.track.copy(comment='location1; location2')) def test_stream_copyright_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['copyright'] = ['copyright'] + del self.tags['comment'] + self.tags['copyright'] = ['copyright'] self.check(self.track.copy(comment='copyright')) def test_multiple_copyright_track_comment(self): - del self.data['tags']['comment'] - self.data['tags']['copyright'] = ['copyright1', 'copyright2'] + del self.tags['comment'] + self.tags['copyright'] = ['copyright1', 'copyright2'] self.check(self.track.copy(comment='copyright1; copyright2')) class ScannerTest(unittest.TestCase): def setUp(self): # noqa self.errors = {} - self.data = {} + self.tags = {} + self.durations = {} def find(self, path): media_dir = path_to_data_dir(path) @@ -285,40 +272,31 @@ class ScannerTest(unittest.TestCase): key = uri[len('file://'):] try: tags, duration = scanner.scan(uri) - self.data[key] = { - 'uri': uri, 'tags': tags, 'duration': duration} + self.tags[key] = tags + self.durations[key] = duration except exceptions.ScannerError as error: self.errors[key] = error - def check(self, name, key, value): - name = path_to_data_dir(name) - self.assertEqual(self.data[name][key], value) - def check_tag(self, name, key, value): name = path_to_data_dir(name) - self.assertEqual(self.data[name]['tags'][key], value) + self.assertEqual(self.tags[name][key], value) - def test_data_is_set(self): + def check_duration(self, name, value): + name = path_to_data_dir(name) + self.assertEqual(self.durations[name], value) + + def test_tags_is_set(self): self.scan(self.find('scanner/simple')) - self.assert_(self.data) + self.assert_(self.tags) def test_errors_is_not_set(self): self.scan(self.find('scanner/simple')) self.assert_(not self.errors) - def test_uri_is_set(self): - self.scan(self.find('scanner/simple')) - self.check( - 'scanner/simple/song1.mp3', 'uri', - 'file://%s' % path_to_data_dir('scanner/simple/song1.mp3')) - self.check( - 'scanner/simple/song1.ogg', 'uri', - 'file://%s' % path_to_data_dir('scanner/simple/song1.ogg')) - def test_duration_is_set(self): self.scan(self.find('scanner/simple')) - self.check('scanner/simple/song1.mp3', 'duration', 4680) - self.check('scanner/simple/song1.ogg', 'duration', 4680) + self.check_duration('scanner/simple/song1.mp3', 4680) + self.check_duration('scanner/simple/song1.ogg', 4680) def test_artist_is_set(self): self.scan(self.find('scanner/simple')) From de6bd63481edb4f6433d1a834622b3c7e0974ac1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 16 Dec 2014 22:50:01 +0100 Subject: [PATCH 181/495] audio: Remove min duration code from scanner. The local scanner now handles this itself by looking at the duration. --- mopidy/audio/scan.py | 18 +++++------------- mopidy/local/commands.py | 19 ++++++++++++------- mopidy/stream/actor.py | 2 +- tests/audio/test_scan.py | 33 +++++++++++++++++---------------- 4 files changed, 35 insertions(+), 37 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 5de046ce..8a66ab01 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -18,13 +18,10 @@ class Scanner(object): :param timeout: timeout for scanning a URI in ms :type event: int - :param min_duration: minimum duration of scanned URI in ms, -1 for all. - :type event: int """ - def __init__(self, timeout=1000, min_duration=100): + def __init__(self, timeout=1000): self._timeout_ms = timeout - self._min_duration_ms = min_duration sink = gst.element_factory_make('fakesink') @@ -52,20 +49,15 @@ class Scanner(object): the tags we found and duration is the length of the URI in milliseconds, or :class:`None` if the URI has no duration. """ + tags, duration = None, None try: self._setup(uri) - tags = self._collect() # Ensure collect before queries. + tags = self._collect() duration = self._query_duration() finally: self._reset() - if self._min_duration_ms is None: - return tags, duration - elif duration >= self._min_duration_ms: - return tags, duration - - raise exceptions.ScannerError('Rejecting file with less than %dms ' - 'audio data.' % self._min_duration_ms) + return tags, duration def _setup(self, uri): """Primes the pipeline for collection.""" @@ -80,7 +72,7 @@ class Scanner(object): def _collect(self): """Polls for messages to collect data.""" start = time.time() - timeout_s = self._timeout_ms / 1000. + timeout_s = self._timeout_ms / 1000.0 tags = {} while time.time() - start < timeout_s: diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index d7765a41..7348d459 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -13,6 +13,8 @@ from mopidy.utils import path logger = logging.getLogger(__name__) +MIN_DURATION_MS = 100 # Shortest length of track to include. + def _get_library(args, config): libraries = dict((l.name, l) for l in args.registry['local:library']) @@ -128,13 +130,16 @@ class ScanCommand(commands.Command): relpath = translator.local_track_uri_to_path(uri, media_dir) file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) tags, duration = scanner.scan(file_uri) - # TODO: reuse mtime from above... - mtime = os.path.getmtime(os.path.join(media_dir, relpath)) - track = scan.tags_to_track(tags).copy( - uri=uri, length=duration, last_modified=mtime) - track = translator.add_musicbrainz_coverart_to_track(track) - library.add(track) - logger.debug('Added %s', track.uri) + if duration < MIN_DURATION_MS: + logger.warning('Failed %s: Track shorter than 100ms', uri) + else: + # TODO: reuse mtime from above... + mtime = os.path.getmtime(os.path.join(media_dir, relpath)) + track = scan.tags_to_track(tags).copy( + uri=uri, length=duration, last_modified=mtime) + track = translator.add_musicbrainz_coverart_to_track(track) + library.add(track) + logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: logger.warning('Failed %s: %s', uri, error) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 5a5b1a2f..f73f8798 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -31,7 +31,7 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): class StreamLibraryProvider(backend.LibraryProvider): def __init__(self, backend, timeout, blacklist): super(StreamLibraryProvider, self).__init__(backend) - self._scanner = scan.Scanner(min_duration=None, timeout=timeout) + self._scanner = scan.Scanner(timeout=timeout) self._blacklist_re = re.compile( r'^(%s)$' % '|'.join(fnmatch.translate(u) for u in blacklist)) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index d17a9f3d..1687b07c 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -277,14 +277,10 @@ class ScannerTest(unittest.TestCase): except exceptions.ScannerError as error: self.errors[key] = error - def check_tag(self, name, key, value): + def check(self, name, key, value): name = path_to_data_dir(name) self.assertEqual(self.tags[name][key], value) - def check_duration(self, name, value): - name = path_to_data_dir(name) - self.assertEqual(self.durations[name], value) - def test_tags_is_set(self): self.scan(self.find('scanner/simple')) self.assert_(self.tags) @@ -295,23 +291,26 @@ class ScannerTest(unittest.TestCase): def test_duration_is_set(self): self.scan(self.find('scanner/simple')) - self.check_duration('scanner/simple/song1.mp3', 4680) - self.check_duration('scanner/simple/song1.ogg', 4680) + + self.assertEqual( + self.durations[path_to_data_dir('scanner/simple/song1.mp3')], 4680) + self.assertEqual( + self.durations[path_to_data_dir('scanner/simple/song1.ogg')], 4680) def test_artist_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'artist', ['name']) - self.check_tag('scanner/simple/song1.ogg', 'artist', ['name']) + self.check('scanner/simple/song1.mp3', 'artist', ['name']) + self.check('scanner/simple/song1.ogg', 'artist', ['name']) def test_album_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'album', ['albumname']) - self.check_tag('scanner/simple/song1.ogg', 'album', ['albumname']) + self.check('scanner/simple/song1.mp3', 'album', ['albumname']) + self.check('scanner/simple/song1.ogg', 'album', ['albumname']) def test_track_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'title', ['trackname']) - self.check_tag('scanner/simple/song1.ogg', 'title', ['trackname']) + self.check('scanner/simple/song1.mp3', 'title', ['trackname']) + self.check('scanner/simple/song1.ogg', 'title', ['trackname']) def test_nonexistant_dir_does_not_fail(self): self.scan(self.find('scanner/does-not-exist')) @@ -323,11 +322,13 @@ class ScannerTest(unittest.TestCase): def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): self.scan([path_to_data_dir('scanner/example.log')]) - self.assert_(self.errors) + self.assertLess( + self.durations[path_to_data_dir('scanner/example.log')], 100) - def test_empty_wav_file_is_ignored(self): + def test_empty_wav_file(self): self.scan([path_to_data_dir('scanner/empty.wav')]) - self.assert_(self.errors) + self.assertEqual( + self.durations[path_to_data_dir('scanner/empty.wav')], 0) @unittest.SkipTest def test_song_without_time_is_handeled(self): From 6c62252919b3ad53a1f3b40f27c1bfbbdf9946af Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 17 Dec 2014 23:59:54 +0100 Subject: [PATCH 182/495] audio: Add tags changed event to audio. Current version simply emits the keys of the changed tags to the audio listener. Following change will add support for storing the actual data. --- mopidy/audio/actor.py | 8 ++++++++ mopidy/audio/dummy.py | 3 +++ mopidy/audio/listener.py | 18 ++++++++++++++++ tests/audio/test_actor.py | 40 +++++++++++++++++++++--------------- tests/audio/test_listener.py | 5 ++++- 5 files changed, 57 insertions(+), 17 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 190895dc..3bc62a29 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -303,6 +303,8 @@ class _Handler(object): self.on_warning(*msg.parse_warning()) elif msg.type == gst.MESSAGE_ASYNC_DONE: self.on_async_done() + elif msg.type == gst.MESSAGE_TAG: + self.on_tag(msg.parse_tag()) elif msg.type == gst.MESSAGE_ELEMENT: if gst.pbutils.is_missing_plugin_message(msg): self.on_missing_plugin(_get_missing_description(msg), @@ -387,6 +389,12 @@ class _Handler(object): def on_async_done(self): gst_logger.debug('Got async-done.') + def on_tag(self, taglist): + # TODO: store current tags and reset on stream changes. + tags = taglist.keys() + logger.debug('Audio event: tags_changed(tags=%r)', tags) + AudioListener.send('tags_changed', tags=tags) + def on_missing_plugin(self, msg): desc = gst.pbutils.missing_plugin_message_get_description(msg) debug = gst.pbutils.missing_plugin_message_get_installer_detail(msg) diff --git a/mopidy/audio/dummy.py b/mopidy/audio/dummy.py index f7fa9f0d..e67ebed2 100644 --- a/mopidy/audio/dummy.py +++ b/mopidy/audio/dummy.py @@ -91,6 +91,9 @@ class DummyAudio(pykka.ThreadingActor): AudioListener.send('state_changed', old_state=old_state, new_state=new_state, target_state=None) + if new_state == PlaybackState.PLAYING: + AudioListener.send('tags_changed', tags=[]) + return self._state_change_result def trigger_fake_playback_failure(self): diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 6beb4444..9961cf54 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -75,3 +75,21 @@ class AudioListener(listener.Listener): field or :class:`None` if this is a final state. """ pass + + def tags_changed(self, tags): + """ + Called whenever the current audio streams tags changes. + + This event signals that some track metadata has been updated. This can + be metadata such as artists, titles, organization, or details about the + actual audio such as bit-rates, numbers of channels etc. + + For the available tag keys please refer to GStreamer documenation for + tags. + + *MAY* be implemented by actor. + + :param tags: The tags that have just been updated. + :type tags: :class:`set` of strings + """ + pass diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index ab897595..4ae9de63 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -42,7 +42,7 @@ class BaseTest(unittest.TestCase): audio_class = audio.Audio - def setUp(self): + def setUp(self): # noqa config = { 'audio': { 'mixer': 'foomixer', @@ -57,7 +57,7 @@ class BaseTest(unittest.TestCase): self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) self.audio = self.audio_class.start(config=config, mixer=None).proxy() - def tearDown(self): + def tearDown(self): # noqa pykka.ActorRegistry.stop_all() def possibly_trigger_fake_playback_error(self): @@ -135,7 +135,7 @@ class AudioDummyTest(DummyMixin, AudioTest): @mock.patch.object(audio.AudioListener, 'send') class AudioEventTest(BaseTest): - def setUp(self): + def setUp(self): # noqa super(AudioEventTest, self).setUp() self.audio.enable_sync_handler().get() @@ -292,6 +292,14 @@ class AudioEventTest(BaseTest): call = mock.call('position_changed', position=2000) self.assertIn(call, send_mock.call_args_list) + def test_tags_changed_on_playback(self, send_mock): + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + self.audio.wait_for_state_change().get() + + send_mock.assert_any_call('tags_changed', tags=mock.ANY) + # Unlike the other events, having the state changed done is not # enough to ensure our event is called. So we setup a threading # event that we can wait for with a timeout while the track playback @@ -361,20 +369,20 @@ class AudioEventTest(BaseTest): if not done.wait(timeout=1.0): self.fail('EOS not received') - excepted = [ - ('position_changed', {'position': 0}), - ('stream_changed', {'uri': self.uris[0]}), - ('state_changed', {'old_state': PlaybackState.STOPPED, - 'new_state': PlaybackState.PLAYING, - 'target_state': None}), - ('position_changed', {'position': 0}), - ('stream_changed', {'uri': self.uris[1]}), - ('reached_end_of_stream', {})] - self.assertEqual(excepted, events) + # Check that both uris got played + self.assertIn(('stream_changed', {'uri': self.uris[0]}), events) + self.assertIn(('stream_changed', {'uri': self.uris[1]}), events) + + # Check that events counts check out. + keys = [k for k, v in events] + self.assertEqual(2, keys.count('stream_changed')) + self.assertEqual(2, keys.count('position_changed')) + self.assertEqual(1, keys.count('state_changed')) + self.assertEqual(1, keys.count('reached_end_of_stream')) class AudioDummyEventTest(DummyMixin, AudioEventTest): - pass + """Exercise the AudioEventTest against our mock audio classes.""" # TODO: move to mixer tests... @@ -399,7 +407,7 @@ class MixerTest(BaseTest): class AudioStateTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa self.audio = audio.Audio(config=None, mixer=None) def test_state_starts_as_stopped(self): @@ -444,7 +452,7 @@ class AudioStateTest(unittest.TestCase): class AudioBufferingTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa self.audio = audio.Audio(config=None, mixer=None) self.audio._playbin = mock.Mock(spec=['set_state']) diff --git a/tests/audio/test_listener.py b/tests/audio/test_listener.py index 08b03e6c..6b78ecb0 100644 --- a/tests/audio/test_listener.py +++ b/tests/audio/test_listener.py @@ -8,7 +8,7 @@ from mopidy import audio class AudioListenerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa self.listener = audio.AudioListener() def test_on_event_forwards_to_specific_handler(self): @@ -32,3 +32,6 @@ class AudioListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_position_changed(self): self.listener.position_changed(None) + + def test_listener_has_default_impl_for_tags_changed(self): + self.listener.tags_changed([]) From bc347f16508ea5e3dcf9305f6b840e962b6cdecd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 18 Dec 2014 22:45:50 +0100 Subject: [PATCH 183/495] audio: Fix minor typo in a debug log message --- mopidy/audio/actor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 3bc62a29..9ec85f4c 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -409,7 +409,7 @@ class _Handler(object): # required helper installed? def on_new_segment(self, update, rate, format_, start, stop, position): - gst_logger.debug('Got new-segment event: update=%s rate=%s format=%s' + gst_logger.debug('Got new-segment event: update=%s rate=%s format=%s ' 'start=%s stop=%s position=%s', update, rate, format_.value_name, start, stop, position) position_ms = position // gst.MSECOND From 983148a9a42cb110de80574801aff532ce917e7d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 18 Dec 2014 23:25:24 +0100 Subject: [PATCH 184/495] audio: Start storing the tags we find in audio Adds a new get_currents_tags method for fetching the full set of current tags. There are still some untested cases for this, and I also suspect we still want some API refinements one core starts using this. --- mopidy/audio/actor.py | 29 +++++++++++++++++--- mopidy/audio/dummy.py | 9 ++++++- tests/audio/test_actor.py | 56 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 9ec85f4c..63c6a80b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -372,6 +372,7 @@ class _Handler(object): def on_end_of_stream(self): gst_logger.debug('Got end-of-stream message.') logger.debug('Audio event: reached_end_of_stream()') + self._audio._tags = {} AudioListener.send('reached_end_of_stream') def on_error(self, error, debug): @@ -390,10 +391,10 @@ class _Handler(object): gst_logger.debug('Got async-done.') def on_tag(self, taglist): - # TODO: store current tags and reset on stream changes. - tags = taglist.keys() - logger.debug('Audio event: tags_changed(tags=%r)', tags) - AudioListener.send('tags_changed', tags=tags) + tags = utils.convert_taglist(taglist) + self._audio._tags.update(tags) + logger.debug('Audio event: tags_changed(tags=%r)', tags.keys()) + AudioListener.send('tags_changed', tags=tags.keys()) def on_missing_plugin(self, msg): desc = gst.pbutils.missing_plugin_message_get_description(msg) @@ -440,6 +441,7 @@ class Audio(pykka.ThreadingActor): self._config = config self._target_state = gst.STATE_NULL self._buffering = False + self._tags = {} self._playbin = None self._outputs = None @@ -546,6 +548,7 @@ class Audio(pykka.ThreadingActor): :param uri: the URI to play :type uri: string """ + self._tags = {} # TODO: add test for this somehow self._playbin.set_property('uri', uri) def set_appsrc( @@ -733,6 +736,7 @@ class Audio(pykka.ThreadingActor): # of faking it in the message handling when result=OK return True + # TODO: bake this into setup appsrc perhaps? def set_metadata(self, track): """ Set track metadata for currently playing song. @@ -763,5 +767,22 @@ class Audio(pykka.ThreadingActor): taglist[gst.TAG_ALBUM] = track.album.name event = gst.event_new_tag(taglist) + # TODO: check if we get this back on our own bus? self._playbin.send_event(event) gst_logger.debug('Sent tag event: track=%s', track.uri) + + def get_current_tags(self): + """ + Get the currently playing media's tags. + + If no tags have been found, or nothing is playing this returns an empty + dictionary. For each set of tags we collect a tags_changed event is + emitted with the keys of the changes tags. After such calls users may + call this function to get the updated values. + + :rtype: {key: [values]} dict for the current media. + """ + # TODO: should this be a (deep) copy? most likely yes + # TODO: should we return None when stopped? + # TODO: support only fetching keys we care about? + return self._tags diff --git a/mopidy/audio/dummy.py b/mopidy/audio/dummy.py index e67ebed2..95b9d0fb 100644 --- a/mopidy/audio/dummy.py +++ b/mopidy/audio/dummy.py @@ -21,9 +21,11 @@ class DummyAudio(pykka.ThreadingActor): self._callback = None self._uri = None self._state_change_result = True + self._tags = {} def set_uri(self, uri): assert self._uri is None, 'prepare change not called before set' + self._tags = {} self._uri = uri def set_appsrc(self, *args, **kwargs): @@ -66,6 +68,9 @@ class DummyAudio(pykka.ThreadingActor): def set_metadata(self, track): pass + def get_current_tags(self): + return self._tags + def set_about_to_finish_callback(self, callback): self._callback = callback @@ -92,7 +97,8 @@ class DummyAudio(pykka.ThreadingActor): new_state=new_state, target_state=None) if new_state == PlaybackState.PLAYING: - AudioListener.send('tags_changed', tags=[]) + self._tags['audio-codec'] = [u'fake info...'] + AudioListener.send('tags_changed', tags=['audio-codec']) return self._state_change_result @@ -107,6 +113,7 @@ class DummyAudio(pykka.ThreadingActor): self._callback() if not self._uri or not self._callback: + self._tags = {} AudioListener.send('reached_end_of_stream') else: AudioListener.send('position_changed', position=0) diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 4ae9de63..f77505b7 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -338,7 +338,7 @@ class AudioEventTest(BaseTest): if not event.wait(timeout=1.0): self.fail('End of stream not reached within deadline') - # Make sure that gapless really works: + self.assertFalse(self.audio.get_current_tags().get()) def test_gapless(self, send_mock): uris = self.uris[1:] @@ -380,6 +380,60 @@ class AudioEventTest(BaseTest): self.assertEqual(1, keys.count('state_changed')) self.assertEqual(1, keys.count('reached_end_of_stream')) + # TODO: test tag states within gaples + + def test_current_tags_are_blank_to_begin_with(self, send_mock): + self.assertFalse(self.audio.get_current_tags().get()) + + def test_current_tags_blank_after_end_of_stream(self, send_mock): + done = threading.Event() + + def send(name, **kwargs): + if name == 'reached_end_of_stream': + done.set() + + send_mock.side_effect = send + + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + + self.possibly_trigger_fake_about_to_finish() + self.audio.wait_for_state_change().get() + + if not done.wait(timeout=1.0): + self.fail('EOS not received') + + self.assertFalse(self.audio.get_current_tags().get()) + + def test_current_tags_stored(self, send_mock): + done = threading.Event() + tags = [] + + def callback(): + tags.append(self.audio.get_current_tags().get()) + + def send(name, **kwargs): + if name == 'reached_end_of_stream': + done.set() + + send_mock.side_effect = send + self.audio.set_about_to_finish_callback(callback).get() + + self.audio.prepare_change() + self.audio.set_uri(self.uris[0]) + self.audio.start_playback() + + self.possibly_trigger_fake_about_to_finish() + self.audio.wait_for_state_change().get() + + if not done.wait(timeout=1.0): + self.fail('EOS not received') + + self.assertTrue(tags[0]) + + # TODO: test that we reset when we expect between songs + class AudioDummyEventTest(DummyMixin, AudioEventTest): """Exercise the AudioEventTest against our mock audio classes.""" From 9be788b12909ac9606473f73812db9d06ad6e4a4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 18 Dec 2014 23:36:56 +0100 Subject: [PATCH 185/495] audio: Move tags to track conversion to audio utils --- mopidy/audio/scan.py | 56 --------- mopidy/audio/test_utils.py | 245 +++++++++++++++++++++++++++++++++++++ mopidy/audio/utils.py | 63 ++++++++++ mopidy/local/commands.py | 4 +- mopidy/stream/actor.py | 4 +- tests/audio/test_scan.py | 240 ------------------------------------ 6 files changed, 312 insertions(+), 300 deletions(-) create mode 100644 mopidy/audio/test_utils.py diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 8a66ab01..2cf8f493 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -8,7 +8,6 @@ import gst # noqa from mopidy import exceptions from mopidy.audio import utils -from mopidy.models import Album, Artist, Track from mopidy.utils import encoding @@ -110,58 +109,3 @@ class Scanner(object): return None else: return duration // gst.MSECOND - - -def _artists(tags, artist_name, artist_id=None): - # Name missing, don't set artist - if not tags.get(artist_name): - return None - # One artist name and id, provide artist with id. - if len(tags[artist_name]) == 1 and artist_id in tags: - return [Artist(name=tags[artist_name][0], - musicbrainz_id=tags[artist_id][0])] - # Multiple artist, provide artists without id. - return [Artist(name=name) for name in tags[artist_name]] - - -def tags_to_track(tags): - album_kwargs = {} - track_kwargs = {} - - track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER) - track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER) - track_kwargs['artists'] = _artists( - tags, gst.TAG_ARTIST, 'musicbrainz-artistid') - album_kwargs['artists'] = _artists( - tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid') - - track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, [])) - track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, [])) - if not track_kwargs['name']: - track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, [])) - - track_kwargs['comment'] = '; '.join(tags.get('comment', [])) - if not track_kwargs['comment']: - track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, [])) - if not track_kwargs['comment']: - track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, [])) - - track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0] - track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0] - track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0] - track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0] - - album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0] - album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0] - album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] - album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] - - if tags.get(gst.TAG_DATE) and tags.get(gst.TAG_DATE)[0]: - track_kwargs['date'] = tags[gst.TAG_DATE][0].isoformat() - - # Clear out any empty values we found - track_kwargs = {k: v for k, v in track_kwargs.items() if v} - album_kwargs = {k: v for k, v in album_kwargs.items() if v} - - track_kwargs['album'] = Album(**album_kwargs) - return Track(**track_kwargs) diff --git a/mopidy/audio/test_utils.py b/mopidy/audio/test_utils.py new file mode 100644 index 00000000..fd71f38e --- /dev/null +++ b/mopidy/audio/test_utils.py @@ -0,0 +1,245 @@ +from __future__ import absolute_import, unicode_literals + +import datetime +import unittest + +from mopidy.audio import utils +from mopidy.models import Album, Artist, Track + + +# TODO: keep ids without name? +class TagsToTrackTest(unittest.TestCase): + def setUp(self): # noqa + self.tags = { + 'album': ['album'], + 'track-number': [1], + 'artist': ['artist'], + 'composer': ['composer'], + 'performer': ['performer'], + 'album-artist': ['albumartist'], + 'title': ['track'], + 'track-count': [2], + 'album-disc-number': [2], + 'album-disc-count': [3], + 'date': [datetime.date(2006, 1, 1,)], + 'container-format': ['ID3 tag'], + 'genre': ['genre'], + 'comment': ['comment'], + 'musicbrainz-trackid': ['trackid'], + 'musicbrainz-albumid': ['albumid'], + 'musicbrainz-artistid': ['artistid'], + 'musicbrainz-albumartistid': ['albumartistid'], + 'bitrate': [1000], + } + + artist = Artist(name='artist', musicbrainz_id='artistid') + composer = Artist(name='composer') + performer = Artist(name='performer') + albumartist = Artist(name='albumartist', + musicbrainz_id='albumartistid') + + album = Album(name='album', num_tracks=2, num_discs=3, + musicbrainz_id='albumid', artists=[albumartist]) + + self.track = Track(name='track', date='2006-01-01', + genre='genre', track_no=1, disc_no=2, + comment='comment', musicbrainz_id='trackid', + album=album, bitrate=1000, artists=[artist], + composers=[composer], performers=[performer]) + + def check(self, expected): + actual = utils.convert_tags_to_track(self.tags) + self.assertEqual(expected, actual) + + def test_track(self): + self.check(self.track) + + def test_missing_track_no(self): + del self.tags['track-number'] + self.check(self.track.copy(track_no=None)) + + def test_multiple_track_no(self): + self.tags['track-number'].append(9) + self.check(self.track) + + def test_missing_track_disc_no(self): + del self.tags['album-disc-number'] + self.check(self.track.copy(disc_no=None)) + + def test_multiple_track_disc_no(self): + self.tags['album-disc-number'].append(9) + self.check(self.track) + + def test_missing_track_name(self): + del self.tags['title'] + self.check(self.track.copy(name=None)) + + def test_multiple_track_name(self): + self.tags['title'] = ['name1', 'name2'] + self.check(self.track.copy(name='name1; name2')) + + def test_missing_track_musicbrainz_id(self): + del self.tags['musicbrainz-trackid'] + self.check(self.track.copy(musicbrainz_id=None)) + + def test_multiple_track_musicbrainz_id(self): + self.tags['musicbrainz-trackid'].append('id') + self.check(self.track) + + def test_missing_track_bitrate(self): + del self.tags['bitrate'] + self.check(self.track.copy(bitrate=None)) + + def test_multiple_track_bitrate(self): + self.tags['bitrate'].append(1234) + self.check(self.track) + + def test_missing_track_genre(self): + del self.tags['genre'] + self.check(self.track.copy(genre=None)) + + def test_multiple_track_genre(self): + self.tags['genre'] = ['genre1', 'genre2'] + self.check(self.track.copy(genre='genre1; genre2')) + + def test_missing_track_date(self): + del self.tags['date'] + self.check(self.track.copy(date=None)) + + def test_multiple_track_date(self): + self.tags['date'].append(datetime.date(2030, 1, 1)) + self.check(self.track) + + def test_missing_track_comment(self): + del self.tags['comment'] + self.check(self.track.copy(comment=None)) + + def test_multiple_track_comment(self): + self.tags['comment'] = ['comment1', 'comment2'] + self.check(self.track.copy(comment='comment1; comment2')) + + def test_missing_track_artist_name(self): + del self.tags['artist'] + self.check(self.track.copy(artists=[])) + + def test_multiple_track_artist_name(self): + self.tags['artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + self.check(self.track.copy(artists=artists)) + + def test_missing_track_artist_musicbrainz_id(self): + del self.tags['musicbrainz-artistid'] + artist = list(self.track.artists)[0].copy(musicbrainz_id=None) + self.check(self.track.copy(artists=[artist])) + + def test_multiple_track_artist_musicbrainz_id(self): + self.tags['musicbrainz-artistid'].append('id') + self.check(self.track) + + def test_missing_track_composer_name(self): + del self.tags['composer'] + self.check(self.track.copy(composers=[])) + + def test_multiple_track_composer_name(self): + self.tags['composer'] = ['composer1', 'composer2'] + composers = [Artist(name='composer1'), Artist(name='composer2')] + self.check(self.track.copy(composers=composers)) + + def test_missing_track_performer_name(self): + del self.tags['performer'] + self.check(self.track.copy(performers=[])) + + def test_multiple_track_performe_name(self): + self.tags['performer'] = ['performer1', 'performer2'] + performers = [Artist(name='performer1'), Artist(name='performer2')] + self.check(self.track.copy(performers=performers)) + + def test_missing_album_name(self): + del self.tags['album'] + album = self.track.album.copy(name=None) + self.check(self.track.copy(album=album)) + + def test_multiple_album_name(self): + self.tags['album'].append('album2') + self.check(self.track) + + def test_missing_album_musicbrainz_id(self): + del self.tags['musicbrainz-albumid'] + album = self.track.album.copy(musicbrainz_id=None, + images=[]) + self.check(self.track.copy(album=album)) + + def test_multiple_album_musicbrainz_id(self): + self.tags['musicbrainz-albumid'].append('id') + self.check(self.track) + + def test_missing_album_num_tracks(self): + del self.tags['track-count'] + album = self.track.album.copy(num_tracks=None) + self.check(self.track.copy(album=album)) + + def test_multiple_album_num_tracks(self): + self.tags['track-count'].append(9) + self.check(self.track) + + def test_missing_album_num_discs(self): + del self.tags['album-disc-count'] + album = self.track.album.copy(num_discs=None) + self.check(self.track.copy(album=album)) + + def test_multiple_album_num_discs(self): + self.tags['album-disc-count'].append(9) + self.check(self.track) + + def test_missing_album_artist_name(self): + del self.tags['album-artist'] + album = self.track.album.copy(artists=[]) + self.check(self.track.copy(album=album)) + + def test_multiple_album_artist_name(self): + self.tags['album-artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + album = self.track.album.copy(artists=artists) + self.check(self.track.copy(album=album)) + + def test_missing_album_artist_musicbrainz_id(self): + del self.tags['musicbrainz-albumartistid'] + albumartist = list(self.track.album.artists)[0] + albumartist = albumartist.copy(musicbrainz_id=None) + album = self.track.album.copy(artists=[albumartist]) + self.check(self.track.copy(album=album)) + + def test_multiple_album_artist_musicbrainz_id(self): + self.tags['musicbrainz-albumartistid'].append('id') + self.check(self.track) + + def test_stream_organization_track_name(self): + del self.tags['title'] + self.tags['organization'] = ['organization'] + self.check(self.track.copy(name='organization')) + + def test_multiple_organization_track_name(self): + del self.tags['title'] + self.tags['organization'] = ['organization1', 'organization2'] + self.check(self.track.copy(name='organization1; organization2')) + + # TODO: combine all comment types? + def test_stream_location_track_comment(self): + del self.tags['comment'] + self.tags['location'] = ['location'] + self.check(self.track.copy(comment='location')) + + def test_multiple_location_track_comment(self): + del self.tags['comment'] + self.tags['location'] = ['location1', 'location2'] + self.check(self.track.copy(comment='location1; location2')) + + def test_stream_copyright_track_comment(self): + del self.tags['comment'] + self.tags['copyright'] = ['copyright'] + self.check(self.track.copy(comment='copyright')) + + def test_multiple_copyright_track_comment(self): + del self.tags['comment'] + self.tags['copyright'] = ['copyright1', 'copyright2'] + self.check(self.track.copy(comment='copyright1; copyright2')) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 9ddd8494..107baecb 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -9,6 +9,7 @@ pygst.require('0.10') import gst # noqa from mopidy import compat +from mopidy.models import Album, Artist, Track logger = logging.getLogger(__name__) @@ -64,6 +65,68 @@ def supported_uri_schemes(uri_schemes): return supported_schemes +def _artists(tags, artist_name, artist_id=None): + # Name missing, don't set artist + if not tags.get(artist_name): + return None + # One artist name and id, provide artist with id. + if len(tags[artist_name]) == 1 and artist_id in tags: + return [Artist(name=tags[artist_name][0], + musicbrainz_id=tags[artist_id][0])] + # Multiple artist, provide artists without id. + return [Artist(name=name) for name in tags[artist_name]] + + +# TODO: split based on "stream" and "track" based conversion? i.e. handle data +# from radios in it's own helper instead? +def convert_tags_to_track(tags): + """Convert our normalized tags to a track. + + :param :class:`dict` tags: dictionary of tag keys with a list of values + :rtype: :class:`mopidy.models.Track` + """ + album_kwargs = {} + track_kwargs = {} + + track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER) + track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER) + track_kwargs['artists'] = _artists( + tags, gst.TAG_ARTIST, 'musicbrainz-artistid') + album_kwargs['artists'] = _artists( + tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid') + + track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, [])) + track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, [])) + if not track_kwargs['name']: + track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, [])) + + track_kwargs['comment'] = '; '.join(tags.get('comment', [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, [])) + + track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0] + track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0] + track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0] + track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0] + + album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0] + album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0] + album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] + album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] + + if tags.get(gst.TAG_DATE) and tags.get(gst.TAG_DATE)[0]: + track_kwargs['date'] = tags[gst.TAG_DATE][0].isoformat() + + # Clear out any empty values we found + track_kwargs = {k: v for k, v in track_kwargs.items() if v} + album_kwargs = {k: v for k, v in album_kwargs.items() if v} + + track_kwargs['album'] = Album(**album_kwargs) + return Track(**track_kwargs) + + def convert_taglist(taglist): """Convert a :class:`gst.Taglist` to plain python types. diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 7348d459..7355b1a1 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -6,7 +6,7 @@ import os import time from mopidy import commands, compat, exceptions -from mopidy.audio import scan +from mopidy.audio import scan, utils from mopidy.local import translator from mopidy.utils import path @@ -135,7 +135,7 @@ class ScanCommand(commands.Command): else: # TODO: reuse mtime from above... mtime = os.path.getmtime(os.path.join(media_dir, relpath)) - track = scan.tags_to_track(tags).copy( + track = utils.convert_tags_to_track(tags).copy( uri=uri, length=duration, last_modified=mtime) track = translator.add_musicbrainz_coverart_to_track(track) library.add(track) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index f73f8798..96d405e6 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -8,7 +8,7 @@ import urlparse import pykka from mopidy import audio as audio_lib, backend, exceptions -from mopidy.audio import scan +from mopidy.audio import scan, utils from mopidy.models import Track logger = logging.getLogger(__name__) @@ -45,7 +45,7 @@ class StreamLibraryProvider(backend.LibraryProvider): try: tags, duration = self._scanner.scan(uri) - track = scan.tags_to_track(tags).copy(uri=uri, length=duration) + track = utils.tags_to_track(tags).copy(uri=uri, length=duration) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) track = Track(uri=uri) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 1687b07c..97406c41 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, unicode_literals -import datetime import os import unittest @@ -9,250 +8,11 @@ gobject.threads_init() from mopidy import exceptions from mopidy.audio import scan -from mopidy.models import Album, Artist, Track from mopidy.utils import path as path_lib from tests import path_to_data_dir -# TODO: keep ids without name? -class TagsToTrackTest(unittest.TestCase): - def setUp(self): # noqa - self.tags = { - 'album': ['album'], - 'track-number': [1], - 'artist': ['artist'], - 'composer': ['composer'], - 'performer': ['performer'], - 'album-artist': ['albumartist'], - 'title': ['track'], - 'track-count': [2], - 'album-disc-number': [2], - 'album-disc-count': [3], - 'date': [datetime.date(2006, 1, 1,)], - 'container-format': ['ID3 tag'], - 'genre': ['genre'], - 'comment': ['comment'], - 'musicbrainz-trackid': ['trackid'], - 'musicbrainz-albumid': ['albumid'], - 'musicbrainz-artistid': ['artistid'], - 'musicbrainz-albumartistid': ['albumartistid'], - 'bitrate': [1000], - } - - artist = Artist(name='artist', musicbrainz_id='artistid') - composer = Artist(name='composer') - performer = Artist(name='performer') - albumartist = Artist(name='albumartist', - musicbrainz_id='albumartistid') - - album = Album(name='album', num_tracks=2, num_discs=3, - musicbrainz_id='albumid', artists=[albumartist]) - - self.track = Track(name='track', date='2006-01-01', - genre='genre', track_no=1, disc_no=2, - comment='comment', musicbrainz_id='trackid', - album=album, bitrate=1000, artists=[artist], - composers=[composer], performers=[performer]) - - def check(self, expected): - actual = scan.tags_to_track(self.tags) - self.assertEqual(expected, actual) - - def test_track(self): - self.check(self.track) - - def test_missing_track_no(self): - del self.tags['track-number'] - self.check(self.track.copy(track_no=None)) - - def test_multiple_track_no(self): - self.tags['track-number'].append(9) - self.check(self.track) - - def test_missing_track_disc_no(self): - del self.tags['album-disc-number'] - self.check(self.track.copy(disc_no=None)) - - def test_multiple_track_disc_no(self): - self.tags['album-disc-number'].append(9) - self.check(self.track) - - def test_missing_track_name(self): - del self.tags['title'] - self.check(self.track.copy(name=None)) - - def test_multiple_track_name(self): - self.tags['title'] = ['name1', 'name2'] - self.check(self.track.copy(name='name1; name2')) - - def test_missing_track_musicbrainz_id(self): - del self.tags['musicbrainz-trackid'] - self.check(self.track.copy(musicbrainz_id=None)) - - def test_multiple_track_musicbrainz_id(self): - self.tags['musicbrainz-trackid'].append('id') - self.check(self.track) - - def test_missing_track_bitrate(self): - del self.tags['bitrate'] - self.check(self.track.copy(bitrate=None)) - - def test_multiple_track_bitrate(self): - self.tags['bitrate'].append(1234) - self.check(self.track) - - def test_missing_track_genre(self): - del self.tags['genre'] - self.check(self.track.copy(genre=None)) - - def test_multiple_track_genre(self): - self.tags['genre'] = ['genre1', 'genre2'] - self.check(self.track.copy(genre='genre1; genre2')) - - def test_missing_track_date(self): - del self.tags['date'] - self.check(self.track.copy(date=None)) - - def test_multiple_track_date(self): - self.tags['date'].append(datetime.date(2030, 1, 1)) - self.check(self.track) - - def test_missing_track_comment(self): - del self.tags['comment'] - self.check(self.track.copy(comment=None)) - - def test_multiple_track_comment(self): - self.tags['comment'] = ['comment1', 'comment2'] - self.check(self.track.copy(comment='comment1; comment2')) - - def test_missing_track_artist_name(self): - del self.tags['artist'] - self.check(self.track.copy(artists=[])) - - def test_multiple_track_artist_name(self): - self.tags['artist'] = ['name1', 'name2'] - artists = [Artist(name='name1'), Artist(name='name2')] - self.check(self.track.copy(artists=artists)) - - def test_missing_track_artist_musicbrainz_id(self): - del self.tags['musicbrainz-artistid'] - artist = list(self.track.artists)[0].copy(musicbrainz_id=None) - self.check(self.track.copy(artists=[artist])) - - def test_multiple_track_artist_musicbrainz_id(self): - self.tags['musicbrainz-artistid'].append('id') - self.check(self.track) - - def test_missing_track_composer_name(self): - del self.tags['composer'] - self.check(self.track.copy(composers=[])) - - def test_multiple_track_composer_name(self): - self.tags['composer'] = ['composer1', 'composer2'] - composers = [Artist(name='composer1'), Artist(name='composer2')] - self.check(self.track.copy(composers=composers)) - - def test_missing_track_performer_name(self): - del self.tags['performer'] - self.check(self.track.copy(performers=[])) - - def test_multiple_track_performe_name(self): - self.tags['performer'] = ['performer1', 'performer2'] - performers = [Artist(name='performer1'), Artist(name='performer2')] - self.check(self.track.copy(performers=performers)) - - def test_missing_album_name(self): - del self.tags['album'] - album = self.track.album.copy(name=None) - self.check(self.track.copy(album=album)) - - def test_multiple_album_name(self): - self.tags['album'].append('album2') - self.check(self.track) - - def test_missing_album_musicbrainz_id(self): - del self.tags['musicbrainz-albumid'] - album = self.track.album.copy(musicbrainz_id=None, - images=[]) - self.check(self.track.copy(album=album)) - - def test_multiple_album_musicbrainz_id(self): - self.tags['musicbrainz-albumid'].append('id') - self.check(self.track) - - def test_missing_album_num_tracks(self): - del self.tags['track-count'] - album = self.track.album.copy(num_tracks=None) - self.check(self.track.copy(album=album)) - - def test_multiple_album_num_tracks(self): - self.tags['track-count'].append(9) - self.check(self.track) - - def test_missing_album_num_discs(self): - del self.tags['album-disc-count'] - album = self.track.album.copy(num_discs=None) - self.check(self.track.copy(album=album)) - - def test_multiple_album_num_discs(self): - self.tags['album-disc-count'].append(9) - self.check(self.track) - - def test_missing_album_artist_name(self): - del self.tags['album-artist'] - album = self.track.album.copy(artists=[]) - self.check(self.track.copy(album=album)) - - def test_multiple_album_artist_name(self): - self.tags['album-artist'] = ['name1', 'name2'] - artists = [Artist(name='name1'), Artist(name='name2')] - album = self.track.album.copy(artists=artists) - self.check(self.track.copy(album=album)) - - def test_missing_album_artist_musicbrainz_id(self): - del self.tags['musicbrainz-albumartistid'] - albumartist = list(self.track.album.artists)[0] - albumartist = albumartist.copy(musicbrainz_id=None) - album = self.track.album.copy(artists=[albumartist]) - self.check(self.track.copy(album=album)) - - def test_multiple_album_artist_musicbrainz_id(self): - self.tags['musicbrainz-albumartistid'].append('id') - self.check(self.track) - - def test_stream_organization_track_name(self): - del self.tags['title'] - self.tags['organization'] = ['organization'] - self.check(self.track.copy(name='organization')) - - def test_multiple_organization_track_name(self): - del self.tags['title'] - self.tags['organization'] = ['organization1', 'organization2'] - self.check(self.track.copy(name='organization1; organization2')) - - # TODO: combine all comment types? - def test_stream_location_track_comment(self): - del self.tags['comment'] - self.tags['location'] = ['location'] - self.check(self.track.copy(comment='location')) - - def test_multiple_location_track_comment(self): - del self.tags['comment'] - self.tags['location'] = ['location1', 'location2'] - self.check(self.track.copy(comment='location1; location2')) - - def test_stream_copyright_track_comment(self): - del self.tags['comment'] - self.tags['copyright'] = ['copyright'] - self.check(self.track.copy(comment='copyright')) - - def test_multiple_copyright_track_comment(self): - del self.tags['comment'] - self.tags['copyright'] = ['copyright1', 'copyright2'] - self.check(self.track.copy(comment='copyright1; copyright2')) - - class ScannerTest(unittest.TestCase): def setUp(self): # noqa self.errors = {} From b6cf86c6a2060d5a79dce913ccd7e439b00822e6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 19 Dec 2014 22:37:48 +0100 Subject: [PATCH 186/495] startup: Log backend and frontend startup times. Allows us to debug cases where a "bad" extension is blocking the startup. In there future we might also warning log extension that take longer than some threshold to help find these cases. --- mopidy/commands.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/mopidy/commands.py b/mopidy/commands.py index 4b00a685..fecabe98 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -2,9 +2,11 @@ from __future__ import absolute_import, print_function, unicode_literals import argparse import collections +import contextlib import logging import os import sys +import time import glib @@ -63,6 +65,13 @@ class _HelpAction(argparse.Action): raise _HelpError() +@contextlib.contextmanager +def _startup_timer(name): + start = time.time() + yield + logger.debug('%s startup took %dms', name, (time.time() - start) * 1000) + + class Command(object): """Command parser and runner for building trees of commands. @@ -339,8 +348,9 @@ class RootCommand(Command): backends = [] for backend_class in backend_classes: try: - backend = backend_class.start( - config=config, audio=audio).proxy() + with _startup_timer(backend_class.__name__): + backend = backend_class.start( + config=config, audio=audio).proxy() backends.append(backend) except exceptions.BackendError as exc: logger.error( @@ -361,7 +371,8 @@ class RootCommand(Command): for frontend_class in frontend_classes: try: - frontend_class.start(config=config, core=core) + with _startup_timer(frontend_class.__name__): + frontend_class.start(config=config, core=core) except exceptions.FrontendError as exc: logger.error( 'Frontend (%s) initialization error: %s', From 9a2f8a3e4f5ea80ba0e6c497acfc8bee40a04375 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 20 Dec 2014 21:32:08 +0100 Subject: [PATCH 187/495] http: Log errors instead of dying for HTTP startup. --- docs/changelog.rst | 5 +++++ mopidy/http/actor.py | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a0371840..36b81f25 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -69,6 +69,11 @@ v0.20.0 (UNRELEASED) make sense for a server such as Mopidy. Currently the only way to find out if it is in use and will be missed is to go ahead and remove it. +**HTTP** + +- Log error while starting HTTP apps instead letting the HTTP server thread + die. (Fixes: :issue:`875`) + v0.19.5 (UNRELEASED) ==================== diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index d37a5672..200ef833 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -129,11 +129,16 @@ class HttpServer(threading.Thread): def _get_app_request_handlers(self): result = [] for app in self.apps: + try: + request_handlers = app['factory'](self.config, self.core) + except Exception: + logger.exception('Loading %s failed.', app['name']) + continue + result.append(( r'/%s' % app['name'], handlers.AddSlashHandler )) - request_handlers = app['factory'](self.config, self.core) for handler in request_handlers: handler = list(handler) handler[0] = '/%s%s' % (app['name'], handler[0]) From 32da1cb8e96fedef36221e6a744d258217d67037 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 21 Dec 2014 21:43:48 +0100 Subject: [PATCH 188/495] local: Use MIN_DURATION_MS in log message --- mopidy/local/commands.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 7355b1a1..33a78461 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -131,13 +131,15 @@ class ScanCommand(commands.Command): file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) tags, duration = scanner.scan(file_uri) if duration < MIN_DURATION_MS: - logger.warning('Failed %s: Track shorter than 100ms', uri) + logger.warning('Failed %s: Track shorter than %dms', + uri, MIN_DURATION_MS) else: # TODO: reuse mtime from above... mtime = os.path.getmtime(os.path.join(media_dir, relpath)) track = utils.convert_tags_to_track(tags).copy( uri=uri, length=duration, last_modified=mtime) track = translator.add_musicbrainz_coverart_to_track(track) + # TODO: add tags to call if library supports it. library.add(track) logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: From 7b36a598bb51b74061c5a63eaceb7e91042d1d4b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 21 Dec 2014 21:48:09 +0100 Subject: [PATCH 189/495] review: Fix typos found in PR#915 feedback --- mopidy/audio/listener.py | 4 ++-- mopidy/audio/utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 9961cf54..9472227f 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -78,13 +78,13 @@ class AudioListener(listener.Listener): def tags_changed(self, tags): """ - Called whenever the current audio streams tags changes. + Called whenever the current audio stream's tags change. This event signals that some track metadata has been updated. This can be metadata such as artists, titles, organization, or details about the actual audio such as bit-rates, numbers of channels etc. - For the available tag keys please refer to GStreamer documenation for + For the available tag keys please refer to GStreamer documentation for tags. *MAY* be implemented by actor. diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 107baecb..b562f759 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -128,7 +128,7 @@ def convert_tags_to_track(tags): def convert_taglist(taglist): - """Convert a :class:`gst.Taglist` to plain python types. + """Convert a :class:`gst.Taglist` to plain Python types. Knows how to convert: - Dates From 77dc046efd5d40bf1cf8525e4d15c74ffc6e5ca5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 21 Dec 2014 21:49:51 +0100 Subject: [PATCH 190/495] audio: Fix rST formatting in docstring --- mopidy/audio/utils.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index b562f759..369e74b6 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -131,11 +131,12 @@ def convert_taglist(taglist): """Convert a :class:`gst.Taglist` to plain Python types. Knows how to convert: - - Dates - - Buffers - - Numbers - - Strings - - Booleans + + - Dates + - Buffers + - Numbers + - Strings + - Booleans Unknown types will be ignored and debug logged. Tag keys are all strings defined by GStreamer. From 9b9cdc3ade91e63db6e40ec6f2009062acce2281 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 22 Dec 2014 22:29:36 +0100 Subject: [PATCH 191/495] stream: Fix track conversion bug and add tests This adds basic checks for the library provider lookup: - Check that uri schemes are respected - Check that blacklisting and globbing works - Check uri successfully gets converted to a track --- mopidy/stream/actor.py | 3 ++- tests/stream/__init__.py | 0 tests/stream/test_library.py | 43 ++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/stream/__init__.py create mode 100644 tests/stream/test_library.py diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 96d405e6..9599d9d3 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -45,7 +45,8 @@ class StreamLibraryProvider(backend.LibraryProvider): try: tags, duration = self._scanner.scan(uri) - track = utils.tags_to_track(tags).copy(uri=uri, length=duration) + track = utils.convert_tags_to_track(tags).copy( + uri=uri, length=duration) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) track = Track(uri=uri) diff --git a/tests/stream/__init__.py b/tests/stream/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py new file mode 100644 index 00000000..d90610d2 --- /dev/null +++ b/tests/stream/test_library.py @@ -0,0 +1,43 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +import gobject +gobject.threads_init() + +import pygst +pygst.require('0.10') +import gst # noqa: pygst magic is needed to import correct gst + +import mock + +from mopidy.models import Album, Track +from mopidy.stream import actor +from mopidy.utils.path import path_to_uri + +from tests import path_to_data_dir + + +class LibraryProviderTest(unittest.TestCase): + def setUp(self): # noqa: ignore method must be lowercase + self.backend = mock.Mock() + self.backend.uri_schemes = ['file'] + self.uri = path_to_uri(path_to_data_dir('song1.wav')) + + def test_lookup_ignores_unknown_scheme(self): + library = actor.StreamLibraryProvider(self.backend, 1000, []) + self.assertFalse(library.lookup('http://example.com')) + + def test_lookup_respects_blacklist(self): + library = actor.StreamLibraryProvider(self.backend, 100, [self.uri]) + self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) + + def test_lookup_respects_blacklist_globbing(self): + blacklist = [path_to_uri(path_to_data_dir('')) + '*'] + library = actor.StreamLibraryProvider(self.backend, 100, blacklist) + self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) + + def test_lookup_converts_uri_metadata_to_track(self): + library = actor.StreamLibraryProvider(self.backend, 100, []) + self.assertEqual([Track(length=4406, uri=self.uri, album=Album())], + library.lookup(self.uri)) From 819680e0747c17c8ac2de34c6e2acf41ae093bc3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 22 Dec 2014 22:34:11 +0100 Subject: [PATCH 192/495] audio: Move utils test to tests folder --- {mopidy => tests}/audio/test_utils.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {mopidy => tests}/audio/test_utils.py (100%) diff --git a/mopidy/audio/test_utils.py b/tests/audio/test_utils.py similarity index 100% rename from mopidy/audio/test_utils.py rename to tests/audio/test_utils.py From 935a038405d01d2561d671308f81c6c186cb1229 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 22 Dec 2014 22:45:31 +0100 Subject: [PATCH 193/495] audio: Only add albums that have a name Fixes case where we could have an empty album. We could alternatively be more conservative and only limit to fully empty albums. But I think we only want ones with names anyway. --- mopidy/audio/utils.py | 5 ++++- tests/audio/test_utils.py | 5 +++-- tests/stream/test_library.py | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 369e74b6..84d80cc3 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -123,7 +123,10 @@ def convert_tags_to_track(tags): track_kwargs = {k: v for k, v in track_kwargs.items() if v} album_kwargs = {k: v for k, v in album_kwargs.items() if v} - track_kwargs['album'] = Album(**album_kwargs) + # Only bother with album if we have a name to show. + if album_kwargs.get('name'): + track_kwargs['album'] = Album(**album_kwargs) + return Track(**track_kwargs) diff --git a/tests/audio/test_utils.py b/tests/audio/test_utils.py index fd71f38e..b2028518 100644 --- a/tests/audio/test_utils.py +++ b/tests/audio/test_utils.py @@ -8,6 +8,8 @@ from mopidy.models import Album, Artist, Track # TODO: keep ids without name? +# TODO: current test is trying to test everything at once with a complete tags +# set, instead we might want to try with a minimal one making testing easier. class TagsToTrackTest(unittest.TestCase): def setUp(self): # noqa self.tags = { @@ -156,8 +158,7 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_album_name(self): del self.tags['album'] - album = self.track.album.copy(name=None) - self.check(self.track.copy(album=album)) + self.check(self.track.copy(album=None)) def test_multiple_album_name(self): self.tags['album'].append('album2') diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py index d90610d2..b660a2d4 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -11,7 +11,7 @@ import gst # noqa: pygst magic is needed to import correct gst import mock -from mopidy.models import Album, Track +from mopidy.models import Track from mopidy.stream import actor from mopidy.utils.path import path_to_uri @@ -39,5 +39,5 @@ class LibraryProviderTest(unittest.TestCase): def test_lookup_converts_uri_metadata_to_track(self): library = actor.StreamLibraryProvider(self.backend, 100, []) - self.assertEqual([Track(length=4406, uri=self.uri, album=Album())], + self.assertEqual([Track(length=4406, uri=self.uri)], library.lookup(self.uri)) From 60aec2dc95e2f58bfa998f5f0f99f2fbedde7ce6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 23 Dec 2014 22:39:37 +0100 Subject: [PATCH 194/495] docs: Update changelog with audio taglist changes etc --- docs/changelog.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index c59822ae..264f101c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -69,6 +69,25 @@ v0.20.0 (UNRELEASED) - Add foundation for trying to re-add multiple output support. + - Add internal helper for converting GStreamer data types to Python. + + - Move MusicBrainz coverart code out of audio and into local. + + - Reduce scope of audio scanner to just tags + duration. Mtime, uri and min + length handling are now outside of this class. + + - Update scanner to operate with milliseconds for duration. + +- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new tags + are found. + +- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current + tags of the playing media. + +- Move and rename helper for converting tags to tracks. + + - Helper now ignores albums without a name. + - Kill support for visualizers. Feature was originally added as a workaround for all the people asking for ncmpcpp visualizer support. And since we could get it almost for free thanks to GStreamer. But this feature didn't really ever @@ -76,6 +95,11 @@ v0.20.0 (UNRELEASED) it is in use and will be missed is to go ahead and remove it. +**Stream backend** + +- Add basic tests for the stream library provider. + + v0.19.5 (UNRELEASED) ==================== From f4c501a08f6daba08f04d54c8bbc37d94d46b7eb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 23 Dec 2014 23:25:47 +0100 Subject: [PATCH 195/495] local: Reuse the mtime we already found in local scan --- mopidy/local/commands.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 33a78461..d49ab8f8 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -73,9 +73,6 @@ class ScanCommand(commands.Command): library = _get_library(args, config) - uris_to_update = set() - uris_to_remove = set() - file_mtimes, file_errors = path.find_mtimes( media_dir, follow=config['local']['scan_follow_symlinks']) @@ -90,14 +87,19 @@ class ScanCommand(commands.Command): num_tracks = library.load() logger.info('Checking %d tracks from library.', num_tracks) + uris_to_update = set() + uris_to_remove = set() + uris_in_library = set() + for track in library.begin(): abspath = translator.local_track_uri_to_path(track.uri, media_dir) - mtime = file_mtimes.pop(abspath, None) + mtime = file_mtimes.get(abspath) if mtime is None: logger.debug('Missing file %s', track.uri) uris_to_remove.add(track.uri) elif mtime > track.last_modified: uris_to_update.add(track.uri) + uris_in_library.add(track.uri) logger.info('Removing %d missing tracks.', len(uris_to_remove)) for uri in uris_to_remove: @@ -107,12 +109,11 @@ class ScanCommand(commands.Command): relpath = os.path.relpath(abspath, media_dir) uri = translator.path_to_local_track_uri(relpath) - # TODO: move these to a "predicate" check in the finder? if b'/.' in relpath: logger.debug('Skipped %s: Hidden directory/file.', uri) elif relpath.lower().endswith(excluded_file_extensions): logger.debug('Skipped %s: File extension excluded.', uri) - else: + elif uri not in uris_in_library: uris_to_update.add(uri) logger.info( @@ -134,8 +135,7 @@ class ScanCommand(commands.Command): logger.warning('Failed %s: Track shorter than %dms', uri, MIN_DURATION_MS) else: - # TODO: reuse mtime from above... - mtime = os.path.getmtime(os.path.join(media_dir, relpath)) + mtime = file_mtimes.get(os.path.join(media_dir, relpath)) track = utils.convert_tags_to_track(tags).copy( uri=uri, length=duration, last_modified=mtime) track = translator.add_musicbrainz_coverart_to_track(track) From eba314588827d43c3997e7b9cd196c6c4a6cc8eb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 25 Dec 2014 22:58:44 +0100 Subject: [PATCH 196/495] core: Start marking some arguments and methods as internal per #870 --- mopidy/core/playback.py | 4 ++-- mopidy/core/tracklist.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 8a415257..ef3cc4b2 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -136,7 +136,7 @@ class PlaybackController(object): :param tl_track: track to change to :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track + track (default), -1 for previous track. **INTERNAL** :type on_error_step: int, -1 or 1 """ old_state = self.state @@ -217,7 +217,7 @@ class PlaybackController(object): :param tl_track: track to play :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track + track (default), -1 for previous track. **INTERNAL** :type on_error_step: int, -1 or 1 """ diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 48d20777..f9560a13 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -437,18 +437,18 @@ class TracklistController(object): return self._tl_tracks[start:end] def mark_playing(self, tl_track): - """Private method used by :class:`mopidy.core.PlaybackController`.""" + """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" if self.random and tl_track in self._shuffled: self._shuffled.remove(tl_track) def mark_unplayable(self, tl_track): - """Private method used by :class:`mopidy.core.PlaybackController`.""" + """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" logger.warning('Track is not playable: %s', tl_track.track.uri) if self.random and tl_track in self._shuffled: self._shuffled.remove(tl_track) def mark_played(self, tl_track): - """Private method used by :class:`mopidy.core.PlaybackController`.""" + """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" if self.consume and tl_track is not None: self.remove(tlid=[tl_track.tlid]) return True From 7d443c6cda70f85d14a4b24b13458dd817a15d69 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Dec 2014 13:08:17 +0100 Subject: [PATCH 197/495] Move {data => extra/desktop}/mopidy.desktop --- MANIFEST.in | 4 ++-- docs/clients/mpris.rst | 10 +++++----- {data => extra/desktop}/mopidy.desktop | 0 3 files changed, 7 insertions(+), 7 deletions(-) rename {data => extra/desktop}/mopidy.desktop (100%) diff --git a/MANIFEST.in b/MANIFEST.in index df9f8491..5a99b8b8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,11 +9,11 @@ include LICENSE include MANIFEST.in include tox.ini -recursive-include data * - recursive-include docs * prune docs/_build +recursive-include extra * + recursive-include mopidy *.conf recursive-include mopidy/http/data * diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index 650372e6..aef02566 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -30,11 +30,11 @@ menu, including the official Spotify player and Mopidy. If you install Mopidy from apt.mopidy.com, the sound menu should work out of the box. If you install Mopidy in any other way, you need to make sure that the -file located at ``data/mopidy.desktop`` in the Mopidy git repo is installed as -``/usr/share/applications/mopidy.desktop``, and that the properties ``TryExec`` -and ``Exec`` in the file points to an existing executable file, preferably your -Mopidy executable. If this isn't in place, the sound menu will not detect that -Mopidy is running. +file located at ``extra/desktop/mopidy.desktop`` in the Mopidy git repo is +installed as ``/usr/share/applications/mopidy.desktop``, and that the +properties ``TryExec`` and ``Exec`` in the file points to an existing +executable file, preferably your Mopidy executable. If this isn't in place, the +sound menu will not detect that Mopidy is running. Next, Mopidy's MPRIS frontend must be running for the sound menu to be able to control Mopidy. The frontend is enabled by default, so as long as you have all diff --git a/data/mopidy.desktop b/extra/desktop/mopidy.desktop similarity index 100% rename from data/mopidy.desktop rename to extra/desktop/mopidy.desktop From 243bf4acbc52f68b08506d57808fab15e9667c4d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Dec 2014 13:13:17 +0100 Subject: [PATCH 198/495] Add systemd service file Copied from Debian package --- extra/systemd/mopidy.service | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 extra/systemd/mopidy.service diff --git a/extra/systemd/mopidy.service b/extra/systemd/mopidy.service new file mode 100644 index 00000000..3d8abc20 --- /dev/null +++ b/extra/systemd/mopidy.service @@ -0,0 +1,16 @@ +[Unit] +Description=Mopidy music server +After=avahi-daemon.service +After=dbus.service +After=network.target +After=nss-lookup.target +After=pulseaudio.service +After=remote-fs.target +After=sound.target + +[Service] +User=mopidy +ExecStart=/usr/bin/mopidy --config /usr/share/mopidy/conf.d:/etc/mopidy/mopidy.conf + +[Install] +WantedBy=multi-user.target From 29c6e198b5f129e75c24ea63021045104b670288 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Dec 2014 13:13:31 +0100 Subject: [PATCH 199/495] Add mopidyctl script and manpage Copied from Debian package --- extra/mopidyctl/mopidyctl | 24 ++++++++++++++++++++++++ extra/mopidyctl/mopidyctl.8 | 17 +++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100755 extra/mopidyctl/mopidyctl create mode 100644 extra/mopidyctl/mopidyctl.8 diff --git a/extra/mopidyctl/mopidyctl b/extra/mopidyctl/mopidyctl new file mode 100755 index 00000000..76d2fa63 --- /dev/null +++ b/extra/mopidyctl/mopidyctl @@ -0,0 +1,24 @@ +#!/bin/sh + +SELF=$(basename $0) +DAEMON="/usr/bin/mopidy" +DAEMON_USER="mopidy" +CONFIG_FILES="/usr/share/mopidy/conf.d:/etc/mopidy/mopidy.conf" +CMD="$DAEMON --config $CONFIG_FILES $@" + +if [ $# -eq 0 ]; then + echo "Usage: $SELF [options]" 1>&2 + echo "Examples:" 1>&2 + echo " $SELF --help" 1>&2 + echo " $SELF config" 1>&2 + echo " $SELF local scan" 1>&2 + exit 1 +fi + +if [ $(id -u) -ne 0 ]; then + echo "$SELF must be run as root" 1>&2 + exit 2 +fi + +echo "Running \"$CMD\" as user $DAEMON_USER" 1>&2 +su -s /bin/sh -c "$CMD" -- $DAEMON_USER diff --git a/extra/mopidyctl/mopidyctl.8 b/extra/mopidyctl/mopidyctl.8 new file mode 100644 index 00000000..526165e9 --- /dev/null +++ b/extra/mopidyctl/mopidyctl.8 @@ -0,0 +1,17 @@ +.\" Manpage for mopidyctl +.TH "MOPIDYCTL" "8" "October 11, 2014" "1.0" "mopidyctl" +.SH NAME +mopidyctl \- manage the Mopidy music server system service +.SH SYNOPSIS +.B mopidyctl +[any mopidy(1) option] +.SH DESCRIPTION +The \fBmopidyctl\fP command runs \fBmopidy\fP subcommands in the +same environment as the Mopidy system service is running in. That is, as the +same user and with the same config as the Mopidy system service is using. +.SH OPTIONS +mopidyctl(8) takes the same options as mopidy(1). +.SH SEE ALSO +mopidy(1) +.SH COPYRIGHT +2014, Stein Magnus Jodal and contributors From 5513cbcfb1fca47a6688ba7affd37d2be23351b3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 27 Dec 2014 23:28:19 +0100 Subject: [PATCH 200/495] audio: Update audio taglist key documentation --- docs/api/audio.rst | 6 ++++++ mopidy/audio/utils.py | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/api/audio.rst b/docs/api/audio.rst index 550ca890..76389fb4 100644 --- a/docs/api/audio.rst +++ b/docs/api/audio.rst @@ -35,3 +35,9 @@ Audio scanner .. autoclass:: mopidy.audio.scan.Scanner :members: + +Audio utils +=========== + +.. automodule:: mopidy.audio.utils + :members: diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 84d80cc3..6f9c0cd6 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -142,9 +142,11 @@ def convert_taglist(taglist): - Booleans Unknown types will be ignored and debug logged. Tag keys are all strings - defined by GStreamer. + defined as part GStreamer under GstTagList_. - :param :class:`gst.Taglist` taglist: A GStreamer taglist to be converted. + .. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/0.10.36/gstreamer/html/gstreamer-GstTagList.html + + :param gst.Taglist taglist: A GStreamer taglist to be converted. :rtype: dictionary of tag keys with a list of values. """ result = {} From c575f13bf8d57b3adfb4c5dd164ab9c7be474f71 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 28 Dec 2014 09:40:10 +0100 Subject: [PATCH 201/495] audio: Fix long line in docstring --- mopidy/audio/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 6f9c0cd6..8581fd61 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -144,7 +144,8 @@ def convert_taglist(taglist): Unknown types will be ignored and debug logged. Tag keys are all strings defined as part GStreamer under GstTagList_. - .. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/0.10.36/gstreamer/html/gstreamer-GstTagList.html + .. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\ +0.10.36/gstreamer/html/gstreamer-GstTagList.html :param gst.Taglist taglist: A GStreamer taglist to be converted. :rtype: dictionary of tag keys with a list of values. From 9f6e0cc5faa2c4f566515c16f8641dc3cd26d7ab Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 29 Dec 2014 23:48:59 +0100 Subject: [PATCH 202/495] tests: Fix versions test pep8 naming errors --- tests/test_version.py | 91 ++++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/tests/test_version.py b/tests/test_version.py index d391760b..8c3f9404 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,55 +1,58 @@ from __future__ import absolute_import, unicode_literals import unittest -from distutils.version import StrictVersion as SV +from distutils.version import StrictVersion from mopidy import __version__ class VersionTest(unittest.TestCase): + def assertVersionLess(self, first, second): # noqa: N802 + self.assertLess(StrictVersion(first), StrictVersion(second)) + def test_current_version_is_parsable_as_a_strict_version_number(self): - SV(__version__) + StrictVersion(__version__) def test_versions_can_be_strictly_ordered(self): - self.assertLess(SV('0.1.0a0'), SV('0.1.0a1')) - self.assertLess(SV('0.1.0a1'), SV('0.1.0a2')) - self.assertLess(SV('0.1.0a2'), SV('0.1.0a3')) - self.assertLess(SV('0.1.0a3'), SV('0.1.0')) - self.assertLess(SV('0.1.0'), SV('0.2.0')) - self.assertLess(SV('0.1.0'), SV('1.0.0')) - self.assertLess(SV('0.2.0'), SV('0.3.0')) - self.assertLess(SV('0.3.0'), SV('0.3.1')) - self.assertLess(SV('0.3.1'), SV('0.4.0')) - self.assertLess(SV('0.4.0'), SV('0.4.1')) - self.assertLess(SV('0.4.1'), SV('0.5.0')) - self.assertLess(SV('0.5.0'), SV('0.6.0')) - self.assertLess(SV('0.6.0'), SV('0.6.1')) - self.assertLess(SV('0.6.1'), SV('0.7.0')) - self.assertLess(SV('0.7.0'), SV('0.7.1')) - self.assertLess(SV('0.7.1'), SV('0.7.2')) - self.assertLess(SV('0.7.2'), SV('0.7.3')) - self.assertLess(SV('0.7.3'), SV('0.8.0')) - self.assertLess(SV('0.8.0'), SV('0.8.1')) - self.assertLess(SV('0.8.1'), SV('0.9.0')) - self.assertLess(SV('0.9.0'), SV('0.10.0')) - self.assertLess(SV('0.10.0'), SV('0.11.0')) - self.assertLess(SV('0.11.0'), SV('0.11.1')) - self.assertLess(SV('0.11.1'), SV('0.12.0')) - self.assertLess(SV('0.12.0'), SV('0.13.0')) - self.assertLess(SV('0.13.0'), SV('0.14.0')) - self.assertLess(SV('0.14.0'), SV('0.14.1')) - self.assertLess(SV('0.14.1'), SV('0.14.2')) - self.assertLess(SV('0.14.2'), SV('0.15.0')) - self.assertLess(SV('0.15.0'), SV('0.16.0')) - self.assertLess(SV('0.16.0'), SV('0.17.0')) - self.assertLess(SV('0.17.0'), SV('0.18.0')) - self.assertLess(SV('0.18.0'), SV('0.18.1')) - self.assertLess(SV('0.18.1'), SV('0.18.2')) - self.assertLess(SV('0.18.2'), SV('0.18.3')) - self.assertLess(SV('0.18.3'), SV('0.19.0')) - self.assertLess(SV('0.19.0'), SV('0.19.1')) - self.assertLess(SV('0.19.1'), SV('0.19.2')) - self.assertLess(SV('0.19.2'), SV('0.19.3')) - self.assertLess(SV('0.19.3'), SV('0.19.4')) - self.assertLess(SV('0.19.4'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.19.6')) + self.assertVersionLess('0.1.0a0', '0.1.0a1') + self.assertVersionLess('0.1.0a1', '0.1.0a2') + self.assertVersionLess('0.1.0a2', '0.1.0a3') + self.assertVersionLess('0.1.0a3', '0.1.0') + self.assertVersionLess('0.1.0', '0.2.0') + self.assertVersionLess('0.1.0', '1.0.0') + self.assertVersionLess('0.2.0', '0.3.0') + self.assertVersionLess('0.3.0', '0.3.1') + self.assertVersionLess('0.3.1', '0.4.0') + self.assertVersionLess('0.4.0', '0.4.1') + self.assertVersionLess('0.4.1', '0.5.0') + self.assertVersionLess('0.5.0', '0.6.0') + self.assertVersionLess('0.6.0', '0.6.1') + self.assertVersionLess('0.6.1', '0.7.0') + self.assertVersionLess('0.7.0', '0.7.1') + self.assertVersionLess('0.7.1', '0.7.2') + self.assertVersionLess('0.7.2', '0.7.3') + self.assertVersionLess('0.7.3', '0.8.0') + self.assertVersionLess('0.8.0', '0.8.1') + self.assertVersionLess('0.8.1', '0.9.0') + self.assertVersionLess('0.9.0', '0.10.0') + self.assertVersionLess('0.10.0', '0.11.0') + self.assertVersionLess('0.11.0', '0.11.1') + self.assertVersionLess('0.11.1', '0.12.0') + self.assertVersionLess('0.12.0', '0.13.0') + self.assertVersionLess('0.13.0', '0.14.0') + self.assertVersionLess('0.14.0', '0.14.1') + self.assertVersionLess('0.14.1', '0.14.2') + self.assertVersionLess('0.14.2', '0.15.0') + self.assertVersionLess('0.15.0', '0.16.0') + self.assertVersionLess('0.16.0', '0.17.0') + self.assertVersionLess('0.17.0', '0.18.0') + self.assertVersionLess('0.18.0', '0.18.1') + self.assertVersionLess('0.18.1', '0.18.2') + self.assertVersionLess('0.18.2', '0.18.3') + self.assertVersionLess('0.18.3', '0.19.0') + self.assertVersionLess('0.19.0', '0.19.1') + self.assertVersionLess('0.19.1', '0.19.2') + self.assertVersionLess('0.19.2', '0.19.3') + self.assertVersionLess('0.19.3', '0.19.4') + self.assertVersionLess('0.19.4', __version__) + self.assertVersionLess(__version__, '0.19.6') From a50ba6e3a7410db401a6b9d10232de10f40701e7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 29 Dec 2014 23:58:36 +0100 Subject: [PATCH 203/495] tests: Add noqa markers to custom assert helpers --- tests/mpd/protocol/__init__.py | 10 +++++----- tests/mpd/protocol/test_idle.py | 8 ++++---- tests/mpd/test_tokenizer.py | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 813d91fe..b9600a1d 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -50,28 +50,28 @@ class BaseTestCase(unittest.TestCase): self.session.on_receive({'received': request}) return self.connection.response - def assertNoResponse(self): + def assertNoResponse(self): # noqa: N802 self.assertEqual([], self.connection.response) - def assertInResponse(self, value): + def assertInResponse(self, value): # noqa: N802 self.assertIn( value, self.connection.response, 'Did not find %s in %s' % ( repr(value), repr(self.connection.response))) - def assertOnceInResponse(self, value): + def assertOnceInResponse(self, value): # noqa: N802 matched = len([r for r in self.connection.response if r == value]) self.assertEqual( 1, matched, 'Expected to find %s once in %s' % ( repr(value), repr(self.connection.response))) - def assertNotInResponse(self, value): + def assertNotInResponse(self, value): # noqa: N802 self.assertNotIn( value, self.connection.response, 'Found %s in %s' % ( repr(value), repr(self.connection.response))) - def assertEqualResponse(self, value): + def assertEqualResponse(self, value): # noqa: N802 self.assertEqual(1, len(self.connection.response)) self.assertEqual(value, self.connection.response[0]) diff --git a/tests/mpd/protocol/test_idle.py b/tests/mpd/protocol/test_idle.py index 4c987647..3af983e2 100644 --- a/tests/mpd/protocol/test_idle.py +++ b/tests/mpd/protocol/test_idle.py @@ -11,16 +11,16 @@ class IdleHandlerTest(protocol.BaseTestCase): def idleEvent(self, subsystem): self.session.on_idle(subsystem) - def assertEqualEvents(self, events): + def assertEqualEvents(self, events): # noqa: N802 self.assertEqual(set(events), self.context.events) - def assertEqualSubscriptions(self, events): + def assertEqualSubscriptions(self, events): # noqa: N802 self.assertEqual(set(events), self.context.subscriptions) - def assertNoEvents(self): + def assertNoEvents(self): # noqa: N802 self.assertEqualEvents([]) - def assertNoSubscriptions(self): + def assertNoSubscriptions(self): # noqa: N802 self.assertEqualSubscriptions([]) def test_base_state(self): diff --git a/tests/mpd/test_tokenizer.py b/tests/mpd/test_tokenizer.py index b4a1df09..b4d46719 100644 --- a/tests/mpd/test_tokenizer.py +++ b/tests/mpd/test_tokenizer.py @@ -8,10 +8,10 @@ from mopidy.mpd import exceptions, tokenize class TestTokenizer(unittest.TestCase): - def assertTokenizeEquals(self, expected, line): + def assertTokenizeEquals(self, expected, line): # noqa: N802 self.assertEqual(expected, tokenize.split(line)) - def assertTokenizeRaises(self, exception, message, line): + def assertTokenizeRaises(self, exception, message, line): # noqa: N802 with self.assertRaises(exception) as cm: tokenize.split(line) self.assertEqual(cm.exception.message, message) From fa8547c397c0d7eb17fb71997b76e30240d58ed8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 30 Dec 2014 00:01:00 +0100 Subject: [PATCH 204/495] tests: Add noqa markers for setUp/tearDown --- tests/audio/test_actor.py | 8 ++++---- tests/audio/test_listener.py | 2 +- tests/audio/test_scan.py | 2 +- tests/audio/test_utils.py | 2 +- tests/backend/test_listener.py | 2 +- tests/config/test_config.py | 2 +- tests/config/test_schemas.py | 2 +- tests/core/test_actor.py | 4 ++-- tests/core/test_events.py | 4 ++-- tests/core/test_history.py | 2 +- tests/core/test_library.py | 2 +- tests/core/test_listener.py | 2 +- tests/core/test_playback.py | 2 +- tests/core/test_playlists.py | 2 +- tests/core/test_tracklist.py | 2 +- tests/local/test_events.py | 4 ++-- tests/local/test_json.py | 2 +- tests/local/test_library.py | 4 ++-- tests/local/test_playback.py | 4 ++-- tests/local/test_playlists.py | 4 ++-- tests/local/test_tracklist.py | 4 ++-- tests/mpd/protocol/__init__.py | 4 ++-- tests/mpd/test_commands.py | 2 +- tests/mpd/test_dispatcher.py | 4 ++-- tests/mpd/test_status.py | 4 ++-- tests/mpd/test_translator.py | 4 ++-- tests/stream/test_library.py | 2 +- tests/test_commands.py | 4 ++-- tests/test_ext.py | 2 +- tests/test_mixer.py | 2 +- tests/utils/network/test_connection.py | 2 +- tests/utils/network/test_lineprotocol.py | 2 +- tests/utils/network/test_server.py | 2 +- tests/utils/test_jsonrpc.py | 4 ++-- tests/utils/test_path.py | 14 +++++++------- 35 files changed, 57 insertions(+), 57 deletions(-) diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index f77505b7..43f7c076 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -42,7 +42,7 @@ class BaseTest(unittest.TestCase): audio_class = audio.Audio - def setUp(self): # noqa + def setUp(self): # noqa: N802 config = { 'audio': { 'mixer': 'foomixer', @@ -135,7 +135,7 @@ class AudioDummyTest(DummyMixin, AudioTest): @mock.patch.object(audio.AudioListener, 'send') class AudioEventTest(BaseTest): - def setUp(self): # noqa + def setUp(self): # noqa: N802 super(AudioEventTest, self).setUp() self.audio.enable_sync_handler().get() @@ -461,7 +461,7 @@ class MixerTest(BaseTest): class AudioStateTest(unittest.TestCase): - def setUp(self): # noqa + def setUp(self): # noqa: N802 self.audio = audio.Audio(config=None, mixer=None) def test_state_starts_as_stopped(self): @@ -506,7 +506,7 @@ class AudioStateTest(unittest.TestCase): class AudioBufferingTest(unittest.TestCase): - def setUp(self): # noqa + def setUp(self): # noqa: N802 self.audio = audio.Audio(config=None, mixer=None) self.audio._playbin = mock.Mock(spec=['set_state']) diff --git a/tests/audio/test_listener.py b/tests/audio/test_listener.py index 6b78ecb0..5cac75bb 100644 --- a/tests/audio/test_listener.py +++ b/tests/audio/test_listener.py @@ -8,7 +8,7 @@ from mopidy import audio class AudioListenerTest(unittest.TestCase): - def setUp(self): # noqa + def setUp(self): # noqa: N802 self.listener = audio.AudioListener() def test_on_event_forwards_to_specific_handler(self): diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 97406c41..50ec8352 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -14,7 +14,7 @@ from tests import path_to_data_dir class ScannerTest(unittest.TestCase): - def setUp(self): # noqa + def setUp(self): # noqa: N802 self.errors = {} self.tags = {} self.durations = {} diff --git a/tests/audio/test_utils.py b/tests/audio/test_utils.py index b2028518..f1f15761 100644 --- a/tests/audio/test_utils.py +++ b/tests/audio/test_utils.py @@ -11,7 +11,7 @@ from mopidy.models import Album, Artist, Track # TODO: current test is trying to test everything at once with a complete tags # set, instead we might want to try with a minimal one making testing easier. class TagsToTrackTest(unittest.TestCase): - def setUp(self): # noqa + def setUp(self): # noqa: N802 self.tags = { 'album': ['album'], 'track-number': [1], diff --git a/tests/backend/test_listener.py b/tests/backend/test_listener.py index 6ec39308..ae8bbffe 100644 --- a/tests/backend/test_listener.py +++ b/tests/backend/test_listener.py @@ -8,7 +8,7 @@ from mopidy import backend class BackendListenerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.listener = backend.BackendListener() def test_on_event_forwards_to_specific_handler(self): diff --git a/tests/config/test_config.py b/tests/config/test_config.py index cd97d9a8..b893c5df 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -84,7 +84,7 @@ class LoadConfigTest(unittest.TestCase): class ValidateTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.schema = config.ConfigSchema('foo') self.schema['bar'] = config.ConfigValue() diff --git a/tests/config/test_schemas.py b/tests/config/test_schemas.py index 910b5004..f9e64b9b 100644 --- a/tests/config/test_schemas.py +++ b/tests/config/test_schemas.py @@ -11,7 +11,7 @@ from tests import any_unicode class ConfigSchemaTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.schema = schemas.ConfigSchema('test') self.schema['foo'] = mock.Mock() self.schema['bar'] = mock.Mock() diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index a3cb93da..e82962dc 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -11,7 +11,7 @@ from mopidy.utils import versioning class CoreActorTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.backend1.actor_ref.actor_class.__name__ = b'B1' @@ -22,7 +22,7 @@ class CoreActorTest(unittest.TestCase): self.core = Core(mixer=None, backends=[self.backend1, self.backend2]) - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_uri_schemes_has_uris_from_all_backends(self): diff --git a/tests/core/test_events.py b/tests/core/test_events.py index ebe099f3..7226673d 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -13,11 +13,11 @@ from mopidy.models import Track @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_forwards_backend_playlists_loaded_event_to_frontends(self, send): diff --git a/tests/core/test_history.py b/tests/core/test_history.py index eb1404b5..42922e52 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -8,7 +8,7 @@ from mopidy.models import Artist, Track class PlaybackHistoryTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.tracks = [ Track(uri='dummy1:a', name='foo', artists=[Artist(name='foober'), Artist(name='barber')]), diff --git a/tests/core/test_library.py b/tests/core/test_library.py index cbbea2e3..9bd3b244 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -9,7 +9,7 @@ from mopidy.models import Ref, SearchResult, Track class CoreLibraryTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 dummy1_root = Ref.directory(uri='dummy1:directory', name='dummy1') self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index 22bb9146..64003769 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -9,7 +9,7 @@ from mopidy.models import Playlist, TlTrack class CoreListenerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.listener = CoreListener() def test_on_event_forwards_to_specific_handler(self): diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 04e3f260..b9d19966 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -9,7 +9,7 @@ from mopidy.models import Track class CorePlaybackTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.playback1 = mock.Mock(spec=backend.PlaybackProvider) diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 20577763..55a75767 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -9,7 +9,7 @@ from mopidy.models import Playlist, Track class PlaylistsTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.sp1 = mock.Mock(spec=backend.PlaylistsProvider) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 38885912..7b5577f9 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -9,7 +9,7 @@ from mopidy.models import Track class TracklistTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.tracks = [ Track(uri='dummy1:a', name='foo'), Track(uri='dummy1:b', name='foo'), diff --git a/tests/local/test_events.py b/tests/local/test_events.py index 7a85731e..ae2ec66a 100644 --- a/tests/local/test_events.py +++ b/tests/local/test_events.py @@ -23,13 +23,13 @@ class LocalBackendEventsTest(unittest.TestCase): } } - def setUp(self): + def setUp(self): # noqa: N802 self.audio = audio.DummyAudio.start().proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core.start(backends=[self.backend]).proxy() - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_playlists_refresh_sends_playlists_loaded_event(self, send): diff --git a/tests/local/test_json.py b/tests/local/test_json.py index 2da13632..0d62c2e3 100644 --- a/tests/local/test_json.py +++ b/tests/local/test_json.py @@ -9,7 +9,7 @@ from mopidy.models import Ref class BrowseCacheTest(unittest.TestCase): maxDiff = None - def setUp(self): + def setUp(self): # noqa: N802 self.uris = ['local:track:foo/bar/song1', 'local:track:foo/bar/song2', 'local:track:foo/baz/song3', diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 3be41333..6cc1992e 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -73,14 +73,14 @@ class LocalLibraryProviderTest(unittest.TestCase): }, } - def setUp(self): + def setUp(self): # noqa: N802 actor.LocalBackend.libraries = [json.JsonLibrary] self.backend = actor.LocalBackend.start( config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) self.library = self.core.library - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() actor.LocalBackend.libraries = [] diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index ae031191..0edd89c5 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -39,7 +39,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): track = Track(uri=uri, length=4464) self.tracklist.add([track]) - def setUp(self): + def setUp(self): # noqa: N802 self.audio = audio.DummyAudio.start().proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() @@ -52,7 +52,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): assert self.tracks[0].length >= 2000, \ 'First song needs to be at least 2000 miliseconds' - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_uri_scheme(self): diff --git a/tests/local/test_playlists.py b/tests/local/test_playlists.py index 4210f248..c9aa299a 100644 --- a/tests/local/test_playlists.py +++ b/tests/local/test_playlists.py @@ -25,7 +25,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): } } - def setUp(self): + def setUp(self): # noqa: N802 self.config['local']['playlists_dir'] = tempfile.mkdtemp() self.playlists_dir = self.config['local']['playlists_dir'] @@ -34,7 +34,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): config=self.config, audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() if os.path.exists(self.playlists_dir): diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index 69dd6400..d74d436c 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -26,7 +26,7 @@ class LocalTracklistProviderTest(unittest.TestCase): tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] - def setUp(self): + def setUp(self): # noqa: N802 self.audio = audio.DummyAudio.start().proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() @@ -36,7 +36,7 @@ class LocalTracklistProviderTest(unittest.TestCase): assert len(self.tracks) == 3, 'Need three tracks to run tests.' - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_length(self): diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index b9600a1d..4f6e697a 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -31,7 +31,7 @@ class BaseTestCase(unittest.TestCase): } } - def setUp(self): + def setUp(self): # noqa: N802 self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() @@ -41,7 +41,7 @@ class BaseTestCase(unittest.TestCase): self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def sendRequest(self, request): diff --git a/tests/mpd/test_commands.py b/tests/mpd/test_commands.py index 4699dfe0..e0903e9f 100644 --- a/tests/mpd/test_commands.py +++ b/tests/mpd/test_commands.py @@ -55,7 +55,7 @@ class TestConverts(unittest.TestCase): class TestCommands(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.commands = protocol.Commands() def test_add_as_a_decorator(self): diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index 24d03bf1..1a230451 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -11,7 +11,7 @@ from mopidy.mpd.exceptions import MpdAckError class MpdDispatcherTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 config = { 'mpd': { 'password': None, @@ -21,7 +21,7 @@ class MpdDispatcherTest(unittest.TestCase): self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = MpdDispatcher(config=config) - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_call_handler_for_unknown_command_raises_exception(self): diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 57b2d4d4..1015615c 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -20,13 +20,13 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() def test_stats_method(self): diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 82e60d93..027ce28f 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -26,11 +26,11 @@ class TrackMpdFormatTest(unittest.TestCase): length=137000, ) - def setUp(self): + def setUp(self): # noqa: N802 self.media_dir = '/dir/subdir' mtime.set_fake_time(1234567) - def tearDown(self): + def tearDown(self): # noqa: N802 mtime.undo_fake() def test_track_to_mpd_format_for_empty_track(self): diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py index b660a2d4..7ed871cb 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -19,7 +19,7 @@ from tests import path_to_data_dir class LibraryProviderTest(unittest.TestCase): - def setUp(self): # noqa: ignore method must be lowercase + def setUp(self): # noqa: N802 self.backend = mock.Mock() self.backend.uri_schemes = ['file'] self.uri = path_to_uri(path_to_data_dir('song1.wav')) diff --git a/tests/test_commands.py b/tests/test_commands.py index 58f681be..0942b3a0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -44,12 +44,12 @@ class ConfigOverrideTypeTest(unittest.TestCase): class CommandParsingTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.exit_patcher = mock.patch.object(commands.Command, 'exit') self.exit_mock = self.exit_patcher.start() self.exit_mock.side_effect = SystemExit - def tearDown(self): + def tearDown(self): # noqa: N802 self.exit_patcher.stop() def test_command_parsing_returns_namespace(self): diff --git a/tests/test_ext.py b/tests/test_ext.py index 0e850e60..f4e247b6 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -6,7 +6,7 @@ from mopidy import config, ext class ExtensionTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.ext = ext.Extension() def test_dist_name_is_none(self): diff --git a/tests/test_mixer.py b/tests/test_mixer.py index d0f1b0f2..c57d861a 100644 --- a/tests/test_mixer.py +++ b/tests/test_mixer.py @@ -8,7 +8,7 @@ from mopidy import mixer class MixerListenerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.listener = mixer.MixerListener() def test_on_event_forwards_to_specific_handler(self): diff --git a/tests/utils/network/test_connection.py b/tests/utils/network/test_connection.py index 031ea385..0ccaea0a 100644 --- a/tests/utils/network/test_connection.py +++ b/tests/utils/network/test_connection.py @@ -17,7 +17,7 @@ from tests import any_int, any_unicode class ConnectionTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Connection) def test_init_ensure_nonblocking_io(self): diff --git a/tests/utils/network/test_lineprotocol.py b/tests/utils/network/test_lineprotocol.py index 28bfbad2..1b584e47 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/utils/network/test_lineprotocol.py @@ -14,7 +14,7 @@ from tests import any_unicode class LineProtocolTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.LineProtocol) self.mock.terminator = network.LineProtocol.terminator diff --git a/tests/utils/network/test_server.py b/tests/utils/network/test_server.py index f5f61101..d85d6c27 100644 --- a/tests/utils/network/test_server.py +++ b/tests/utils/network/test_server.py @@ -14,7 +14,7 @@ from tests import any_int class ServerTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Server) def test_init_calls_create_server_socket(self): diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index bf7da541..a74000b2 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -40,7 +40,7 @@ class Calculator(object): class JsonRpcTestBase(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() @@ -56,7 +56,7 @@ class JsonRpcTestBase(unittest.TestCase): encoders=[models.ModelJSONEncoder], decoders=[models.model_json_decoder]) - def tearDown(self): + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 36d1f7db..6fd4f8d1 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -16,10 +16,10 @@ import tests class GetOrCreateDirTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.parent = tempfile.mkdtemp() - def tearDown(self): + def tearDown(self): # noqa: N802 if os.path.isdir(self.parent): shutil.rmtree(self.parent) @@ -67,10 +67,10 @@ class GetOrCreateDirTest(unittest.TestCase): class GetOrCreateFileTest(unittest.TestCase): - def setUp(self): + def setUp(self): # noqa: N802 self.parent = tempfile.mkdtemp() - def tearDown(self): + def tearDown(self): # noqa: N802 if os.path.isdir(self.parent): shutil.rmtree(self.parent) @@ -221,10 +221,10 @@ class ExpandPathTest(unittest.TestCase): class FindMTimesTest(unittest.TestCase): maxDiff = None - def setUp(self): + def setUp(self): # noqa: N802 self.tmpdir = tempfile.mkdtemp(b'.mopidy-tests') - def tearDown(self): + def tearDown(self): # noqa: N802 shutil.rmtree(self.tmpdir, ignore_errors=True) def mkdir(self, *args): @@ -378,7 +378,7 @@ class FindMTimesTest(unittest.TestCase): # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): - def tearDown(self): + def tearDown(self): # noqa: N802 path.mtime.undo_fake() def test_mtime_of_current_dir(self): From 627b8565787a31e635745f227e9b26ece5ae78c8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 30 Dec 2014 00:14:41 +0100 Subject: [PATCH 205/495] tests: Fix MPD tests helper names --- tests/mpd/protocol/__init__.py | 2 +- tests/mpd/protocol/test_audio_output.py | 12 +- tests/mpd/protocol/test_authentication.py | 20 +- tests/mpd/protocol/test_channels.py | 10 +- tests/mpd/protocol/test_command_list.py | 30 +- tests/mpd/protocol/test_connection.py | 10 +- tests/mpd/protocol/test_current_playlist.py | 112 +++--- tests/mpd/protocol/test_idle.py | 98 ++--- tests/mpd/protocol/test_music_db.py | 424 ++++++++++---------- tests/mpd/protocol/test_playback.py | 128 +++--- tests/mpd/protocol/test_reflection.py | 16 +- tests/mpd/protocol/test_regression.py | 60 +-- tests/mpd/protocol/test_status.py | 10 +- tests/mpd/protocol/test_stickers.py | 12 +- tests/mpd/protocol/test_stored_playlists.py | 50 +-- 15 files changed, 497 insertions(+), 497 deletions(-) diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 4f6e697a..8c744a78 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -44,7 +44,7 @@ class BaseTestCase(unittest.TestCase): def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() - def sendRequest(self, request): + def send_request(self, request): self.connection.response = [] request = '%s\n' % request.encode('utf-8') self.session.on_receive({'received': request}) diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index 4815c2db..137ac029 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -7,26 +7,26 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): self.core.playback.mute = False - self.sendRequest('enableoutput "0"') + self.send_request('enableoutput "0"') self.assertInResponse('OK') self.assertEqual(self.core.playback.mute.get(), True) def test_enableoutput_unknown_outputid(self): - self.sendRequest('enableoutput "7"') + self.send_request('enableoutput "7"') self.assertInResponse('ACK [50@0] {enableoutput} No such audio output') def test_disableoutput(self): self.core.playback.mute = True - self.sendRequest('disableoutput "0"') + self.send_request('disableoutput "0"') self.assertInResponse('OK') self.assertEqual(self.core.playback.mute.get(), False) def test_disableoutput_unknown_outputid(self): - self.sendRequest('disableoutput "7"') + self.send_request('disableoutput "7"') self.assertInResponse( 'ACK [50@0] {disableoutput} No such audio output') @@ -34,7 +34,7 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): def test_outputs_when_unmuted(self): self.core.playback.mute = False - self.sendRequest('outputs') + self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') @@ -44,7 +44,7 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): def test_outputs_when_muted(self): self.core.playback.mute = True - self.sendRequest('outputs') + self.send_request('outputs') self.assertInResponse('outputid: 0') self.assertInResponse('outputname: Mute') diff --git a/tests/mpd/protocol/test_authentication.py b/tests/mpd/protocol/test_authentication.py index 6785ff98..ac6e71da 100644 --- a/tests/mpd/protocol/test_authentication.py +++ b/tests/mpd/protocol/test_authentication.py @@ -10,53 +10,53 @@ class AuthenticationActiveTest(protocol.BaseTestCase): return config def test_authentication_with_valid_password_is_accepted(self): - self.sendRequest('password "topsecret"') + self.send_request('password "topsecret"') self.assertTrue(self.dispatcher.authenticated) self.assertInResponse('OK') def test_authentication_with_invalid_password_is_not_accepted(self): - self.sendRequest('password "secret"') + self.send_request('password "secret"') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse('ACK [3@0] {password} incorrect password') def test_authentication_without_password_fails(self): - self.sendRequest('password') + self.send_request('password') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse( 'ACK [2@0] {password} wrong number of arguments for "password"') def test_anything_when_not_authenticated_should_fail(self): - self.sendRequest('any request at all') + self.send_request('any request at all') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse( u'ACK [4@0] {any} you don\'t have permission for "any"') def test_close_is_allowed_without_authentication(self): - self.sendRequest('close') + self.send_request('close') self.assertFalse(self.dispatcher.authenticated) def test_commands_is_allowed_without_authentication(self): - self.sendRequest('commands') + self.send_request('commands') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') def test_notcommands_is_allowed_without_authentication(self): - self.sendRequest('notcommands') + self.send_request('notcommands') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') def test_ping_is_allowed_without_authentication(self): - self.sendRequest('ping') + self.send_request('ping') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') class AuthenticationInactiveTest(protocol.BaseTestCase): def test_authentication_with_anything_when_password_check_turned_off(self): - self.sendRequest('any request at all') + self.send_request('any request at all') self.assertTrue(self.dispatcher.authenticated) self.assertEqualResponse('ACK [5@0] {} unknown command "any"') def test_any_password_is_not_accepted_when_password_check_turned_off(self): - self.sendRequest('password "secret"') + self.send_request('password "secret"') self.assertEqualResponse('ACK [3@0] {password} incorrect password') diff --git a/tests/mpd/protocol/test_channels.py b/tests/mpd/protocol/test_channels.py index 1c04974e..c29b2b57 100644 --- a/tests/mpd/protocol/test_channels.py +++ b/tests/mpd/protocol/test_channels.py @@ -5,21 +5,21 @@ from tests.mpd import protocol class ChannelsHandlerTest(protocol.BaseTestCase): def test_subscribe(self): - self.sendRequest('subscribe "topic"') + self.send_request('subscribe "topic"') self.assertEqualResponse('ACK [0@0] {subscribe} Not implemented') def test_unsubscribe(self): - self.sendRequest('unsubscribe "topic"') + self.send_request('unsubscribe "topic"') self.assertEqualResponse('ACK [0@0] {unsubscribe} Not implemented') def test_channels(self): - self.sendRequest('channels') + self.send_request('channels') self.assertEqualResponse('ACK [0@0] {channels} Not implemented') def test_readmessages(self): - self.sendRequest('readmessages') + self.send_request('readmessages') self.assertEqualResponse('ACK [0@0] {readmessages} Not implemented') def test_sendmessage(self): - self.sendRequest('sendmessage "topic" "a message"') + self.send_request('sendmessage "topic" "a message"') self.assertEqualResponse('ACK [0@0] {sendmessage} Not implemented') diff --git a/tests/mpd/protocol/test_command_list.py b/tests/mpd/protocol/test_command_list.py index 330af176..28642b47 100644 --- a/tests/mpd/protocol/test_command_list.py +++ b/tests/mpd/protocol/test_command_list.py @@ -5,55 +5,55 @@ from tests.mpd import protocol class CommandListsTest(protocol.BaseTestCase): def test_command_list_begin(self): - response = self.sendRequest('command_list_begin') + response = self.send_request('command_list_begin') self.assertEquals([], response) def test_command_list_end(self): - self.sendRequest('command_list_begin') - self.sendRequest('command_list_end') + self.send_request('command_list_begin') + self.send_request('command_list_end') self.assertInResponse('OK') def test_command_list_end_without_start_first_is_an_unknown_command(self): - self.sendRequest('command_list_end') + self.send_request('command_list_end') self.assertEqualResponse( 'ACK [5@0] {} unknown command "command_list_end"') def test_command_list_with_ping(self): - self.sendRequest('command_list_begin') + self.send_request('command_list_begin') self.assertTrue(self.dispatcher.command_list_receiving) self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.sendRequest('ping') + self.send_request('ping') self.assertIn('ping', self.dispatcher.command_list) - self.sendRequest('command_list_end') + self.send_request('command_list_end') self.assertInResponse('OK') self.assertFalse(self.dispatcher.command_list_receiving) self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) def test_command_list_with_error_returns_ack_with_correct_index(self): - self.sendRequest('command_list_begin') - self.sendRequest('play') # Known command - self.sendRequest('paly') # Unknown command - self.sendRequest('command_list_end') + self.send_request('command_list_begin') + self.send_request('play') # Known command + self.send_request('paly') # Unknown command + self.send_request('command_list_end') self.assertEqualResponse('ACK [5@1] {} unknown command "paly"') def test_command_list_ok_begin(self): - response = self.sendRequest('command_list_ok_begin') + response = self.send_request('command_list_ok_begin') self.assertEquals([], response) def test_command_list_ok_with_ping(self): - self.sendRequest('command_list_ok_begin') + self.send_request('command_list_ok_begin') self.assertTrue(self.dispatcher.command_list_receiving) self.assertTrue(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.sendRequest('ping') + self.send_request('ping') self.assertIn('ping', self.dispatcher.command_list) - self.sendRequest('command_list_end') + self.send_request('command_list_end') self.assertInResponse('list_OK') self.assertInResponse('OK') self.assertFalse(self.dispatcher.command_list_receiving) diff --git a/tests/mpd/protocol/test_connection.py b/tests/mpd/protocol/test_connection.py index 2a21a1c3..da25153d 100644 --- a/tests/mpd/protocol/test_connection.py +++ b/tests/mpd/protocol/test_connection.py @@ -8,22 +8,22 @@ from tests.mpd import protocol class ConnectionHandlerTest(protocol.BaseTestCase): def test_close_closes_the_client_connection(self): with patch.object(self.session, 'close') as close_mock: - self.sendRequest('close') + self.send_request('close') close_mock.assertEqualResponsecalled_once_with() self.assertEqualResponse('OK') def test_empty_request(self): - self.sendRequest('') + self.send_request('') self.assertEqualResponse('ACK [5@0] {} No command given') - self.sendRequest(' ') + self.send_request(' ') self.assertEqualResponse('ACK [5@0] {} No command given') def test_kill(self): - self.sendRequest('kill') + self.send_request('kill') self.assertEqualResponse( 'ACK [4@0] {kill} you don\'t have permission for "kill"') def test_ping(self): - self.sendRequest('ping') + self.send_request('ping') self.assertEqualResponse('OK') diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index 6501e5c7..d6fdce8e 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -14,13 +14,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('add "dummy://foo"') + self.send_request('add "dummy://foo"') self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqual(self.core.tracklist.tracks.get()[5], needle) self.assertEqualResponse('OK') def test_add_with_uri_not_found_in_library_should_ack(self): - self.sendRequest('add "dummy://foo"') + self.send_request('add "dummy://foo"') self.assertEqualResponse( 'ACK [50@0] {add} directory or file not found') @@ -29,7 +29,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} - self.sendRequest('add ""') + self.send_request('add ""') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') @@ -43,7 +43,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} - self.sendRequest('add "/dummy"') + self.send_request('add "/dummy"') self.assertEqual(self.core.tracklist.tracks.get(), tracks) self.assertInResponse('OK') @@ -52,7 +52,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} - self.sendRequest('add "/"') + self.send_request('add "/"') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') @@ -64,7 +64,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('addid "dummy://foo"') + self.send_request('addid "dummy://foo"') self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqual(self.core.tracklist.tracks.get()[5], needle) self.assertInResponse( @@ -72,7 +72,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_addid_with_empty_uri_acks(self): - self.sendRequest('addid ""') + self.send_request('addid ""') self.assertEqualResponse('ACK [50@0] {addid} No such song') def test_addid_with_songpos(self): @@ -83,7 +83,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('addid "dummy://foo" "3"') + self.send_request('addid "dummy://foo" "3"') self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqual(self.core.tracklist.tracks.get()[3], needle) self.assertInResponse( @@ -98,11 +98,11 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('addid "dummy://foo" "6"') + self.send_request('addid "dummy://foo" "6"') self.assertEqualResponse('ACK [2@0] {addid} Bad song index') def test_addid_with_uri_not_found_in_library_should_ack(self): - self.sendRequest('addid "dummy://foo"') + self.send_request('addid "dummy://foo"') self.assertEqualResponse('ACK [50@0] {addid} No such song') def test_clear(self): @@ -110,7 +110,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('clear') + self.send_request('clear') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertEqual(self.core.playback.current_track.get(), None) self.assertInResponse('OK') @@ -120,7 +120,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest( + self.send_request( 'delete "%d"' % self.core.tracklist.tl_tracks.get()[2].tlid) self.assertEqual(len(self.core.tracklist.tracks.get()), 4) self.assertInResponse('OK') @@ -130,7 +130,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('delete "5"') + self.send_request('delete "5"') self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') @@ -139,7 +139,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('delete "1:"') + self.send_request('delete "1:"') self.assertEqual(len(self.core.tracklist.tracks.get()), 1) self.assertInResponse('OK') @@ -148,7 +148,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('delete "1:3"') + self.send_request('delete "1:3"') self.assertEqual(len(self.core.tracklist.tracks.get()), 3) self.assertInResponse('OK') @@ -157,7 +157,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.sendRequest('delete "5:7"') + self.send_request('delete "5:7"') self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') @@ -165,7 +165,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.sendRequest('deleteid "1"') + self.send_request('deleteid "1"') self.assertEqual(len(self.core.tracklist.tracks.get()), 1) self.assertInResponse('OK') @@ -173,7 +173,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.sendRequest('deleteid "12345"') + self.send_request('deleteid "12345"') self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.assertEqualResponse('ACK [50@0] {deleteid} No such song') @@ -183,7 +183,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('move "1" "0"') + self.send_request('move "1" "0"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'a') @@ -199,7 +199,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('move "2:" "0"') + self.send_request('move "2:" "0"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'c') self.assertEqual(tracks[1].name, 'd') @@ -215,7 +215,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('move "1:3" "0"') + self.send_request('move "1:3" "0"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'c') @@ -231,7 +231,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('moveid "4" "2"') + self.send_request('moveid "4" "2"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') @@ -242,31 +242,31 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_moveid_with_tlid_not_found_in_tracklist_should_ack(self): - self.sendRequest('moveid "9" "0"') + self.send_request('moveid "9" "0"') self.assertEqualResponse( 'ACK [50@0] {moveid} No such song') def test_playlist_returns_same_as_playlistinfo(self): - playlist_response = self.sendRequest('playlist') - playlistinfo_response = self.sendRequest('playlistinfo') + playlist_response = self.send_request('playlist') + playlistinfo_response = self.send_request('playlistinfo') self.assertEqual(playlist_response, playlistinfo_response) def test_playlistfind(self): - self.sendRequest('playlistfind "tag" "needle"') + self.send_request('playlistfind "tag" "needle"') self.assertEqualResponse('ACK [0@0] {playlistfind} Not implemented') def test_playlistfind_by_filename_not_in_tracklist(self): - self.sendRequest('playlistfind "filename" "file:///dev/null"') + self.send_request('playlistfind "filename" "file:///dev/null"') self.assertEqualResponse('OK') def test_playlistfind_by_filename_without_quotes(self): - self.sendRequest('playlistfind filename "file:///dev/null"') + self.send_request('playlistfind filename "file:///dev/null"') self.assertEqualResponse('OK') def test_playlistfind_by_filename_in_tracklist(self): self.core.tracklist.add([Track(uri='file:///exists')]) - self.sendRequest('playlistfind filename "file:///exists"') + self.send_request('playlistfind filename "file:///exists"') self.assertInResponse('file: file:///exists') self.assertInResponse('Id: 0') self.assertInResponse('Pos: 0') @@ -275,7 +275,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistid_without_songid(self): self.core.tracklist.add([Track(name='a'), Track(name='b')]) - self.sendRequest('playlistid') + self.send_request('playlistid') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('OK') @@ -283,7 +283,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistid_with_songid(self): self.core.tracklist.add([Track(name='a'), Track(name='b')]) - self.sendRequest('playlistid "1"') + self.send_request('playlistid "1"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Id: 0') self.assertInResponse('Title: b') @@ -293,7 +293,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistid_with_not_existing_songid_fails(self): self.core.tracklist.add([Track(name='a'), Track(name='b')]) - self.sendRequest('playlistid "25"') + self.send_request('playlistid "25"') self.assertEqualResponse('ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): @@ -302,7 +302,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('playlistinfo') + self.send_request('playlistinfo') self.assertInResponse('Title: a') self.assertInResponse('Pos: 0') self.assertInResponse('Title: b') @@ -325,7 +325,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('playlistinfo "4"') + self.send_request('playlistinfo "4"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Pos: 0') self.assertNotInResponse('Title: b') @@ -341,8 +341,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistinfo_with_negative_songpos_same_as_playlistinfo(self): - response1 = self.sendRequest('playlistinfo "-1"') - response2 = self.sendRequest('playlistinfo') + response1 = self.send_request('playlistinfo "-1"') + response2 = self.send_request('playlistinfo') self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): @@ -351,7 +351,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('playlistinfo "2:"') + self.send_request('playlistinfo "2:"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Pos: 0') self.assertNotInResponse('Title: b') @@ -372,7 +372,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('playlistinfo "2:4"') + self.send_request('playlistinfo "2:4"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') self.assertInResponse('Title: c') @@ -382,30 +382,30 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistinfo_with_too_high_start_of_range_returns_arg_error(self): - self.sendRequest('playlistinfo "10:20"') + self.send_request('playlistinfo "10:20"') self.assertEqualResponse('ACK [2@0] {playlistinfo} Bad song index') def test_playlistinfo_with_too_high_end_of_range_returns_ok(self): - self.sendRequest('playlistinfo "0:20"') + self.send_request('playlistinfo "0:20"') self.assertInResponse('OK') def test_playlistinfo_with_zero_returns_ok(self): - self.sendRequest('playlistinfo "0"') + self.send_request('playlistinfo "0"') self.assertInResponse('OK') def test_playlistsearch(self): - self.sendRequest('playlistsearch "any" "needle"') + self.send_request('playlistsearch "any" "needle"') self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') def test_playlistsearch_without_quotes(self): - self.sendRequest('playlistsearch any "needle"') + self.send_request('playlistsearch any "needle"') self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') def test_plchanges_with_lower_version_returns_changes(self): self.core.tracklist.add( [Track(name='a'), Track(name='b'), Track(name='c')]) - self.sendRequest('plchanges "0"') + self.send_request('plchanges "0"') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('Title: c') @@ -416,7 +416,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(name='a'), Track(name='b'), Track(name='c')]) self.assertEqual(self.core.tracklist.version.get(), 1) - self.sendRequest('plchanges "1"') + self.send_request('plchanges "1"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') self.assertNotInResponse('Title: c') @@ -427,7 +427,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(name='a'), Track(name='b'), Track(name='c')]) self.assertEqual(self.core.tracklist.version.get(), 1) - self.sendRequest('plchanges "2"') + self.send_request('plchanges "2"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') self.assertNotInResponse('Title: c') @@ -437,7 +437,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.core.tracklist.add( [Track(name='a'), Track(name='b'), Track(name='c')]) - self.sendRequest('plchanges "-1"') + self.send_request('plchanges "-1"') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('Title: c') @@ -447,7 +447,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.core.tracklist.add( [Track(name='a'), Track(name='b'), Track(name='c')]) - self.sendRequest('plchanges 0') + self.send_request('plchanges 0') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('Title: c') @@ -456,7 +456,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_plchangesposid(self): self.core.tracklist.add([Track(), Track(), Track()]) - self.sendRequest('plchangesposid "0"') + self.send_request('plchangesposid "0"') tl_tracks = self.core.tracklist.tl_tracks.get() self.assertInResponse('cpos: 0') self.assertInResponse('Id: %d' % tl_tracks[0].tlid) @@ -473,7 +473,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): ]) version = self.core.tracklist.version.get() - self.sendRequest('shuffle') + self.send_request('shuffle') self.assertLess(version, self.core.tracklist.version.get()) self.assertInResponse('OK') @@ -484,7 +484,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): ]) version = self.core.tracklist.version.get() - self.sendRequest('shuffle "4:"') + self.send_request('shuffle "4:"') self.assertLess(version, self.core.tracklist.version.get()) tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') @@ -500,7 +500,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): ]) version = self.core.tracklist.version.get() - self.sendRequest('shuffle "1:3"') + self.send_request('shuffle "1:3"') self.assertLess(version, self.core.tracklist.version.get()) tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') @@ -515,7 +515,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('swap "1" "4"') + self.send_request('swap "1" "4"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') @@ -531,7 +531,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest('swapid "1" "4"') + self.send_request('swapid "1" "4"') tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') @@ -543,12 +543,12 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_swapid_with_first_id_unknown_should_ack(self): self.core.tracklist.add([Track()]) - self.sendRequest('swapid "0" "4"') + self.send_request('swapid "0" "4"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') def test_swapid_with_second_id_unknown_should_ack(self): self.core.tracklist.add([Track()]) - self.sendRequest('swapid "4" "0"') + self.send_request('swapid "4" "0"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') diff --git a/tests/mpd/protocol/test_idle.py b/tests/mpd/protocol/test_idle.py index 3af983e2..0bd16992 100644 --- a/tests/mpd/protocol/test_idle.py +++ b/tests/mpd/protocol/test_idle.py @@ -8,7 +8,7 @@ from tests.mpd import protocol class IdleHandlerTest(protocol.BaseTestCase): - def idleEvent(self, subsystem): + def idle_event(self, subsystem): self.session.on_idle(subsystem) def assertEqualEvents(self, events): # noqa: N802 @@ -29,96 +29,96 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertNoResponse() def test_idle(self): - self.sendRequest('idle') + self.send_request('idle') self.assertEqualSubscriptions(SUBSYSTEMS) self.assertNoEvents() self.assertNoResponse() def test_idle_disables_timeout(self): - self.sendRequest('idle') + self.send_request('idle') self.connection.disable_timeout.assert_called_once_with() def test_noidle(self): - self.sendRequest('noidle') + self.send_request('noidle') self.assertNoSubscriptions() self.assertNoEvents() self.assertNoResponse() def test_idle_player(self): - self.sendRequest('idle player') + self.send_request('idle player') self.assertEqualSubscriptions(['player']) self.assertNoEvents() self.assertNoResponse() def test_idle_player_playlist(self): - self.sendRequest('idle player playlist') + self.send_request('idle player playlist') self.assertEqualSubscriptions(['player', 'playlist']) self.assertNoEvents() self.assertNoResponse() def test_idle_then_noidle(self): - self.sendRequest('idle') - self.sendRequest('noidle') + self.send_request('idle') + self.send_request('noidle') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('OK') def test_idle_then_noidle_enables_timeout(self): - self.sendRequest('idle') - self.sendRequest('noidle') + self.send_request('idle') + self.send_request('noidle') self.connection.enable_timeout.assert_called_once_with() def test_idle_then_play(self): with patch.object(self.session, 'stop') as stop_mock: - self.sendRequest('idle') - self.sendRequest('play') + self.send_request('idle') + self.send_request('play') stop_mock.assert_called_once_with() def test_idle_then_idle(self): with patch.object(self.session, 'stop') as stop_mock: - self.sendRequest('idle') - self.sendRequest('idle') + self.send_request('idle') + self.send_request('idle') stop_mock.assert_called_once_with() def test_idle_player_then_play(self): with patch.object(self.session, 'stop') as stop_mock: - self.sendRequest('idle player') - self.sendRequest('play') + self.send_request('idle player') + self.send_request('play') stop_mock.assert_called_once_with() def test_idle_then_player(self): - self.sendRequest('idle') - self.idleEvent('player') + self.send_request('idle') + self.idle_event('player') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') def test_idle_player_then_event_player(self): - self.sendRequest('idle player') - self.idleEvent('player') + self.send_request('idle player') + self.idle_event('player') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') def test_idle_player_then_noidle(self): - self.sendRequest('idle player') - self.sendRequest('noidle') + self.send_request('idle player') + self.send_request('noidle') self.assertNoSubscriptions() self.assertNoEvents() self.assertOnceInResponse('OK') def test_idle_player_playlist_then_noidle(self): - self.sendRequest('idle player playlist') - self.sendRequest('noidle') + self.send_request('idle player playlist') + self.send_request('noidle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('OK') def test_idle_player_playlist_then_player(self): - self.sendRequest('idle player playlist') - self.idleEvent('player') + self.send_request('idle player playlist') + self.idle_event('player') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') @@ -126,16 +126,16 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertOnceInResponse('OK') def test_idle_playlist_then_player(self): - self.sendRequest('idle playlist') - self.idleEvent('player') + self.send_request('idle playlist') + self.idle_event('player') self.assertEqualEvents(['player']) self.assertEqualSubscriptions(['playlist']) self.assertNoResponse() def test_idle_playlist_then_player_then_playlist(self): - self.sendRequest('idle playlist') - self.idleEvent('player') - self.idleEvent('playlist') + self.send_request('idle playlist') + self.idle_event('player') + self.idle_event('playlist') self.assertNoEvents() self.assertNoSubscriptions() self.assertNotInResponse('changed: player') @@ -143,14 +143,14 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertOnceInResponse('OK') def test_player(self): - self.idleEvent('player') + self.idle_event('player') self.assertEqualEvents(['player']) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle_player(self): - self.idleEvent('player') - self.sendRequest('idle player') + self.idle_event('player') + self.send_request('idle player') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') @@ -158,24 +158,24 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertOnceInResponse('OK') def test_player_then_playlist(self): - self.idleEvent('player') - self.idleEvent('playlist') + self.idle_event('player') + self.idle_event('playlist') self.assertEqualEvents(['player', 'playlist']) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle(self): - self.idleEvent('player') - self.sendRequest('idle') + self.idle_event('player') + self.send_request('idle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') def test_player_then_playlist_then_idle(self): - self.idleEvent('player') - self.idleEvent('playlist') - self.sendRequest('idle') + self.idle_event('player') + self.idle_event('playlist') + self.send_request('idle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('changed: player') @@ -183,24 +183,24 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertOnceInResponse('OK') def test_player_then_idle_playlist(self): - self.idleEvent('player') - self.sendRequest('idle playlist') + self.idle_event('player') + self.send_request('idle playlist') self.assertEqualEvents(['player']) self.assertEqualSubscriptions(['playlist']) self.assertNoResponse() def test_player_then_idle_playlist_then_noidle(self): - self.idleEvent('player') - self.sendRequest('idle playlist') - self.sendRequest('noidle') + self.idle_event('player') + self.send_request('idle playlist') + self.send_request('noidle') self.assertNoEvents() self.assertNoSubscriptions() self.assertOnceInResponse('OK') def test_player_then_playlist_then_idle_playlist(self): - self.idleEvent('player') - self.idleEvent('playlist') - self.sendRequest('idle playlist') + self.idle_event('player') + self.idle_event('playlist') + self.send_request('idle playlist') self.assertNoEvents() self.assertNoSubscriptions() self.assertNotInResponse('changed: player') diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 30907bce..9f3b7348 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -34,19 +34,19 @@ class QueryFromMpdListFormatTest(unittest.TestCase): class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_count(self): - self.sendRequest('count "artist" "needle"') + self.send_request('count "artist" "needle"') self.assertInResponse('songs: 0') self.assertInResponse('playtime: 0') self.assertInResponse('OK') def test_count_without_quotes(self): - self.sendRequest('count artist "needle"') + self.send_request('count artist "needle"') self.assertInResponse('songs: 0') self.assertInResponse('playtime: 0') self.assertInResponse('OK') def test_count_with_multiple_pairs(self): - self.sendRequest('count "artist" "foo" "album" "bar"') + self.send_request('count "artist" "foo" "album" "bar"') self.assertInResponse('songs: 0') self.assertInResponse('playtime: 0') self.assertInResponse('OK') @@ -57,7 +57,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): tracks=[ Track(uri='dummy:a', name="foo", date="2001", length=4000), ]) - self.sendRequest('count "title" "foo"') + self.send_request('count "title" "foo"') self.assertInResponse('songs: 1') self.assertInResponse('playtime: 4') self.assertInResponse('OK') @@ -68,7 +68,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Track(uri='dummy:b', date="2001", length=50000), Track(uri='dummy:c', date="2001", length=600000), ]) - self.sendRequest('count "date" "2001"') + self.send_request('count "date" "2001"') self.assertInResponse('songs: 2') self.assertInResponse('playtime: 650') self.assertInResponse('OK') @@ -78,7 +78,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) - self.sendRequest('findadd "title" "A"') + self.send_request('findadd "title" "A"') self.assertEqual(self.core.tracklist.length.get(), 1) self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') @@ -89,7 +89,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) - self.sendRequest('searchadd "title" "a"') + self.send_request('searchadd "title" "a"') self.assertEqual(self.core.tracklist.length.get(), 1) self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') @@ -108,7 +108,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqual(len(playlists), 1) self.assertEqual(len(playlists[0].tracks), 2) - self.sendRequest('searchaddpl "my favs" "title" "a"') + self.send_request('searchaddpl "my favs" "title" "a"') playlists = self.core.playlists.filter(name='my favs').get() self.assertEqual(len(playlists), 1) @@ -124,7 +124,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqual( len(self.core.playlists.filter(name='my favs').get()), 0) - self.sendRequest('searchaddpl "my favs" "title" "a"') + self.send_request('searchaddpl "my favs" "title" "a"') playlists = self.core.playlists.filter(name='my favs').get() self.assertEqual(len(playlists), 1) @@ -143,7 +143,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Ref.playlist(uri='dummy:/pl', name='pl')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} - self.sendRequest('listall') + self.send_request('listall') self.assertInResponse('file: dummy:/a') self.assertInResponse('directory: /dummy/foo') @@ -162,7 +162,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} - self.sendRequest('listall "/dummy/foo"') + self.send_request('listall "/dummy/foo"') self.assertNotInResponse('file: dummy:/a') self.assertInResponse('directory: /dummy/foo') @@ -170,7 +170,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listall_with_unknown_uri(self): - self.sendRequest('listall "/unknown"') + self.send_request('listall "/unknown"') self.assertEqualResponse('ACK [50@0] {listall} Not found') @@ -179,8 +179,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('listall "dummy"') - response2 = self.sendRequest('listall "/dummy"') + response1 = self.send_request('listall "dummy"') + response2 = self.send_request('listall "/dummy"') self.assertEqual(response1, response2) def test_listall_for_dir_with_and_without_trailing_slash_is_the_same(self): @@ -188,8 +188,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('listall "dummy"') - response2 = self.sendRequest('listall "dummy/"') + response1 = self.send_request('listall "dummy"') + response2 = self.send_request('listall "dummy/"') self.assertEqual(response1, response2) def test_listall_duplicate(self): @@ -197,7 +197,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), Ref.directory(uri='dummy:/a2', name='a')]} - self.sendRequest('listall') + self.send_request('listall') self.assertInResponse('directory: /dummy/a') self.assertInResponse('directory: /dummy/a [2]') @@ -213,7 +213,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Ref.playlist(uri='dummy:/pl', name='pl')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} - self.sendRequest('listallinfo') + self.send_request('listallinfo') self.assertInResponse('file: dummy:/a') self.assertInResponse('Title: a') @@ -234,7 +234,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} - self.sendRequest('listallinfo "/dummy/foo"') + self.send_request('listallinfo "/dummy/foo"') self.assertNotInResponse('file: dummy:/a') self.assertNotInResponse('Title: a') @@ -244,7 +244,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listallinfo_with_unknown_uri(self): - self.sendRequest('listallinfo "/unknown"') + self.send_request('listallinfo "/unknown"') self.assertEqualResponse('ACK [50@0] {listallinfo} Not found') @@ -253,8 +253,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('listallinfo "dummy"') - response2 = self.sendRequest('listallinfo "/dummy"') + response1 = self.send_request('listallinfo "dummy"') + response2 = self.send_request('listallinfo "/dummy"') self.assertEqual(response1, response2) def test_listallinfo_for_dir_with_and_without_trailing_slash_is_same(self): @@ -262,8 +262,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('listallinfo "dummy"') - response2 = self.sendRequest('listallinfo "dummy/"') + response1 = self.send_request('listallinfo "dummy"') + response2 = self.send_request('listallinfo "dummy/"') self.assertEqual(response1, response2) def test_listallinfo_duplicate(self): @@ -271,7 +271,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), Ref.directory(uri='dummy:/a2', name='a')]} - self.sendRequest('listallinfo') + self.send_request('listallinfo') self.assertInResponse('directory: /dummy/a') self.assertInResponse('directory: /dummy/a [2]') @@ -280,8 +280,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.playlists.playlists = [ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] - response1 = self.sendRequest('lsinfo') - response2 = self.sendRequest('lsinfo "/"') + response1 = self.send_request('lsinfo') + response2 = self.send_request('lsinfo "/"') self.assertEqual(response1, response2) def test_lsinfo_with_empty_path_returns_same_as_for_root(self): @@ -289,8 +289,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.playlists.playlists = [ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] - response1 = self.sendRequest('lsinfo ""') - response2 = self.sendRequest('lsinfo "/"') + response1 = self.send_request('lsinfo ""') + response2 = self.send_request('lsinfo "/"') self.assertEqual(response1, response2) def test_lsinfo_for_root_includes_playlists(self): @@ -298,7 +298,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.playlists.playlists = [ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] - self.sendRequest('lsinfo "/"') + self.send_request('lsinfo "/"') self.assertInResponse('playlist: a') # Date without milliseconds and with time zone information self.assertInResponse('Last-Modified: 2014-01-28T21:01:13Z') @@ -309,7 +309,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - self.sendRequest('lsinfo "/"') + self.send_request('lsinfo "/"') self.assertInResponse('directory: dummy') self.assertInResponse('OK') @@ -318,8 +318,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('lsinfo "dummy"') - response2 = self.sendRequest('lsinfo "/dummy"') + response1 = self.send_request('lsinfo "dummy"') + response2 = self.send_request('lsinfo "/dummy"') self.assertEqual(response1, response2) def test_lsinfo_for_dir_with_and_without_trailing_slash_is_the_same(self): @@ -327,8 +327,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - response1 = self.sendRequest('lsinfo "dummy"') - response2 = self.sendRequest('lsinfo "dummy/"') + response1 = self.send_request('lsinfo "dummy"') + response2 = self.send_request('lsinfo "dummy/"') self.assertEqual(response1, response2) def test_lsinfo_for_dir_includes_tracks(self): @@ -338,7 +338,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertInResponse('file: dummy:/a') self.assertInResponse('Title: a') self.assertInResponse('OK') @@ -347,7 +347,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')]} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertInResponse('directory: dummy/foo') self.assertInResponse('OK') @@ -355,7 +355,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': []} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertInResponse('OK') def test_lsinfo_for_dir_does_not_recurse(self): @@ -366,7 +366,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertNotInResponse('file: dummy:/a') self.assertInResponse('OK') @@ -375,7 +375,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')], 'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertNotInResponse('directory: dummy') self.assertInResponse('OK') @@ -387,7 +387,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.playlists.playlists = [ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] - response = self.sendRequest('lsinfo "/"') + response = self.send_request('lsinfo "/"') self.assertLess(response.index('directory: dummy'), response.index('playlist: a')) @@ -396,27 +396,27 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 'dummy:/': [Ref.directory(uri='dummy:/a1', name='a'), Ref.directory(uri='dummy:/a2', name='a')]} - self.sendRequest('lsinfo "/dummy"') + self.send_request('lsinfo "/dummy"') self.assertInResponse('directory: dummy/a') self.assertInResponse('directory: dummy/a [2]') def test_update_without_uri(self): - self.sendRequest('update') + self.send_request('update') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') def test_update_with_uri(self): - self.sendRequest('update "file:///dev/urandom"') + self.send_request('update "file:///dev/urandom"') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') def test_rescan_without_uri(self): - self.sendRequest('rescan') + self.send_request('rescan') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') def test_rescan_with_uri(self): - self.sendRequest('rescan "file:///dev/urandom"') + self.send_request('rescan "file:///dev/urandom"') self.assertInResponse('updating_db: 0') self.assertInResponse('OK') @@ -428,7 +428,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) - self.sendRequest('find "any" "foo"') + self.send_request('find "any" "foo"') self.assertInResponse('file: dummy:artist:b') self.assertInResponse('Title: Artist: B') @@ -448,7 +448,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) - self.sendRequest('find "artist" "foo"') + self.send_request('find "artist" "foo"') self.assertNotInResponse('file: dummy:artist:b') self.assertNotInResponse('Title: Artist: B') @@ -468,7 +468,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) - self.sendRequest('find "albumartist" "foo"') + self.send_request('find "albumartist" "foo"') self.assertNotInResponse('file: dummy:artist:b') self.assertNotInResponse('Title: Artist: B') @@ -488,7 +488,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) - self.sendRequest('find "artist" "foo" "album" "bar"') + self.send_request('find "artist" "foo" "album" "bar"') self.assertNotInResponse('file: dummy:artist:b') self.assertNotInResponse('Title: Artist: B') @@ -503,111 +503,111 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_find_album(self): - self.sendRequest('find "album" "what"') + self.send_request('find "album" "what"') self.assertInResponse('OK') def test_find_album_without_quotes(self): - self.sendRequest('find album "what"') + self.send_request('find album "what"') self.assertInResponse('OK') def test_find_artist(self): - self.sendRequest('find "artist" "what"') + self.send_request('find "artist" "what"') self.assertInResponse('OK') def test_find_artist_without_quotes(self): - self.sendRequest('find artist "what"') + self.send_request('find artist "what"') self.assertInResponse('OK') def test_find_albumartist(self): - self.sendRequest('find "albumartist" "what"') + self.send_request('find "albumartist" "what"') self.assertInResponse('OK') def test_find_albumartist_without_quotes(self): - self.sendRequest('find albumartist "what"') + self.send_request('find albumartist "what"') self.assertInResponse('OK') def test_find_composer(self): - self.sendRequest('find "composer" "what"') + self.send_request('find "composer" "what"') self.assertInResponse('OK') def test_find_composer_without_quotes(self): - self.sendRequest('find composer "what"') + self.send_request('find composer "what"') self.assertInResponse('OK') def test_find_performer(self): - self.sendRequest('find "performer" "what"') + self.send_request('find "performer" "what"') self.assertInResponse('OK') def test_find_performer_without_quotes(self): - self.sendRequest('find performer "what"') + self.send_request('find performer "what"') self.assertInResponse('OK') def test_find_filename(self): - self.sendRequest('find "filename" "afilename"') + self.send_request('find "filename" "afilename"') self.assertInResponse('OK') def test_find_filename_without_quotes(self): - self.sendRequest('find filename "afilename"') + self.send_request('find filename "afilename"') self.assertInResponse('OK') def test_find_file(self): - self.sendRequest('find "file" "afilename"') + self.send_request('find "file" "afilename"') self.assertInResponse('OK') def test_find_file_without_quotes(self): - self.sendRequest('find file "afilename"') + self.send_request('find file "afilename"') self.assertInResponse('OK') def test_find_title(self): - self.sendRequest('find "title" "what"') + self.send_request('find "title" "what"') self.assertInResponse('OK') def test_find_title_without_quotes(self): - self.sendRequest('find title "what"') + self.send_request('find title "what"') self.assertInResponse('OK') def test_find_track_no(self): - self.sendRequest('find "track" "10"') + self.send_request('find "track" "10"') self.assertInResponse('OK') def test_find_track_no_without_quotes(self): - self.sendRequest('find track "10"') + self.send_request('find track "10"') self.assertInResponse('OK') def test_find_track_no_without_filter_value(self): - self.sendRequest('find "track" ""') + self.send_request('find "track" ""') self.assertInResponse('OK') def test_find_genre(self): - self.sendRequest('find "genre" "what"') + self.send_request('find "genre" "what"') self.assertInResponse('OK') def test_find_genre_without_quotes(self): - self.sendRequest('find genre "what"') + self.send_request('find genre "what"') self.assertInResponse('OK') def test_find_date(self): - self.sendRequest('find "date" "2002-01-01"') + self.send_request('find "date" "2002-01-01"') self.assertInResponse('OK') def test_find_date_without_quotes(self): - self.sendRequest('find date "2002-01-01"') + self.send_request('find date "2002-01-01"') self.assertInResponse('OK') def test_find_date_with_capital_d_and_incomplete_date(self): - self.sendRequest('find Date "2005"') + self.send_request('find Date "2005"') self.assertInResponse('OK') def test_find_else_should_fail(self): - self.sendRequest('find "somethingelse" "what"') + self.send_request('find "somethingelse" "what"') self.assertEqualResponse('ACK [2@0] {find} incorrect arguments') def test_find_album_and_artist(self): - self.sendRequest('find album "album_what" artist "artist_what"') + self.send_request('find album "album_what" artist "artist_what"') self.assertInResponse('OK') def test_find_without_filter_value(self): - self.sendRequest('find "album" ""') + self.send_request('find "album" ""') self.assertInResponse('OK') @@ -618,132 +618,132 @@ class MusicDatabaseListTest(protocol.BaseTestCase): Track(uri='dummy:a', name='A', artists=[ Artist(name='A Artist')])]) - self.sendRequest('list "artist" "artist" "foo"') + self.send_request('list "artist" "artist" "foo"') self.assertInResponse('Artist: A Artist') self.assertInResponse('OK') def test_list_foo_returns_ack(self): - self.sendRequest('list "foo"') + self.send_request('list "foo"') self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') # Artist def test_list_artist_with_quotes(self): - self.sendRequest('list "artist"') + self.send_request('list "artist"') self.assertInResponse('OK') def test_list_artist_without_quotes(self): - self.sendRequest('list artist') + self.send_request('list artist') self.assertInResponse('OK') def test_list_artist_without_quotes_and_capitalized(self): - self.sendRequest('list Artist') + self.send_request('list Artist') self.assertInResponse('OK') def test_list_artist_with_query_of_one_token(self): - self.sendRequest('list "artist" "anartist"') + self.send_request('list "artist" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_artist_with_unknown_field_in_query_returns_ack(self): - self.sendRequest('list "artist" "foo" "bar"') + self.send_request('list "artist" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_artist_by_artist(self): - self.sendRequest('list "artist" "artist" "anartist"') + self.send_request('list "artist" "artist" "anartist"') self.assertInResponse('OK') def test_list_artist_by_album(self): - self.sendRequest('list "artist" "album" "analbum"') + self.send_request('list "artist" "album" "analbum"') self.assertInResponse('OK') def test_list_artist_by_full_date(self): - self.sendRequest('list "artist" "date" "2001-01-01"') + self.send_request('list "artist" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_artist_by_year(self): - self.sendRequest('list "artist" "date" "2001"') + self.send_request('list "artist" "date" "2001"') self.assertInResponse('OK') def test_list_artist_by_genre(self): - self.sendRequest('list "artist" "genre" "agenre"') + self.send_request('list "artist" "genre" "agenre"') self.assertInResponse('OK') def test_list_artist_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "artist" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_artist_without_filter_value(self): - self.sendRequest('list "artist" "artist" ""') + self.send_request('list "artist" "artist" ""') self.assertInResponse('OK') def test_list_artist_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(artists=[Artist(name='')])]) - self.sendRequest('list "artist"') + self.send_request('list "artist"') self.assertNotInResponse('Artist: ') self.assertInResponse('OK') # Albumartist def test_list_albumartist_with_quotes(self): - self.sendRequest('list "albumartist"') + self.send_request('list "albumartist"') self.assertInResponse('OK') def test_list_albumartist_without_quotes(self): - self.sendRequest('list albumartist') + self.send_request('list albumartist') self.assertInResponse('OK') def test_list_albumartist_without_quotes_and_capitalized(self): - self.sendRequest('list Albumartist') + self.send_request('list Albumartist') self.assertInResponse('OK') def test_list_albumartist_with_query_of_one_token(self): - self.sendRequest('list "albumartist" "anartist"') + self.send_request('list "albumartist" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_albumartist_with_unknown_field_in_query_returns_ack(self): - self.sendRequest('list "albumartist" "foo" "bar"') + self.send_request('list "albumartist" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_albumartist_by_artist(self): - self.sendRequest('list "albumartist" "artist" "anartist"') + self.send_request('list "albumartist" "artist" "anartist"') self.assertInResponse('OK') def test_list_albumartist_by_album(self): - self.sendRequest('list "albumartist" "album" "analbum"') + self.send_request('list "albumartist" "album" "analbum"') self.assertInResponse('OK') def test_list_albumartist_by_full_date(self): - self.sendRequest('list "albumartist" "date" "2001-01-01"') + self.send_request('list "albumartist" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_albumartist_by_year(self): - self.sendRequest('list "albumartist" "date" "2001"') + self.send_request('list "albumartist" "date" "2001"') self.assertInResponse('OK') def test_list_albumartist_by_genre(self): - self.sendRequest('list "albumartist" "genre" "agenre"') + self.send_request('list "albumartist" "genre" "agenre"') self.assertInResponse('OK') def test_list_albumartist_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "albumartist" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_albumartist_without_filter_value(self): - self.sendRequest('list "albumartist" "artist" ""') + self.send_request('list "albumartist" "artist" ""') self.assertInResponse('OK') def test_list_albumartist_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(album=Album(artists=[Artist(name='')]))]) - self.sendRequest('list "albumartist"') + self.send_request('list "albumartist"') self.assertNotInResponse('Artist: ') self.assertNotInResponse('Albumartist: ') self.assertNotInResponse('Composer: ') @@ -753,60 +753,60 @@ class MusicDatabaseListTest(protocol.BaseTestCase): # Composer def test_list_composer_with_quotes(self): - self.sendRequest('list "composer"') + self.send_request('list "composer"') self.assertInResponse('OK') def test_list_composer_without_quotes(self): - self.sendRequest('list composer') + self.send_request('list composer') self.assertInResponse('OK') def test_list_composer_without_quotes_and_capitalized(self): - self.sendRequest('list Composer') + self.send_request('list Composer') self.assertInResponse('OK') def test_list_composer_with_query_of_one_token(self): - self.sendRequest('list "composer" "anartist"') + self.send_request('list "composer" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_composer_with_unknown_field_in_query_returns_ack(self): - self.sendRequest('list "composer" "foo" "bar"') + self.send_request('list "composer" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_composer_by_artist(self): - self.sendRequest('list "composer" "artist" "anartist"') + self.send_request('list "composer" "artist" "anartist"') self.assertInResponse('OK') def test_list_composer_by_album(self): - self.sendRequest('list "composer" "album" "analbum"') + self.send_request('list "composer" "album" "analbum"') self.assertInResponse('OK') def test_list_composer_by_full_date(self): - self.sendRequest('list "composer" "date" "2001-01-01"') + self.send_request('list "composer" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_composer_by_year(self): - self.sendRequest('list "composer" "date" "2001"') + self.send_request('list "composer" "date" "2001"') self.assertInResponse('OK') def test_list_composer_by_genre(self): - self.sendRequest('list "composer" "genre" "agenre"') + self.send_request('list "composer" "genre" "agenre"') self.assertInResponse('OK') def test_list_composer_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "composer" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_composer_without_filter_value(self): - self.sendRequest('list "composer" "artist" ""') + self.send_request('list "composer" "artist" ""') self.assertInResponse('OK') def test_list_composer_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(composers=[Artist(name='')])]) - self.sendRequest('list "composer"') + self.send_request('list "composer"') self.assertNotInResponse('Artist: ') self.assertNotInResponse('Albumartist: ') self.assertNotInResponse('Composer: ') @@ -816,60 +816,60 @@ class MusicDatabaseListTest(protocol.BaseTestCase): # Performer def test_list_performer_with_quotes(self): - self.sendRequest('list "performer"') + self.send_request('list "performer"') self.assertInResponse('OK') def test_list_performer_without_quotes(self): - self.sendRequest('list performer') + self.send_request('list performer') self.assertInResponse('OK') def test_list_performer_without_quotes_and_capitalized(self): - self.sendRequest('list Albumartist') + self.send_request('list Albumartist') self.assertInResponse('OK') def test_list_performer_with_query_of_one_token(self): - self.sendRequest('list "performer" "anartist"') + self.send_request('list "performer" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_performer_with_unknown_field_in_query_returns_ack(self): - self.sendRequest('list "performer" "foo" "bar"') + self.send_request('list "performer" "foo" "bar"') self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_performer_by_artist(self): - self.sendRequest('list "performer" "artist" "anartist"') + self.send_request('list "performer" "artist" "anartist"') self.assertInResponse('OK') def test_list_performer_by_album(self): - self.sendRequest('list "performer" "album" "analbum"') + self.send_request('list "performer" "album" "analbum"') self.assertInResponse('OK') def test_list_performer_by_full_date(self): - self.sendRequest('list "performer" "date" "2001-01-01"') + self.send_request('list "performer" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_performer_by_year(self): - self.sendRequest('list "performer" "date" "2001"') + self.send_request('list "performer" "date" "2001"') self.assertInResponse('OK') def test_list_performer_by_genre(self): - self.sendRequest('list "performer" "genre" "agenre"') + self.send_request('list "performer" "genre" "agenre"') self.assertInResponse('OK') def test_list_performer_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "performer" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_performer_without_filter_value(self): - self.sendRequest('list "performer" "artist" ""') + self.send_request('list "performer" "artist" ""') self.assertInResponse('OK') def test_list_performer_should_not_return_artists_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(performers=[Artist(name='')])]) - self.sendRequest('list "performer"') + self.send_request('list "performer"') self.assertNotInResponse('Artist: ') self.assertNotInResponse('Albumartist: ') self.assertNotInResponse('Composer: ') @@ -879,179 +879,179 @@ class MusicDatabaseListTest(protocol.BaseTestCase): # Album def test_list_album_with_quotes(self): - self.sendRequest('list "album"') + self.send_request('list "album"') self.assertInResponse('OK') def test_list_album_without_quotes(self): - self.sendRequest('list album') + self.send_request('list album') self.assertInResponse('OK') def test_list_album_without_quotes_and_capitalized(self): - self.sendRequest('list Album') + self.send_request('list Album') self.assertInResponse('OK') def test_list_album_with_artist_name(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(album=Album(name='foo'))]) - self.sendRequest('list "album" "anartist"') + self.send_request('list "album" "anartist"') self.assertInResponse('Album: foo') self.assertInResponse('OK') def test_list_album_with_artist_name_without_filter_value(self): - self.sendRequest('list "album" ""') + self.send_request('list "album" ""') self.assertInResponse('OK') def test_list_album_by_artist(self): - self.sendRequest('list "album" "artist" "anartist"') + self.send_request('list "album" "artist" "anartist"') self.assertInResponse('OK') def test_list_album_by_album(self): - self.sendRequest('list "album" "album" "analbum"') + self.send_request('list "album" "album" "analbum"') self.assertInResponse('OK') def test_list_album_by_albumartist(self): - self.sendRequest('list "album" "albumartist" "anartist"') + self.send_request('list "album" "albumartist" "anartist"') self.assertInResponse('OK') def test_list_album_by_composer(self): - self.sendRequest('list "album" "composer" "anartist"') + self.send_request('list "album" "composer" "anartist"') self.assertInResponse('OK') def test_list_album_by_performer(self): - self.sendRequest('list "album" "performer" "anartist"') + self.send_request('list "album" "performer" "anartist"') self.assertInResponse('OK') def test_list_album_by_full_date(self): - self.sendRequest('list "album" "date" "2001-01-01"') + self.send_request('list "album" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_album_by_year(self): - self.sendRequest('list "album" "date" "2001"') + self.send_request('list "album" "date" "2001"') self.assertInResponse('OK') def test_list_album_by_genre(self): - self.sendRequest('list "album" "genre" "agenre"') + self.send_request('list "album" "genre" "agenre"') self.assertInResponse('OK') def test_list_album_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "album" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_album_without_filter_value(self): - self.sendRequest('list "album" "artist" ""') + self.send_request('list "album" "artist" ""') self.assertInResponse('OK') def test_list_album_should_not_return_albums_without_names(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(album=Album(name=''))]) - self.sendRequest('list "album"') + self.send_request('list "album"') self.assertNotInResponse('Album: ') self.assertInResponse('OK') # Date def test_list_date_with_quotes(self): - self.sendRequest('list "date"') + self.send_request('list "date"') self.assertInResponse('OK') def test_list_date_without_quotes(self): - self.sendRequest('list date') + self.send_request('list date') self.assertInResponse('OK') def test_list_date_without_quotes_and_capitalized(self): - self.sendRequest('list Date') + self.send_request('list Date') self.assertInResponse('OK') def test_list_date_with_query_of_one_token(self): - self.sendRequest('list "date" "anartist"') + self.send_request('list "date" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_date_by_artist(self): - self.sendRequest('list "date" "artist" "anartist"') + self.send_request('list "date" "artist" "anartist"') self.assertInResponse('OK') def test_list_date_by_album(self): - self.sendRequest('list "date" "album" "analbum"') + self.send_request('list "date" "album" "analbum"') self.assertInResponse('OK') def test_list_date_by_full_date(self): - self.sendRequest('list "date" "date" "2001-01-01"') + self.send_request('list "date" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_date_by_year(self): - self.sendRequest('list "date" "date" "2001"') + self.send_request('list "date" "date" "2001"') self.assertInResponse('OK') def test_list_date_by_genre(self): - self.sendRequest('list "date" "genre" "agenre"') + self.send_request('list "date" "genre" "agenre"') self.assertInResponse('OK') def test_list_date_by_artist_and_album(self): - self.sendRequest('list "date" "artist" "anartist" "album" "analbum"') + self.send_request('list "date" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_date_without_filter_value(self): - self.sendRequest('list "date" "artist" ""') + self.send_request('list "date" "artist" ""') self.assertInResponse('OK') def test_list_date_should_not_return_blank_dates(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(date='')]) - self.sendRequest('list "date"') + self.send_request('list "date"') self.assertNotInResponse('Date: ') self.assertInResponse('OK') # Genre def test_list_genre_with_quotes(self): - self.sendRequest('list "genre"') + self.send_request('list "genre"') self.assertInResponse('OK') def test_list_genre_without_quotes(self): - self.sendRequest('list genre') + self.send_request('list genre') self.assertInResponse('OK') def test_list_genre_without_quotes_and_capitalized(self): - self.sendRequest('list Genre') + self.send_request('list Genre') self.assertInResponse('OK') def test_list_genre_with_query_of_one_token(self): - self.sendRequest('list "genre" "anartist"') + self.send_request('list "genre" "anartist"') self.assertEqualResponse( 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_genre_by_artist(self): - self.sendRequest('list "genre" "artist" "anartist"') + self.send_request('list "genre" "artist" "anartist"') self.assertInResponse('OK') def test_list_genre_by_album(self): - self.sendRequest('list "genre" "album" "analbum"') + self.send_request('list "genre" "album" "analbum"') self.assertInResponse('OK') def test_list_genre_by_full_date(self): - self.sendRequest('list "genre" "date" "2001-01-01"') + self.send_request('list "genre" "date" "2001-01-01"') self.assertInResponse('OK') def test_list_genre_by_year(self): - self.sendRequest('list "genre" "date" "2001"') + self.send_request('list "genre" "date" "2001"') self.assertInResponse('OK') def test_list_genre_by_genre(self): - self.sendRequest('list "genre" "genre" "agenre"') + self.send_request('list "genre" "genre" "agenre"') self.assertInResponse('OK') def test_list_genre_by_artist_and_album(self): - self.sendRequest( + self.send_request( 'list "genre" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') def test_list_genre_without_filter_value(self): - self.sendRequest('list "genre" "artist" ""') + self.send_request('list "genre" "artist" ""') self.assertInResponse('OK') @@ -1062,7 +1062,7 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) - self.sendRequest('search "any" "foo"') + self.send_request('search "any" "foo"') self.assertInResponse('file: dummy:album:a') self.assertInResponse('Title: Album: A') @@ -1074,165 +1074,165 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_search_album(self): - self.sendRequest('search "album" "analbum"') + self.send_request('search "album" "analbum"') self.assertInResponse('OK') def test_search_album_without_quotes(self): - self.sendRequest('search album "analbum"') + self.send_request('search album "analbum"') self.assertInResponse('OK') def test_search_album_without_filter_value(self): - self.sendRequest('search "album" ""') + self.send_request('search "album" ""') self.assertInResponse('OK') def test_search_artist(self): - self.sendRequest('search "artist" "anartist"') + self.send_request('search "artist" "anartist"') self.assertInResponse('OK') def test_search_artist_without_quotes(self): - self.sendRequest('search artist "anartist"') + self.send_request('search artist "anartist"') self.assertInResponse('OK') def test_search_artist_without_filter_value(self): - self.sendRequest('search "artist" ""') + self.send_request('search "artist" ""') self.assertInResponse('OK') def test_search_albumartist(self): - self.sendRequest('search "albumartist" "analbumartist"') + self.send_request('search "albumartist" "analbumartist"') self.assertInResponse('OK') def test_search_albumartist_without_quotes(self): - self.sendRequest('search albumartist "analbumartist"') + self.send_request('search albumartist "analbumartist"') self.assertInResponse('OK') def test_search_albumartist_without_filter_value(self): - self.sendRequest('search "albumartist" ""') + self.send_request('search "albumartist" ""') self.assertInResponse('OK') def test_search_composer(self): - self.sendRequest('search "composer" "acomposer"') + self.send_request('search "composer" "acomposer"') self.assertInResponse('OK') def test_search_composer_without_quotes(self): - self.sendRequest('search composer "acomposer"') + self.send_request('search composer "acomposer"') self.assertInResponse('OK') def test_search_composer_without_filter_value(self): - self.sendRequest('search "composer" ""') + self.send_request('search "composer" ""') self.assertInResponse('OK') def test_search_performer(self): - self.sendRequest('search "performer" "aperformer"') + self.send_request('search "performer" "aperformer"') self.assertInResponse('OK') def test_search_performer_without_quotes(self): - self.sendRequest('search performer "aperformer"') + self.send_request('search performer "aperformer"') self.assertInResponse('OK') def test_search_performer_without_filter_value(self): - self.sendRequest('search "performer" ""') + self.send_request('search "performer" ""') self.assertInResponse('OK') def test_search_filename(self): - self.sendRequest('search "filename" "afilename"') + self.send_request('search "filename" "afilename"') self.assertInResponse('OK') def test_search_filename_without_quotes(self): - self.sendRequest('search filename "afilename"') + self.send_request('search filename "afilename"') self.assertInResponse('OK') def test_search_filename_without_filter_value(self): - self.sendRequest('search "filename" ""') + self.send_request('search "filename" ""') self.assertInResponse('OK') def test_search_file(self): - self.sendRequest('search "file" "afilename"') + self.send_request('search "file" "afilename"') self.assertInResponse('OK') def test_search_file_without_quotes(self): - self.sendRequest('search file "afilename"') + self.send_request('search file "afilename"') self.assertInResponse('OK') def test_search_file_without_filter_value(self): - self.sendRequest('search "file" ""') + self.send_request('search "file" ""') self.assertInResponse('OK') def test_search_title(self): - self.sendRequest('search "title" "atitle"') + self.send_request('search "title" "atitle"') self.assertInResponse('OK') def test_search_title_without_quotes(self): - self.sendRequest('search title "atitle"') + self.send_request('search title "atitle"') self.assertInResponse('OK') def test_search_title_without_filter_value(self): - self.sendRequest('search "title" ""') + self.send_request('search "title" ""') self.assertInResponse('OK') def test_search_any(self): - self.sendRequest('search "any" "anything"') + self.send_request('search "any" "anything"') self.assertInResponse('OK') def test_search_any_without_quotes(self): - self.sendRequest('search any "anything"') + self.send_request('search any "anything"') self.assertInResponse('OK') def test_search_any_without_filter_value(self): - self.sendRequest('search "any" ""') + self.send_request('search "any" ""') self.assertInResponse('OK') def test_search_track_no(self): - self.sendRequest('search "track" "10"') + self.send_request('search "track" "10"') self.assertInResponse('OK') def test_search_track_no_without_quotes(self): - self.sendRequest('search track "10"') + self.send_request('search track "10"') self.assertInResponse('OK') def test_search_track_no_without_filter_value(self): - self.sendRequest('search "track" ""') + self.send_request('search "track" ""') self.assertInResponse('OK') def test_search_genre(self): - self.sendRequest('search "genre" "agenre"') + self.send_request('search "genre" "agenre"') self.assertInResponse('OK') def test_search_genre_without_quotes(self): - self.sendRequest('search genre "agenre"') + self.send_request('search genre "agenre"') self.assertInResponse('OK') def test_search_genre_without_filter_value(self): - self.sendRequest('search "genre" ""') + self.send_request('search "genre" ""') self.assertInResponse('OK') def test_search_date(self): - self.sendRequest('search "date" "2002-01-01"') + self.send_request('search "date" "2002-01-01"') self.assertInResponse('OK') def test_search_date_without_quotes(self): - self.sendRequest('search date "2002-01-01"') + self.send_request('search date "2002-01-01"') self.assertInResponse('OK') def test_search_date_with_capital_d_and_incomplete_date(self): - self.sendRequest('search Date "2005"') + self.send_request('search Date "2005"') self.assertInResponse('OK') def test_search_date_without_filter_value(self): - self.sendRequest('search "date" ""') + self.send_request('search "date" ""') self.assertInResponse('OK') def test_search_comment(self): - self.sendRequest('search "comment" "acomment"') + self.send_request('search "comment" "acomment"') self.assertInResponse('OK') def test_search_comment_without_quotes(self): - self.sendRequest('search comment "acomment"') + self.send_request('search comment "acomment"') self.assertInResponse('OK') def test_search_comment_without_filter_value(self): - self.sendRequest('search "comment" ""') + self.send_request('search "comment" ""') self.assertInResponse('OK') def test_search_else_should_fail(self): - self.sendRequest('search "sometype" "something"') + self.send_request('search "sometype" "something"') self.assertEqualResponse('ACK [2@0] {search} incorrect arguments') diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index d13cf65f..1cd62bba 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -15,138 +15,138 @@ STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): - self.sendRequest('consume "0"') + self.send_request('consume "0"') self.assertFalse(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_off_without_quotes(self): - self.sendRequest('consume 0') + self.send_request('consume 0') self.assertFalse(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_on(self): - self.sendRequest('consume "1"') + self.send_request('consume "1"') self.assertTrue(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_on_without_quotes(self): - self.sendRequest('consume 1') + self.send_request('consume 1') self.assertTrue(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_crossfade(self): - self.sendRequest('crossfade "10"') + self.send_request('crossfade "10"') self.assertInResponse('ACK [0@0] {crossfade} Not implemented') def test_random_off(self): - self.sendRequest('random "0"') + self.send_request('random "0"') self.assertFalse(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_off_without_quotes(self): - self.sendRequest('random 0') + self.send_request('random 0') self.assertFalse(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_on(self): - self.sendRequest('random "1"') + self.send_request('random "1"') self.assertTrue(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_on_without_quotes(self): - self.sendRequest('random 1') + self.send_request('random 1') self.assertTrue(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_repeat_off(self): - self.sendRequest('repeat "0"') + self.send_request('repeat "0"') self.assertFalse(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_off_without_quotes(self): - self.sendRequest('repeat 0') + self.send_request('repeat 0') self.assertFalse(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_on(self): - self.sendRequest('repeat "1"') + self.send_request('repeat "1"') self.assertTrue(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_on_without_quotes(self): - self.sendRequest('repeat 1') + self.send_request('repeat 1') self.assertTrue(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_setvol_below_min(self): - self.sendRequest('setvol "-10"') + self.send_request('setvol "-10"') self.assertEqual(0, self.core.playback.volume.get()) self.assertInResponse('OK') def test_setvol_min(self): - self.sendRequest('setvol "0"') + self.send_request('setvol "0"') self.assertEqual(0, self.core.playback.volume.get()) self.assertInResponse('OK') def test_setvol_middle(self): - self.sendRequest('setvol "50"') + self.send_request('setvol "50"') self.assertEqual(50, self.core.playback.volume.get()) self.assertInResponse('OK') def test_setvol_max(self): - self.sendRequest('setvol "100"') + self.send_request('setvol "100"') self.assertEqual(100, self.core.playback.volume.get()) self.assertInResponse('OK') def test_setvol_above_max(self): - self.sendRequest('setvol "110"') + self.send_request('setvol "110"') self.assertEqual(100, self.core.playback.volume.get()) self.assertInResponse('OK') def test_setvol_plus_is_ignored(self): - self.sendRequest('setvol "+10"') + self.send_request('setvol "+10"') self.assertEqual(10, self.core.playback.volume.get()) self.assertInResponse('OK') def test_setvol_without_quotes(self): - self.sendRequest('setvol 50') + self.send_request('setvol 50') self.assertEqual(50, self.core.playback.volume.get()) self.assertInResponse('OK') def test_single_off(self): - self.sendRequest('single "0"') + self.send_request('single "0"') self.assertFalse(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_off_without_quotes(self): - self.sendRequest('single 0') + self.send_request('single 0') self.assertFalse(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_on(self): - self.sendRequest('single "1"') + self.send_request('single "1"') self.assertTrue(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_on_without_quotes(self): - self.sendRequest('single 1') + self.send_request('single 1') self.assertTrue(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_replay_gain_mode_off(self): - self.sendRequest('replay_gain_mode "off"') + self.send_request('replay_gain_mode "off"') self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_mode_track(self): - self.sendRequest('replay_gain_mode "track"') + self.send_request('replay_gain_mode "track"') self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_mode_album(self): - self.sendRequest('replay_gain_mode "album"') + self.send_request('replay_gain_mode "album"') self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_status_default(self): - self.sendRequest('replay_gain_status') + self.send_request('replay_gain_status') self.assertInResponse('OK') self.assertInResponse('off') @@ -165,66 +165,66 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_next(self): - self.sendRequest('next') + self.send_request('next') self.assertInResponse('OK') def test_pause_off(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play "0"') - self.sendRequest('pause "1"') - self.sendRequest('pause "0"') + self.send_request('play "0"') + self.send_request('pause "1"') + self.send_request('pause "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_pause_on(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play "0"') - self.sendRequest('pause "1"') + self.send_request('play "0"') + self.send_request('pause "1"') self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse('OK') def test_pause_toggle(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play "0"') + self.send_request('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') - self.sendRequest('pause') + self.send_request('pause') self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse('OK') - self.sendRequest('pause') + self.send_request('pause') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_without_pos(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play') + self.send_request('play') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play "0"') + self.send_request('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_without_quotes(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('play 0') + self.send_request('play 0') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_out_of_bounds(self): self.core.tracklist.add([]) - self.sendRequest('play "0"') + self.send_request('play "0"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('ACK [2@0] {play} Bad song index') @@ -232,7 +232,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(self.core.playback.current_track.get(), None) self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.sendRequest('play "-1"') + self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:a', self.core.playback.current_track.get().uri) @@ -246,7 +246,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.stop() self.assertNotEqual(self.core.playback.current_track.get(), None) - self.sendRequest('play "-1"') + self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:b', self.core.playback.current_track.get().uri) @@ -255,7 +255,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_play_minus_one_on_empty_playlist_does_not_ack(self): self.core.tracklist.clear() - self.sendRequest('play "-1"') + self.send_request('play "-1"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse('OK') @@ -267,7 +267,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) - self.sendRequest('play "-1"') + self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -282,7 +282,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.pause() self.assertEquals(PAUSED, self.core.playback.state.get()) - self.sendRequest('play "-1"') + self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -291,14 +291,14 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('playid "0"') + self.send_request('playid "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_playid_without_quotes(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('playid 0') + self.send_request('playid 0') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') @@ -306,7 +306,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(self.core.playback.current_track.get(), None) self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.sendRequest('playid "-1"') + self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:a', self.core.playback.current_track.get().uri) @@ -320,7 +320,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.stop() self.assertNotEqual(None, self.core.playback.current_track.get()) - self.sendRequest('playid "-1"') + self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual( 'dummy:b', self.core.playback.current_track.get().uri) @@ -329,7 +329,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_minus_one_on_empty_playlist_does_not_ack(self): self.core.tracklist.clear() - self.sendRequest('playid "-1"') + self.send_request('playid "-1"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse('OK') @@ -341,7 +341,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) - self.sendRequest('playid "-1"') + self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -356,7 +356,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.pause() self.assertEquals(PAUSED, self.core.playback.state.get()) - self.sendRequest('playid "-1"') + self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -365,11 +365,11 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_which_does_not_exist(self): self.core.tracklist.add([Track(uri='dummy:a')]) - self.sendRequest('playid "12345"') + self.send_request('playid "12345"') self.assertInResponse('ACK [50@0] {playid} No such song') def test_previous(self): - self.sendRequest('previous') + self.send_request('previous') self.assertInResponse('OK') def test_seek_in_current_track(self): @@ -377,7 +377,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([seek_track]) self.core.playback.play() - self.sendRequest('seek "0" "30"') + self.send_request('seek "0" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertGreaterEqual(self.core.playback.time_position, 30000) @@ -390,7 +390,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.play() self.assertNotEqual(self.core.playback.current_track.get(), seek_track) - self.sendRequest('seek "1" "30"') + self.send_request('seek "1" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertInResponse('OK') @@ -399,7 +399,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() - self.sendRequest('seek 0 30') + self.send_request('seek 0 30') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') @@ -409,7 +409,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([seek_track]) self.core.playback.play() - self.sendRequest('seekid "0" "30"') + self.send_request('seekid "0" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertGreaterEqual( @@ -422,7 +422,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): [Track(uri='dummy:a', length=40000), seek_track]) self.core.playback.play() - self.sendRequest('seekid "1" "30"') + self.send_request('seekid "1" "30"') self.assertEqual(1, self.core.playback.current_tl_track.get().tlid) self.assertEqual(seek_track, self.core.playback.current_track.get()) @@ -432,7 +432,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() - self.sendRequest('seekcur "30"') + self.send_request('seekcur "30"') self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') @@ -443,7 +443,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(10000) self.assertGreaterEqual(self.core.playback.time_position.get(), 10000) - self.sendRequest('seekcur "+20"') + self.send_request('seekcur "+20"') self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') @@ -454,12 +454,12 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) - self.sendRequest('seekcur "-20"') + self.send_request('seekcur "-20"') self.assertLessEqual(self.core.playback.time_position.get(), 15000) self.assertInResponse('OK') def test_stop(self): - self.sendRequest('stop') + self.send_request('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('OK') diff --git a/tests/mpd/protocol/test_reflection.py b/tests/mpd/protocol/test_reflection.py index e721d799..5c44c464 100644 --- a/tests/mpd/protocol/test_reflection.py +++ b/tests/mpd/protocol/test_reflection.py @@ -5,12 +5,12 @@ from tests.mpd import protocol class ReflectionHandlerTest(protocol.BaseTestCase): def test_config_is_not_allowed_across_the_network(self): - self.sendRequest('config') + self.send_request('config') self.assertEqualResponse( 'ACK [4@0] {config} you don\'t have permission for "config"') def test_commands_returns_list_of_all_commands(self): - self.sendRequest('commands') + self.send_request('commands') # Check if some random commands are included self.assertInResponse('command: commands') self.assertInResponse('command: play') @@ -28,22 +28,22 @@ class ReflectionHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_decoders(self): - self.sendRequest('decoders') + self.send_request('decoders') self.assertInResponse('OK') def test_notcommands_returns_only_config_and_kill_and_ok(self): - response = self.sendRequest('notcommands') + response = self.send_request('notcommands') self.assertEqual(3, len(response)) self.assertInResponse('command: config') self.assertInResponse('command: kill') self.assertInResponse('OK') def test_tagtypes(self): - self.sendRequest('tagtypes') + self.send_request('tagtypes') self.assertInResponse('OK') def test_urlhandlers(self): - self.sendRequest('urlhandlers') + self.send_request('urlhandlers') self.assertInResponse('OK') self.assertInResponse('handler: dummy') @@ -55,7 +55,7 @@ class ReflectionWhenNotAuthedTest(protocol.BaseTestCase): return config def test_commands_show_less_if_auth_required_and_not_authed(self): - self.sendRequest('commands') + self.send_request('commands') # Not requiring auth self.assertInResponse('command: close') self.assertInResponse('command: commands') @@ -67,7 +67,7 @@ class ReflectionWhenNotAuthedTest(protocol.BaseTestCase): self.assertNotInResponse('command: status') def test_notcommands_returns_more_if_auth_required_and_not_authed(self): - self.sendRequest('notcommands') + self.send_request('notcommands') # Not requiring auth self.assertNotInResponse('command: close') self.assertNotInResponse('command: commands') diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index b0e9d450..09ec8a46 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -28,21 +28,21 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): ]) random.seed(1) # Playlist order: abcfde - self.sendRequest('play') + self.send_request('play') self.assertEquals( 'dummy:a', self.core.playback.current_track.get().uri) - self.sendRequest('random "1"') - self.sendRequest('next') + self.send_request('random "1"') + self.send_request('next') self.assertEquals( 'dummy:b', self.core.playback.current_track.get().uri) - self.sendRequest('next') + self.send_request('next') # Should now be at track 'c', but playback fails and it skips ahead self.assertEquals( 'dummy:f', self.core.playback.current_track.get().uri) - self.sendRequest('next') + self.send_request('next') self.assertEquals( 'dummy:d', self.core.playback.current_track.get().uri) - self.sendRequest('next') + self.send_request('next') self.assertEquals( 'dummy:e', self.core.playback.current_track.get().uri) @@ -64,17 +64,17 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) - self.sendRequest('play') - self.sendRequest('random "1"') - self.sendRequest('next') - self.sendRequest('random "0"') - self.sendRequest('next') + self.send_request('play') + self.send_request('random "1"') + self.send_request('next') + self.send_request('random "0"') + self.send_request('next') - self.sendRequest('next') + self.send_request('next') tl_track_1 = self.core.playback.current_tl_track.get() - self.sendRequest('next') + self.send_request('next') tl_track_2 = self.core.playback.current_tl_track.get() - self.sendRequest('next') + self.send_request('next') tl_track_3 = self.core.playback.current_tl_track.get() self.assertNotEqual(tl_track_1, tl_track_2) @@ -100,15 +100,15 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) - self.sendRequest('play') - self.sendRequest('random "1"') - self.sendRequest('deleteid "1"') - self.sendRequest('deleteid "2"') - self.sendRequest('deleteid "3"') - self.sendRequest('deleteid "4"') - self.sendRequest('deleteid "5"') - self.sendRequest('deleteid "6"') - self.sendRequest('status') + self.send_request('play') + self.send_request('random "1"') + self.send_request('deleteid "1"') + self.send_request('deleteid "2"') + self.send_request('deleteid "3"') + self.send_request('deleteid "4"') + self.send_request('deleteid "5"') + self.send_request('deleteid "6"') + self.send_request('status') class IssueGH69RegressionTest(protocol.BaseTestCase): @@ -128,10 +128,10 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) - self.sendRequest('play') - self.sendRequest('stop') - self.sendRequest('clear') - self.sendRequest('load "foo"') + self.send_request('play') + self.send_request('stop') + self.send_request('clear') + self.send_request('load "foo"') self.assertNotInResponse('song: None') @@ -151,11 +151,11 @@ class IssueGH113RegressionTest(protocol.BaseTestCase): self.core.playlists.create( u'all lart spotify:track:\w\{22\} pastes') - self.sendRequest('lsinfo "/"') + self.send_request('lsinfo "/"') self.assertInResponse( u'playlist: all lart spotify:track:\w\{22\} pastes') - self.sendRequest( + self.send_request( r'listplaylistinfo "all lart spotify:track:\\w\\{22\\} pastes"') self.assertInResponse('OK') @@ -170,7 +170,7 @@ class IssueGH137RegressionTest(protocol.BaseTestCase): """ def test(self): - self.sendRequest( + self.send_request( u'list Date Artist "Anita Ward" ' u'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"') diff --git a/tests/mpd/protocol/test_status.py b/tests/mpd/protocol/test_status.py index 87e63a1a..09df3526 100644 --- a/tests/mpd/protocol/test_status.py +++ b/tests/mpd/protocol/test_status.py @@ -7,14 +7,14 @@ from tests.mpd import protocol class StatusHandlerTest(protocol.BaseTestCase): def test_clearerror(self): - self.sendRequest('clearerror') + self.send_request('clearerror') self.assertEqualResponse('ACK [0@0] {clearerror} Not implemented') def test_currentsong(self): track = Track() self.core.tracklist.add([track]) self.core.playback.play() - self.sendRequest('currentsong') + self.send_request('currentsong') self.assertInResponse('file: ') self.assertInResponse('Time: 0') self.assertInResponse('Artist: ') @@ -27,13 +27,13 @@ class StatusHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_currentsong_without_song(self): - self.sendRequest('currentsong') + self.send_request('currentsong') self.assertInResponse('OK') def test_stats_command(self): - self.sendRequest('stats') + self.send_request('stats') self.assertInResponse('OK') def test_status_command(self): - self.sendRequest('status') + self.send_request('status') self.assertInResponse('OK') diff --git a/tests/mpd/protocol/test_stickers.py b/tests/mpd/protocol/test_stickers.py index 9eae1ac6..0844c461 100644 --- a/tests/mpd/protocol/test_stickers.py +++ b/tests/mpd/protocol/test_stickers.py @@ -5,31 +5,31 @@ from tests.mpd import protocol class StickersHandlerTest(protocol.BaseTestCase): def test_sticker_get(self): - self.sendRequest( + self.send_request( 'sticker get "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_set(self): - self.sendRequest( + self.send_request( 'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_delete_with_name(self): - self.sendRequest( + self.send_request( 'sticker delete "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_delete_without_name(self): - self.sendRequest( + self.send_request( 'sticker delete "song" "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_list(self): - self.sendRequest( + self.send_request( 'sticker list "song" "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_find(self): - self.sendRequest( + self.send_request( 'sticker find "song" "file:///dev/urandom" "a_name"') self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index bc66387a..a9190aa1 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -11,7 +11,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] - self.sendRequest('listplaylist "name"') + self.send_request('listplaylist "name"') self.assertInResponse('file: dummy:a') self.assertInResponse('OK') @@ -20,12 +20,12 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] - self.sendRequest('listplaylist name') + self.send_request('listplaylist name') self.assertInResponse('file: dummy:a') self.assertInResponse('OK') def test_listplaylist_fails_if_no_playlist_is_found(self): - self.sendRequest('listplaylist "name"') + self.send_request('listplaylist "name"') self.assertEqualResponse('ACK [50@0] {listplaylist} No such playlist') def test_listplaylist_duplicate(self): @@ -33,7 +33,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) self.backend.playlists.playlists = [playlist1, playlist2] - self.sendRequest('listplaylist "a [2]"') + self.send_request('listplaylist "a [2]"') self.assertInResponse('file: c') self.assertInResponse('OK') @@ -42,7 +42,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] - self.sendRequest('listplaylistinfo "name"') + self.send_request('listplaylistinfo "name"') self.assertInResponse('file: dummy:a') self.assertInResponse('Track: 0') self.assertNotInResponse('Pos: 0') @@ -53,14 +53,14 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] - self.sendRequest('listplaylistinfo name') + self.send_request('listplaylistinfo name') self.assertInResponse('file: dummy:a') self.assertInResponse('Track: 0') self.assertNotInResponse('Pos: 0') self.assertInResponse('OK') def test_listplaylistinfo_fails_if_no_playlist_is_found(self): - self.sendRequest('listplaylistinfo "name"') + self.send_request('listplaylistinfo "name"') self.assertEqualResponse( 'ACK [50@0] {listplaylistinfo} No such playlist') @@ -69,7 +69,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) self.backend.playlists.playlists = [playlist1, playlist2] - self.sendRequest('listplaylistinfo "a [2]"') + self.send_request('listplaylistinfo "a [2]"') self.assertInResponse('file: c') self.assertInResponse('Track: 0') self.assertNotInResponse('Pos: 0') @@ -80,7 +80,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.backend.playlists.playlists = [ Playlist(name='a', uri='dummy:a', last_modified=last_modified)] - self.sendRequest('listplaylists') + self.send_request('listplaylists') self.assertInResponse('playlist: a') # Date without milliseconds and with time zone information self.assertInResponse('Last-Modified: 2014-01-28T21:01:13Z') @@ -91,7 +91,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): playlist2 = Playlist(name='a', uri='dummy:a2') self.backend.playlists.playlists = [playlist1, playlist2] - self.sendRequest('listplaylists') + self.send_request('listplaylists') self.assertInResponse('playlist: a') self.assertInResponse('playlist: a [2]') self.assertInResponse('OK') @@ -101,14 +101,14 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.backend.playlists.playlists = [ Playlist(name='', uri='dummy:', last_modified=last_modified)] - self.sendRequest('listplaylists') + self.send_request('listplaylists') self.assertNotInResponse('playlist: ') self.assertInResponse('OK') def test_listplaylists_replaces_newline_with_space(self): self.backend.playlists.playlists = [ Playlist(name='a\n', uri='dummy:')] - self.sendRequest('listplaylists') + self.send_request('listplaylists') self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\n') self.assertInResponse('OK') @@ -116,7 +116,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_replaces_carriage_return_with_space(self): self.backend.playlists.playlists = [ Playlist(name='a\r', uri='dummy:')] - self.sendRequest('listplaylists') + self.send_request('listplaylists') self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\r') self.assertInResponse('OK') @@ -124,7 +124,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_replaces_forward_slash_with_pipe(self): self.backend.playlists.playlists = [ Playlist(name='a/b', uri='dummy:')] - self.sendRequest('listplaylists') + self.send_request('listplaylists') self.assertInResponse('playlist: a|b') self.assertNotInResponse('playlist: a/b') self.assertInResponse('OK') @@ -136,7 +136,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])] - self.sendRequest('load "A-list"') + self.send_request('load "A-list"') tracks = self.core.tracklist.tracks.get() self.assertEqual(5, len(tracks)) @@ -154,7 +154,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])] - self.sendRequest('load "A-list" "1:2"') + self.send_request('load "A-list" "1:2"') tracks = self.core.tracklist.tracks.get() self.assertEqual(3, len(tracks)) @@ -170,7 +170,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])] - self.sendRequest('load "A-list" "1:"') + self.send_request('load "A-list" "1:"') tracks = self.core.tracklist.tracks.get() self.assertEqual(4, len(tracks)) @@ -181,34 +181,34 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_load_unknown_playlist_acks(self): - self.sendRequest('load "unknown playlist"') + self.send_request('load "unknown playlist"') self.assertEqual(0, len(self.core.tracklist.tracks.get())) self.assertEqualResponse('ACK [50@0] {load} No such playlist') def test_playlistadd(self): - self.sendRequest('playlistadd "name" "dummy:a"') + self.send_request('playlistadd "name" "dummy:a"') self.assertEqualResponse('ACK [0@0] {playlistadd} Not implemented') def test_playlistclear(self): - self.sendRequest('playlistclear "name"') + self.send_request('playlistclear "name"') self.assertEqualResponse('ACK [0@0] {playlistclear} Not implemented') def test_playlistdelete(self): - self.sendRequest('playlistdelete "name" "5"') + self.send_request('playlistdelete "name" "5"') self.assertEqualResponse('ACK [0@0] {playlistdelete} Not implemented') def test_playlistmove(self): - self.sendRequest('playlistmove "name" "5" "10"') + self.send_request('playlistmove "name" "5" "10"') self.assertEqualResponse('ACK [0@0] {playlistmove} Not implemented') def test_rename(self): - self.sendRequest('rename "old_name" "new_name"') + self.send_request('rename "old_name" "new_name"') self.assertEqualResponse('ACK [0@0] {rename} Not implemented') def test_rm(self): - self.sendRequest('rm "name"') + self.send_request('rm "name"') self.assertEqualResponse('ACK [0@0] {rm} Not implemented') def test_save(self): - self.sendRequest('save "name"') + self.send_request('save "name"') self.assertEqualResponse('ACK [0@0] {save} Not implemented') From ab49d75a45a97615bdfe78aba50f731e757e6b4b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 30 Dec 2014 00:14:49 +0100 Subject: [PATCH 206/495] tests: Fix outstanding flake8 errors in tests --- mopidy/config/types.py | 4 ++-- mopidy/mpd/protocol/__init__.py | 8 ++++---- tests/config/test_schemas.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/config/types.py b/mopidy/config/types.py index cd3905ac..bed03fa2 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -25,8 +25,8 @@ def encode(value): class ExpandedPath(bytes): - def __new__(self, original, expanded): - return super(ExpandedPath, self).__new__(self, expanded) + def __new__(cls, original, expanded): + return super(ExpandedPath, cls).__new__(cls, expanded) def __init__(self, original, expanded): self.original = original diff --git a/mopidy/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py index 38fcb33a..ff04d435 100644 --- a/mopidy/mpd/protocol/__init__.py +++ b/mopidy/mpd/protocol/__init__.py @@ -36,7 +36,7 @@ def load_protocol_modules(): music_db, playback, reflection, status, stickers, stored_playlists) -def INT(value): +def INT(value): # noqa: N802 """Converts a value that matches [+-]?\d+ into and integer.""" if value is None: raise ValueError('None is not a valid integer') @@ -44,7 +44,7 @@ def INT(value): return int(value) -def UINT(value): +def UINT(value): # noqa: N802 """Converts a value that matches \d+ into an integer.""" if value is None: raise ValueError('None is not a valid integer') @@ -53,14 +53,14 @@ def UINT(value): return int(value) -def BOOL(value): +def BOOL(value): # noqa: N802 """Convert the values 0 and 1 into booleans.""" if value in ('1', '0'): return bool(int(value)) raise ValueError('%r is not 0 or 1' % value) -def RANGE(value): +def RANGE(value): # noqa: N802 """Convert a single integer or range spec into a slice ``n`` should become ``slice(n, n+1)`` diff --git a/tests/config/test_schemas.py b/tests/config/test_schemas.py index f9e64b9b..8412b899 100644 --- a/tests/config/test_schemas.py +++ b/tests/config/test_schemas.py @@ -97,7 +97,7 @@ class LogLevelConfigSchemaTest(unittest.TestCase): class DidYouMeanTest(unittest.TestCase): - def testSuggestoins(self): + def test_suggestions(self): choices = ('enabled', 'username', 'password', 'bitrate', 'timeout') suggestion = schemas._did_you_mean('bitrate', choices) From c0ae202670c716ac2dbe6f7d11ce4bc6ad677196 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 30 Dec 2014 00:25:33 +0100 Subject: [PATCH 207/495] tests: Update tox flake8 settings Run with show source so we can see the context in travis errors. Add stats for number of errors. And limit ourselves to mopidy and tests as I happen to have a tmp folder with non-conforming proof of concept code that I don't want this to check. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b80996e5..57be519a 100644 --- a/tox.ini +++ b/tox.ini @@ -30,4 +30,4 @@ commands = sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html deps = flake8 flake8-import-order -commands = flake8 +commands = flake8 --show-source --statistics mopidy tests From f30ca831b6a3b15c81174a7e2b76fd6ab2c7ec06 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 30 Dec 2014 00:32:03 +0100 Subject: [PATCH 208/495] tests: Add pep8-naming to tox flake8 env --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 57be519a..277ae9d3 100644 --- a/tox.ini +++ b/tox.ini @@ -30,4 +30,5 @@ commands = sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html deps = flake8 flake8-import-order + pep8-naming commands = flake8 --show-source --statistics mopidy tests From 8d913b56dac1e5e00e6b7d1d235f93d908409e52 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 30 Dec 2014 00:39:31 +0100 Subject: [PATCH 209/495] docs: Add note about noqa use --- docs/contributing.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 8526f192..c94ef6ad 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -101,7 +101,9 @@ Mopidy to come with tests. flake8 - If successful, the command will not print anything at all. + If successful, the command will not print anything at all. Ignore the rare + cases you need to ignore a check use `# noqa: ` so we can lookup what + you are ignoring. #. Finally, there is the ultimate but a bit slower command. To run both tests, docs build, and flake8 linting, run:: From 158a448e2b1e37305e59073f096546400d83ff00 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 30 Dec 2014 11:58:57 +0100 Subject: [PATCH 210/495] audio: Fix bug in missing plugin code path --- mopidy/audio/actor.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 63c6a80b..ccb802a4 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -51,10 +51,6 @@ MB = 1 << 20 # Default flags to use for playbin: AUDIO, SOFT_VOLUME, DOWNLOAD PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7) -# These are just to long to wrap nicely, so rename them locally. -_get_missing_description = gst.pbutils.missing_plugin_message_get_description -_get_missing_detail = gst.pbutils.missing_plugin_message_get_installer_detail - class _Signals(object): """Helper for tracking gobject signal registrations""" @@ -307,8 +303,7 @@ class _Handler(object): self.on_tag(msg.parse_tag()) elif msg.type == gst.MESSAGE_ELEMENT: if gst.pbutils.is_missing_plugin_message(msg): - self.on_missing_plugin(_get_missing_description(msg), - _get_missing_detail(msg)) + self.on_missing_plugin(msg) def on_event(self, pad, event): if event.type == gst.EVENT_NEWSEGMENT: From edcad494dabf4dc9ba3b86d2e50ac538ac60a0dd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Jan 2015 12:45:20 +0100 Subject: [PATCH 211/495] New year --- docs/authors.rst | 2 +- docs/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/authors.rst b/docs/authors.rst index 7c00e2ac..1a0f21ed 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -4,7 +4,7 @@ Authors ******* -Mopidy is copyright 2009-2014 Stein Magnus Jodal and contributors. Mopidy is +Mopidy is copyright 2009-2015 Stein Magnus Jodal and contributors. Mopidy is licensed under the `Apache License, Version 2.0 `_. diff --git a/docs/conf.py b/docs/conf.py index c300aa62..938ec87b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -97,7 +97,7 @@ source_suffix = '.rst' master_doc = 'index' project = 'Mopidy' -copyright = '2009-2014, Stein Magnus Jodal and contributors' +copyright = '2009-2015, Stein Magnus Jodal and contributors' from mopidy.utils.versioning import get_version release = get_version() From 78b39390e31d1e677fc630c8afea175991a2c1bc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Jan 2015 12:47:37 +0100 Subject: [PATCH 212/495] docs: Docs site has HTTPS now --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index a70eef7e..1da79a6e 100644 --- a/README.rst +++ b/README.rst @@ -49,8 +49,8 @@ Spotify. To get started with Mopidy, check out `the installation docs `_. -- `Documentation `_ -- `Discuss `_ +- `Documentation `_ +- `Discussion forum `_ - `Source code `_ - `Issue tracker `_ - `Development branch tarball `_ From 1e7a4247c69923c31a92d2ab6c0d0ffc8cd8970b Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 4 Jan 2015 20:21:10 +0000 Subject: [PATCH 213/495] Add non-ascii utf-8 chars to test playlists --- tests/audio/test_playlists.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/audio/test_playlists.py b/tests/audio/test_playlists.py index eb79cfeb..f01568f8 100644 --- a/tests/audio/test_playlists.py +++ b/tests/audio/test_playlists.py @@ -13,7 +13,7 @@ BAD = b'foobarbaz' M3U = b"""#EXTM3U #EXTINF:123, Sample artist - Sample title file:///tmp/foo -#EXTINF:321,Example Artist - Example title +#EXTINF:321,Example Artist - Example \xc5\xa7\xc5\x95 file:///tmp/bar #EXTINF:213,Some Artist - Other title file:///tmp/baz @@ -25,7 +25,7 @@ File1=file:///tmp/foo Title1=Sample Title Length1=123 File2=file:///tmp/bar -Title2=Example title +Title2=Example \xc5\xa7\xc5\x95 Length2=321 File3=file:///tmp/baz Title3=Other title @@ -40,7 +40,7 @@ ASX = b""" - Example title + Example \xc5\xa7\xc5\x95 @@ -65,7 +65,7 @@ XSPF = b""" file:///tmp/foo - Example title + Example \xc5\xa7\xc5\x95 file:///tmp/bar From d666083c6fe8fe17a5880b6e8e55890cf51992c4 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 4 Jan 2015 20:26:45 +0000 Subject: [PATCH 214/495] Use bytestrings when parsing M3U playlists Fixes #853 --- mopidy/audio/playlists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 5688db4b..5a362191 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -58,11 +58,11 @@ def parse_m3u(data): # TODO: convert non URIs to file URIs. found_header = False for line in data.readlines(): - if found_header or line.startswith('#EXTM3U'): + if found_header or line.startswith(b'#EXTM3U'): found_header = True else: continue - if not line.startswith('#') and line.strip(): + if not line.startswith(b'#') and line.strip(): yield line.strip() From e252dd3a555679cbf49e77ee03c1df7599dabc38 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 4 Jan 2015 20:54:13 +0000 Subject: [PATCH 215/495] Update changelog --- docs/changelog.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index a4d5ee44..8cfb9cdf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -93,6 +93,14 @@ v0.20.0 (UNRELEASED) - Add basic tests for the stream library provider. +v0.19.6 (UNRELEASED) +==================== + +**Audio** + +- Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) + + v0.19.5 (2014-12-23) ==================== From 767aa0868552728c8d731b3ac60978cd8c162bcb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Jan 2015 12:45:20 +0100 Subject: [PATCH 216/495] New year (cherry picked from commit edcad494dabf4dc9ba3b86d2e50ac538ac60a0dd) --- docs/authors.rst | 2 +- docs/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/authors.rst b/docs/authors.rst index 7c00e2ac..1a0f21ed 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -4,7 +4,7 @@ Authors ******* -Mopidy is copyright 2009-2014 Stein Magnus Jodal and contributors. Mopidy is +Mopidy is copyright 2009-2015 Stein Magnus Jodal and contributors. Mopidy is licensed under the `Apache License, Version 2.0 `_. diff --git a/docs/conf.py b/docs/conf.py index f7475293..f9cdd613 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -96,7 +96,7 @@ source_suffix = '.rst' master_doc = 'index' project = 'Mopidy' -copyright = '2009-2014, Stein Magnus Jodal and contributors' +copyright = '2009-2015, Stein Magnus Jodal and contributors' from mopidy.utils.versioning import get_version release = get_version() From 3a28aa299987feab5c501e0824ee8632eaceb633 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Jan 2015 12:47:37 +0100 Subject: [PATCH 217/495] docs: Docs site has HTTPS now (cherry picked from commit 78b39390e31d1e677fc630c8afea175991a2c1bc) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index a70eef7e..1da79a6e 100644 --- a/README.rst +++ b/README.rst @@ -49,8 +49,8 @@ Spotify. To get started with Mopidy, check out `the installation docs `_. -- `Documentation `_ -- `Discuss `_ +- `Documentation `_ +- `Discussion forum `_ - `Source code `_ - `Issue tracker `_ - `Development branch tarball `_ From fe8a9f8c39b3057bab265c2b9f4319c56e9c849b Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 4 Jan 2015 20:21:10 +0000 Subject: [PATCH 218/495] Add non-ascii utf-8 chars to test playlists (cherry picked from commit 1e7a4247c69923c31a92d2ab6c0d0ffc8cd8970b) --- tests/audio/test_playlists.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/audio/test_playlists.py b/tests/audio/test_playlists.py index 51c36eac..f8120941 100644 --- a/tests/audio/test_playlists.py +++ b/tests/audio/test_playlists.py @@ -13,7 +13,7 @@ BAD = b'foobarbaz' M3U = b"""#EXTM3U #EXTINF:123, Sample artist - Sample title file:///tmp/foo -#EXTINF:321,Example Artist - Example title +#EXTINF:321,Example Artist - Example \xc5\xa7\xc5\x95 file:///tmp/bar #EXTINF:213,Some Artist - Other title file:///tmp/baz @@ -25,7 +25,7 @@ File1=file:///tmp/foo Title1=Sample Title Length1=123 File2=file:///tmp/bar -Title2=Example title +Title2=Example \xc5\xa7\xc5\x95 Length2=321 File3=file:///tmp/baz Title3=Other title @@ -40,7 +40,7 @@ ASX = b""" - Example title + Example \xc5\xa7\xc5\x95 @@ -65,7 +65,7 @@ XSPF = b""" file:///tmp/foo - Example title + Example \xc5\xa7\xc5\x95 file:///tmp/bar From 7556af83b7216390df77b2a8a6705c4651ea4046 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 4 Jan 2015 20:26:45 +0000 Subject: [PATCH 219/495] Use bytestrings when parsing M3U playlists Fixes #853 (cherry picked from commit d666083c6fe8fe17a5880b6e8e55890cf51992c4) --- mopidy/audio/playlists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 35e0800d..ec5fd63a 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -57,11 +57,11 @@ def parse_m3u(data): # TODO: convert non URIs to file URIs. found_header = False for line in data.readlines(): - if found_header or line.startswith('#EXTM3U'): + if found_header or line.startswith(b'#EXTM3U'): found_header = True else: continue - if not line.startswith('#') and line.strip(): + if not line.startswith(b'#') and line.strip(): yield line.strip() From 73fac53dd59cba0d2344a498c4b45f6c76140653 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 4 Jan 2015 20:54:13 +0000 Subject: [PATCH 220/495] Update changelog (cherry picked from commit e252dd3a555679cbf49e77ee03c1df7599dabc38) Conflicts: docs/changelog.rst --- docs/changelog.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1b88c489..40d5cedc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,14 @@ Changelog This changelog is used to track all major changes to Mopidy. +v0.19.6 (UNRELEASED) +==================== + +Bug fix release. + +- Audio: Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) + + v0.19.5 (2014-12-23) ==================== From dac5f9e406f3c205d6ed212d4414ca55c94b8f15 Mon Sep 17 00:00:00 2001 From: Ali Ukani Date: Mon, 5 Jan 2015 23:23:55 -0500 Subject: [PATCH 221/495] Add test for exact search with album query --- tests/local/test_search.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/local/test_search.py diff --git a/tests/local/test_search.py b/tests/local/test_search.py new file mode 100644 index 00000000..2a704e48 --- /dev/null +++ b/tests/local/test_search.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + +import unittest + +from mopidy.local import search +from mopidy.models import Album, Track + + +class LocalLibrarySearchTest(unittest.TestCase): + def test_find_exact_with_album_query(self): + expected_tracks = [Track(album=Album(name='foo'))] + tracks = [Track(), Track(album=Album(name='bar'))] + expected_tracks + + search_result = search.find_exact(tracks, {'album': ['foo']}) + + self.assertEqual(search_result.tracks, tuple(expected_tracks)) From 20751a5ad75e5b73edc0325fde559b49148014a2 Mon Sep 17 00:00:00 2001 From: Ali Ukani Date: Mon, 5 Jan 2015 23:26:34 -0500 Subject: [PATCH 222/495] Fix album filter: Should work when track's album is an Album or None --- mopidy/local/search.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 947902ed..bc46c33e 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -1,6 +1,6 @@ from __future__ import absolute_import, unicode_literals -from mopidy.models import Album, SearchResult +from mopidy.models import SearchResult def find_exact(tracks, query=None, uris=None): @@ -23,7 +23,8 @@ def find_exact(tracks, query=None, uris=None): uri_filter = lambda t: q == t.uri track_name_filter = lambda t: q == t.name - album_filter = lambda t: q == getattr(t, 'album', Album()).name + album_filter = lambda t: q == getattr( + getattr(t, 'album', None), 'name', None) artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) albumartist_filter = lambda t: any([ From ec94449a63d59afea4cdb2f62bd742926b44b827 Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Thu, 15 Jan 2015 22:25:34 +0100 Subject: [PATCH 223/495] Handle tags_changed in Core and send event to CoreListener --- mopidy/core/actor.py | 20 ++++++++++++++++++++ mopidy/core/listener.py | 11 +++++++++++ 2 files changed, 31 insertions(+) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 75c06f69..ccd1e4c5 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -102,6 +102,26 @@ class Core( # Forward event from mixer to frontends CoreListener.send('mute_changed', mute=mute) + def tags_changed(self, tags): + # Should return only one audio instance + audios = pykka.ActorRegistry.get_by_class(audio.Audio) + + if audios and len(audios) == 1: + audio_proxy = audios[0].proxy() + + # Gets metadata + future = audio_proxy.get_current_tags() + tags_data = future.get() + if not tags_data or not isinstance(tags_data, dict): + return + + # Convert to track and set playback + track = audio.utils.convert_tags_to_track(tags_data) + self.playback.current_track = track + + # Send event to frontends + CoreListener.send('track_metadata_changed', track_metadata=track) + class Backends(list): def __init__(self, backends): diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 2c027e1b..c94037b2 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -163,3 +163,14 @@ class CoreListener(listener.Listener): :type time_position: int """ pass + + def track_metadata_changed(self, track_metadata): + """ + Called whenever current track's metadata changed + + *MAY* be implemented by actor. + + :param track_metadata: the track with metadata + :type track_metadata: :class:`mopidy.models.Track` + """ + pass From e4dd04cfb77e0cd930a84ad4f7d983a5248a4b90 Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Fri, 16 Jan 2015 21:41:55 +0100 Subject: [PATCH 224/495] One step beyond --- mopidy/core/actor.py | 12 +++--------- mopidy/core/listener.py | 5 +---- mopidy/core/playback.py | 8 ++++++++ mopidy/mpd/actor.py | 3 +++ mopidy/mpd/protocol/current_playlist.py | 4 ++++ mopidy/mpd/translator.py | 22 ++++++++++++++++++++++ 6 files changed, 41 insertions(+), 13 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index ccd1e4c5..15a94665 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -109,18 +109,12 @@ class Core( if audios and len(audios) == 1: audio_proxy = audios[0].proxy() - # Gets metadata + # Request available metadata and put in playback future = audio_proxy.get_current_tags() - tags_data = future.get() - if not tags_data or not isinstance(tags_data, dict): - return - - # Convert to track and set playback - track = audio.utils.convert_tags_to_track(tags_data) - self.playback.current_track = track + self.playback.current_metadata = future.get() # Send event to frontends - CoreListener.send('track_metadata_changed', track_metadata=track) + CoreListener.send('current_metadata_changed') class Backends(list): diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index c94037b2..9d952473 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -164,13 +164,10 @@ class CoreListener(listener.Listener): """ pass - def track_metadata_changed(self, track_metadata): + def current_metadata_changed(self): """ Called whenever current track's metadata changed *MAY* be implemented by actor. - - :param track_metadata: the track with metadata - :type track_metadata: :class:`mopidy.models.Track` """ pass diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ef3cc4b2..4b5f4b77 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -126,6 +126,14 @@ class PlaybackController(object): mute = property(get_mute, set_mute) """Mute state as a :class:`True` if muted, :class:`False` otherwise""" + def get_current_metadata(self): + return self.current_metadata + + current_metadata = None + """ + The currently playing metadata :class:`dict`, or :class:`None`. + """ + # Methods # TODO: remove this. diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index c8123c32..1f213812 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -71,3 +71,6 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def mute_changed(self, mute): self.send_idle('output') + + def current_metadata_changed(self): + self.send_idle('playlist') diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 33c090e3..09121df1 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -278,6 +278,10 @@ def plchanges(context, version): if int(version) < context.core.tracklist.version.get(): return translator.tracks_to_mpd_format( context.core.tracklist.tl_tracks.get()) + elif int(version) == context.core.tracklist.version.get(): + return translator.metadata_track_to_mpd_format( + context.core.playback.current_tl_track.get(), + context.core.playback.current_metadata.get()) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 23fb2874..95b1b263 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -86,6 +86,28 @@ def track_to_mpd_format(track, position=None): return result +def metadata_track_to_mpd_format(track, metadata): + # TODO: replace track data with metadata + result = [] + if track: + if isinstance(track, TlTrack): + (tlid, track) = track + else: + (tlid, track) = (None, track) + result = [ + ('file', track.uri or ''), + ('Time', track.length and (track.length // 1000) or 0), + ('Artist', artists_to_mpd_format(track.artists)), + ('Album', track.album and track.album.name or ''), + ] + if metadata and 'title' in metadata: + result.append(('Title', metadata['title'])) + else: + result.append(('Title', track.name or '')) + + return result + + def artists_to_mpd_format(artists): """ Format track artists for output to MPD client. From 7ee1935315ab0499e2c3f6ba8ca65cbb1794a27b Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Fri, 16 Jan 2015 23:45:07 +0100 Subject: [PATCH 225/495] MPD gets metadata's updates from stream --- mopidy/core/actor.py | 4 ++- mopidy/core/playback.py | 9 +++--- mopidy/mpd/protocol/current_playlist.py | 4 +-- mopidy/mpd/translator.py | 37 ++++++++++++------------- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 15a94665..4ad38cb6 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -7,6 +7,7 @@ import pykka from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState +from mopidy.audio.utils import convert_tags_to_track from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener @@ -111,7 +112,8 @@ class Core( # Request available metadata and put in playback future = audio_proxy.get_current_tags() - self.playback.current_metadata = future.get() + mtdata = future.get() + self.playback.current_md_track = convert_tags_to_track(mtdata) # Send event to frontends CoreListener.send('current_metadata_changed') diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 4b5f4b77..ad99e6ec 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -126,12 +126,13 @@ class PlaybackController(object): mute = property(get_mute, set_mute) """Mute state as a :class:`True` if muted, :class:`False` otherwise""" - def get_current_metadata(self): - return self.current_metadata + def get_current_metadata_track(self): + return self.current_md_track - current_metadata = None + current_md_track = None """ - The currently playing metadata :class:`dict`, or :class:`None`. + The currently playing metadata :class:`mopidy.models.Track`, + or :class:`None`. """ # Methods diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 09121df1..d5464791 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -278,10 +278,10 @@ def plchanges(context, version): if int(version) < context.core.tracklist.version.get(): return translator.tracks_to_mpd_format( context.core.tracklist.tl_tracks.get()) - elif int(version) == context.core.tracklist.version.get(): + else: return translator.metadata_track_to_mpd_format( context.core.playback.current_tl_track.get(), - context.core.playback.current_metadata.get()) + context.core.playback.current_md_track.get()) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 95b1b263..0788b2d6 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import re -from mopidy.models import TlTrack +from mopidy.models import TlTrack, Track # TODO: special handling of local:// uri scheme normalize_path_re = re.compile(r'[^/]+') @@ -87,25 +87,24 @@ def track_to_mpd_format(track, position=None): def metadata_track_to_mpd_format(track, metadata): - # TODO: replace track data with metadata - result = [] - if track: - if isinstance(track, TlTrack): - (tlid, track) = track - else: - (tlid, track) = (None, track) - result = [ - ('file', track.uri or ''), - ('Time', track.length and (track.length // 1000) or 0), - ('Artist', artists_to_mpd_format(track.artists)), - ('Album', track.album and track.album.name or ''), - ] - if metadata and 'title' in metadata: - result.append(('Title', metadata['title'])) - else: - result.append(('Title', track.name or '')) + """ + Create new Track with a mix of track and metadata + and convert it to mpd format + """ + # Sanity check + if track is None or metadata is None: + return None - return result + # + if isinstance(track, TlTrack): + (tlid, track) = track + + track_kwargs = {k: v for k, v in track.__dict__.items() if v} + for k, v in metadata.__dict__.items(): + if v: + track_kwargs[k] = v + result_track = Track(**track_kwargs) + return track_to_mpd_format(result_track) def artists_to_mpd_format(artists): From eeed2973f1236a25b598586c1634cbc225d359d9 Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Sat, 17 Jan 2015 14:30:40 +0100 Subject: [PATCH 226/495] Fix metadata refresh with more than one pl in tracklist --- mopidy/core/actor.py | 32 ++++++++++++++++++------- mopidy/mpd/protocol/current_playlist.py | 23 ++++++++++++++---- mopidy/mpd/translator.py | 23 +----------------- 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 4ad38cb6..dbcfc9a1 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -14,6 +14,7 @@ from mopidy.core.listener import CoreListener from mopidy.core.playback import PlaybackController from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController +from mopidy.models import TlTrack, Track from mopidy.utils import versioning @@ -107,16 +108,31 @@ class Core( # Should return only one audio instance audios = pykka.ActorRegistry.get_by_class(audio.Audio) - if audios and len(audios) == 1: - audio_proxy = audios[0].proxy() + # Validity checks + if audios is None or len(audios) != 1: + return + if self.playback.current_tl_track is None: + return - # Request available metadata and put in playback - future = audio_proxy.get_current_tags() - mtdata = future.get() - self.playback.current_md_track = convert_tags_to_track(mtdata) + audio_proxy = audios[0].proxy() - # Send event to frontends - CoreListener.send('current_metadata_changed') + # Request available metadata and set a track + future = audio_proxy.get_current_tags() + mt_track = convert_tags_to_track(future.get()) + + # Merge current_tl_track with metadata in current_md_track + c_track = self.playback.current_tl_track.track + track_kwargs = {k: v for k, v in c_track.__dict__.items() if v} + for k, v in mt_track.__dict__.items(): + if v: + track_kwargs[k] = v + + self.playback.current_md_track = TlTrack(**{ + 'tlid': self.playback.current_tl_track.tlid, + 'track': Track(**track_kwargs)}) + + # Send event to frontends + CoreListener.send('current_metadata_changed') class Backends(list): diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index d5464791..bc89afcb 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -275,13 +275,26 @@ def plchanges(context, version): - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # XXX Naive implementation that returns all tracks as changed - if int(version) < context.core.tracklist.version.get(): + tracklist_version = context.core.tracklist.version.get() + iversion = int(version) + if iversion < tracklist_version: return translator.tracks_to_mpd_format( context.core.tracklist.tl_tracks.get()) - else: - return translator.metadata_track_to_mpd_format( - context.core.playback.current_tl_track.get(), - context.core.playback.current_md_track.get()) + elif iversion == tracklist_version: + # If version are equals, it is just a metadata update + # So we replace the updated track in playlist + current_md_track = context.core.playback.current_md_track.get() + if current_md_track is None: + return None + + ntl_tracks = [] + tl_tracks = context.core.tracklist.tl_tracks.get() + for tl_track in tl_tracks: + if tl_track.tlid == current_md_track.tlid: + ntl_tracks.append(current_md_track) + else: + ntl_tracks.append(tl_track) + return translator.tracks_to_mpd_format(ntl_tracks) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 0788b2d6..23fb2874 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import re -from mopidy.models import TlTrack, Track +from mopidy.models import TlTrack # TODO: special handling of local:// uri scheme normalize_path_re = re.compile(r'[^/]+') @@ -86,27 +86,6 @@ def track_to_mpd_format(track, position=None): return result -def metadata_track_to_mpd_format(track, metadata): - """ - Create new Track with a mix of track and metadata - and convert it to mpd format - """ - # Sanity check - if track is None or metadata is None: - return None - - # - if isinstance(track, TlTrack): - (tlid, track) = track - - track_kwargs = {k: v for k, v in track.__dict__.items() if v} - for k, v in metadata.__dict__.items(): - if v: - track_kwargs[k] = v - result_track = Track(**track_kwargs) - return track_to_mpd_format(result_track) - - def artists_to_mpd_format(artists): """ Format track artists for output to MPD client. From ef950a5e15c97eb1445a283afbc6760eb2ec7f73 Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Tue, 20 Jan 2015 20:01:54 +0100 Subject: [PATCH 227/495] Adds audio in Core --- mopidy/commands.py | 6 +++--- mopidy/core/actor.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/mopidy/commands.py b/mopidy/commands.py index fecabe98..d9b4ce0e 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -279,7 +279,7 @@ class RootCommand(Command): mixer = self.start_mixer(config, mixer_class) audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) - core = self.start_core(mixer, backends) + core = self.start_core(mixer, backends, audio) self.start_frontends(config, frontend_classes, core) loop.run() except (exceptions.BackendError, @@ -360,9 +360,9 @@ class RootCommand(Command): return backends - def start_core(self, mixer, backends): + def start_core(self, mixer, backends, audio): logger.info('Starting Mopidy core') - return Core.start(mixer=mixer, backends=backends).proxy() + return Core.start(mixer=mixer, backends=backends, audio=audio).proxy() def start_frontends(self, config, frontend_classes, core): logger.info( diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index dbcfc9a1..60de442a 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -42,7 +42,7 @@ class Core( """The tracklist controller. An instance of :class:`mopidy.core.TracklistController`.""" - def __init__(self, mixer=None, backends=None): + def __init__(self, mixer=None, backends=None, audio=None): super(Core, self).__init__() self.backends = Backends(backends) @@ -59,6 +59,8 @@ class Core( self.tracklist = TracklistController(core=self) + self.audio = audio + def get_uri_schemes(self): futures = [b.uri_schemes for b in self.backends] results = pykka.get_all(futures) @@ -105,20 +107,18 @@ class Core( CoreListener.send('mute_changed', mute=mute) def tags_changed(self, tags): - # Should return only one audio instance - audios = pykka.ActorRegistry.get_by_class(audio.Audio) - # Validity checks - if audios is None or len(audios) != 1: + if not self.audio: return if self.playback.current_tl_track is None: return - audio_proxy = audios[0].proxy() + tags = self.audio.get_current_tags().get() + if not tags: + return # Request available metadata and set a track - future = audio_proxy.get_current_tags() - mt_track = convert_tags_to_track(future.get()) + mt_track = convert_tags_to_track(tags) # Merge current_tl_track with metadata in current_md_track c_track = self.playback.current_tl_track.track From 64cab9ae95444fc96362b1775024647636a697d3 Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Tue, 20 Jan 2015 21:50:02 +0100 Subject: [PATCH 228/495] Rename current_md_track to current_metadata_track --- mopidy/core/actor.py | 4 ++-- mopidy/core/playback.py | 4 ++-- mopidy/mpd/protocol/current_playlist.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 60de442a..ff60f190 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -120,14 +120,14 @@ class Core( # Request available metadata and set a track mt_track = convert_tags_to_track(tags) - # Merge current_tl_track with metadata in current_md_track + # Merge current_tl_track with metadata in current_metadata_track c_track = self.playback.current_tl_track.track track_kwargs = {k: v for k, v in c_track.__dict__.items() if v} for k, v in mt_track.__dict__.items(): if v: track_kwargs[k] = v - self.playback.current_md_track = TlTrack(**{ + self.playback.current_metadata_track = TlTrack(**{ 'tlid': self.playback.current_tl_track.tlid, 'track': Track(**track_kwargs)}) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ad99e6ec..2bc2fbe6 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -127,9 +127,9 @@ class PlaybackController(object): """Mute state as a :class:`True` if muted, :class:`False` otherwise""" def get_current_metadata_track(self): - return self.current_md_track + return self.current_metadata_track - current_md_track = None + current_metadata_track = None """ The currently playing metadata :class:`mopidy.models.Track`, or :class:`None`. diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index bc89afcb..e083ea7c 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -283,7 +283,7 @@ def plchanges(context, version): elif iversion == tracklist_version: # If version are equals, it is just a metadata update # So we replace the updated track in playlist - current_md_track = context.core.playback.current_md_track.get() + current_md_track = context.core.playback.current_metadata_track.get() if current_md_track is None: return None From 735d1662dce784a783565682487e1fcda38e662b Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Tue, 20 Jan 2015 22:05:14 +0100 Subject: [PATCH 229/495] Makes mpd 'currentsong' send song with metadata --- mopidy/mpd/protocol/status.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index 9dae635e..eabb9317 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -34,7 +34,9 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.current_metadata_track.get() + if tl_track is None: + tl_track = context.core.playback.current_tl_track.get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format(tl_track, position=position) From b46844fbe2078419c330030c20580a81ec36f324 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Jan 2015 23:03:35 +0100 Subject: [PATCH 230/495] docs: Fix copy-paste error --- mopidy/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index 5508d4de..758b6c6d 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -153,7 +153,7 @@ class Ref(ImmutableObject): :param name: object name :type name: string :param type: object type - :type name: string + :type type: string """ #: The object URI. Read-only. From 5b614e95d69a27ed4a9d352a60aedf6904309a86 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 31 Jan 2015 22:06:23 +0100 Subject: [PATCH 231/495] Update to Mopidy.js 0.5.0 --- docs/api/js.rst | 7 +- docs/changelog.rst | 13 + docs/conf.py | 1 + mopidy/http/data/mopidy.js | 1808 ++++++++++++++++++-------------- mopidy/http/data/mopidy.min.js | 6 +- 5 files changed, 1050 insertions(+), 785 deletions(-) diff --git a/docs/api/js.rst b/docs/api/js.rst index 361c24fd..fffb40fa 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -289,9 +289,10 @@ unhandled errors. In general, unhandled errors will not go silently missing. The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A `_ standard. We use the -implementation known as `when.js `_. Please -refer to when.js' documentation or the standard for further details on how to -work with promise objects. +implementation known as `when.js `_, and +reexport it as ``Mopidy.when`` so you don't have to duplicate the dependency. +Please refer to when.js' documentation or the standard for further details on +how to work with promise objects. Cleaning up diff --git a/docs/changelog.rst b/docs/changelog.rst index 8fd3ad40..5be97bd9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -92,6 +92,19 @@ v0.20.0 (UNRELEASED) - Add basic tests for the stream library provider. +**Mopidy.js client library** + +This version has been released to npm as Mopidy.js v0.5.0. + +- Reexport When.js library as ``Mopidy.when``, to make it easily available to + users of Mopidy.js. (Fixes: :js:`1`) + +- Default to ``wss://`` as the WebSocket protocol if the page is hosted on + ``https://``. This has no effect if the ``webSocketUrl`` setting is + specified. (Pull request: :js:`2`) + +- Upgrade dependencies. + v0.19.6 (UNRELEASED) ==================== diff --git a/docs/conf.py b/docs/conf.py index 938ec87b..be748381 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -155,6 +155,7 @@ man_pages = [ extlinks = { 'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#'), 'commit': ('https://github.com/mopidy/mopidy/commit/%s', 'commit '), + 'js': ('https://github.com/mopidy/mopidy.js/issues/%s', 'mopidy.js#'), 'mpris': ( 'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'), 'discuss': ('https://discuss.mopidy.com/t/%s', 'discuss.mopidy.com/t/'), diff --git a/mopidy/http/data/mopidy.js b/mopidy/http/data/mopidy.js index ce2f9763..7c95a56a 100644 --- a/mopidy/http/data/mopidy.js +++ b/mopidy/http/data/mopidy.js @@ -1,6 +1,6 @@ -/*! Mopidy.js v0.4.1 - built 2014-09-11 +/*! Mopidy.js v0.5.0 - built 2015-01-31 * http://www.mopidy.com/ - * Copyright (c) 2014 Stein Magnus Jodal and contributors + * Copyright (c) 2015 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ !function(e){if("object"==typeof exports)module.exports=e();else if("function"==typeof define&&define.amd)define(e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Mopidy=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o>>0; - arrayForEach.call(promises, function(p) { - ++pending; - toPromise(p).then(resolve, handleReject); - }); + var pending = l; + var errors = []; - if(pending === 0) { - resolve(); + for (var h, x, i = 0; i < l; ++i) { + x = promises[i]; + if(x === void 0 && !(i in promises)) { + --pending; + continue; } - function handleReject(e) { - errors.push(e); - if(--pending === 0) { - reject(errors); - } + h = Promise._handler(x); + if(h.state() > 0) { + resolver.become(h); + Promise._visitRemaining(promises, i, h); + break; + } else { + h.visit(resolver, handleFulfill, handleReject); } - }); + } + + if(pending === 0) { + resolver.reject(new RangeError('any(): array must not be empty')); + } + + return p; + + function handleFulfill(x) { + /*jshint validthis:true*/ + errors = null; + this.resolve(x); // this === resolver + } + + function handleReject(e) { + /*jshint validthis:true*/ + if(this.resolved) { // this === resolver + return; + } + + errors.push(e); + if(--pending === 0) { + this.reject(errors); + } + } } /** @@ -516,116 +549,181 @@ define(function() { * @deprecated */ function some(promises, n) { - return new Promise(function(resolve, reject, notify) { - var nFulfill = 0; - var nReject; - var results = []; - var errors = []; + /*jshint maxcomplexity:7*/ + var p = Promise._defer(); + var resolver = p._handler; - arrayForEach.call(promises, function(p) { - ++nFulfill; - toPromise(p).then(handleResolve, handleReject, notify); - }); + var results = []; + var errors = []; - n = Math.max(n, 0); - nReject = (nFulfill - n + 1); - nFulfill = Math.min(n, nFulfill); + var l = promises.length>>>0; + var nFulfill = 0; + var nReject; + var x, i; // reused in both for() loops - if(nFulfill === 0) { - resolve(results); + // First pass: count actual array items + for(i=0; i nFulfill) { + resolver.reject(new RangeError('some(): array must contain at least ' + + n + ' item(s), but had ' + nFulfill)); + } else if(nFulfill === 0) { + resolver.resolve(results); + } + + // Second pass: observe each array item, make progress toward goals + for(i=0; i 0) { - --nFulfill; - results.push(x); + results.push(x); + if(--nFulfill === 0) { + errors = null; + this.resolve(results); + } + } - if(nFulfill === 0) { - resolve(results); - } - } + function reject(e) { + /*jshint validthis:true*/ + if(this.resolved) { // this === resolver + return; } - function handleReject(e) { - if(nReject > 0) { - --nReject; - errors.push(e); - - if(nReject === 0) { - reject(errors); - } - } + errors.push(e); + if(--nReject === 0) { + results = null; + this.reject(errors); } - }); + } } /** * Apply f to the value of each promise in a list of promises * and return a new list containing the results. * @param {array} promises - * @param {function} f - * @param {function} fallback + * @param {function(x:*, index:Number):*} f mapping function * @returns {Promise} */ - function map(promises, f, fallback) { - return all(arrayMap.call(promises, function(x) { - return toPromise(x).then(f, fallback); - })); + function map(promises, f) { + return Promise._traverse(f, promises); + } + + /** + * Filter the provided array of promises using the provided predicate. Input may + * contain promises and values + * @param {Array} promises array of promises and values + * @param {function(x:*, index:Number):boolean} predicate filtering predicate. + * Must return truthy (or promise for truthy) for items to retain. + * @returns {Promise} promise that will fulfill with an array containing all items + * for which predicate returned truthy. + */ + function filter(promises, predicate) { + var a = slice.call(promises); + return Promise._traverse(predicate, a).then(function(keep) { + return filterSync(a, keep); + }); + } + + function filterSync(promises, keep) { + // Safe because we know all promises have fulfilled if we've made it this far + var l = keep.length; + var filtered = new Array(l); + for(var i=0, j=0; i 2 - ? arrayReduce.call(promises, reducer, arguments[2]) - : arrayReduce.call(promises, reducer); - - function reducer(result, x, i) { - return toPromise(result).then(function(r) { - return toPromise(x).then(function(x) { - return f(r, x, i); - }); - }); + function settleOne(p) { + var h = Promise._handler(p); + if(h.state() === 0) { + return toPromise(p).then(state.fulfilled, state.rejected); } + + h._unreport(); + return state.inspect(h); } - function reduceRight(promises, f) { - return arguments.length > 2 - ? arrayReduceRight.call(promises, reducer, arguments[2]) - : arrayReduceRight.call(promises, reducer); + /** + * Traditional reduce function, similar to `Array.prototype.reduce()`, but + * input may contain promises and/or values, and reduceFunc + * may return either a value or a promise, *and* initialValue may + * be a promise for the starting value. + * @param {Array|Promise} promises array or promise for an array of anything, + * may contain a mix of promises and values. + * @param {function(accumulated:*, x:*, index:Number):*} f reduce function + * @returns {Promise} that will resolve to the final reduced value + */ + function reduce(promises, f /*, initialValue */) { + return arguments.length > 2 ? ar.call(promises, liftCombine(f), arguments[2]) + : ar.call(promises, liftCombine(f)); + } - function reducer(result, x, i) { - return toPromise(result).then(function(r) { - return toPromise(x).then(function(x) { - return f(r, x, i); - }); - }); - } + /** + * Traditional reduce function, similar to `Array.prototype.reduceRight()`, but + * input may contain promises and/or values, and reduceFunc + * may return either a value or a promise, *and* initialValue may + * be a promise for the starting value. + * @param {Array|Promise} promises array or promise for an array of anything, + * may contain a mix of promises and values. + * @param {function(accumulated:*, x:*, index:Number):*} f reduce function + * @returns {Promise} that will resolve to the final reduced value + */ + function reduceRight(promises, f /*, initialValue */) { + return arguments.length > 2 ? arr.call(promises, liftCombine(f), arguments[2]) + : arr.call(promises, liftCombine(f)); + } + + function liftCombine(f) { + return function(z, x, i) { + return applyFold(f, void 0, [z,x,i]); + }; } }; - }); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{}],9:[function(_dereq_,module,exports){ +},{"../apply":7,"../state":20}],9:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -635,6 +733,7 @@ define(function() { return function flow(Promise) { + var resolve = Promise.resolve; var reject = Promise.reject; var origCatch = Promise.prototype['catch']; @@ -648,10 +747,7 @@ define(function() { * @returns {undefined} */ Promise.prototype.done = function(onResult, onError) { - var h = this._handler; - h.when({ resolve: this._maybeFatal, notify: noop, context: this, - receiver: h.receiver, fulfilled: onResult, rejected: onError, - progress: void 0 }); + this._handler.visit(this._handler.receiver, onResult, onError); }; /** @@ -663,15 +759,15 @@ define(function() { * @returns {*} */ Promise.prototype['catch'] = Promise.prototype.otherwise = function(onRejected) { - if (arguments.length === 1) { + if (arguments.length < 2) { return origCatch.call(this, onRejected); - } else { - if(typeof onRejected !== 'function') { - return this.ensure(rejectInvalidPredicate); - } - - return origCatch.call(this, createCatchFilter(arguments[1], onRejected)); } + + if(typeof onRejected !== 'function') { + return this.ensure(rejectInvalidPredicate); + } + + return origCatch.call(this, createCatchFilter(arguments[1], onRejected)); }; /** @@ -701,14 +797,29 @@ define(function() { */ Promise.prototype['finally'] = Promise.prototype.ensure = function(handler) { if(typeof handler !== 'function') { - // Optimization: result will not change, return same promise return this; } - handler = isolate(handler, this); - return this.then(handler, handler); + return this.then(function(x) { + return runSideEffect(handler, this, identity, x); + }, function(e) { + return runSideEffect(handler, this, reject, e); + }); }; + function runSideEffect (handler, thisArg, propagate, value) { + var result = handler.call(thisArg); + return maybeThenable(result) + ? propagateValue(result, propagate, value) + : propagate(value); + } + + function propagateValue (result, propagate, x) { + return resolve(result).then(function () { + return propagate(x); + }); + } + /** * Recover from a failure by returning a defaultValue. If defaultValue * is a promise, it's fulfillment value will be used. If defaultValue is @@ -763,15 +874,13 @@ define(function() { || (predicate != null && predicate.prototype instanceof Error); } - // prevent argument passing to f and ignore return value - function isolate(f, x) { - return function() { - f.call(this); - return x; - }; + function maybeThenable(x) { + return (typeof x === 'object' || typeof x === 'function') && x !== null; } - function noop() {} + function identity(x) { + return x; + } }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); @@ -787,9 +896,15 @@ define(function() { return function fold(Promise) { - Promise.prototype.fold = function(fn, arg) { + Promise.prototype.fold = function(f, z) { var promise = this._beget(); - this._handler.fold(promise._handler, fn, arg); + + this._handler.fold(function(z, x, to) { + Promise._handler(z).fold(function(x, z, to) { + to.resolve(f.call(this, z, x)); + }, x, this, to); + }, z, promise._handler.receiver, promise._handler); + return promise; }; @@ -805,21 +920,23 @@ define(function() { /** @author John Hann */ (function(define) { 'use strict'; -define(function() { +define(function(_dereq_) { - return function inspect(Promise) { + var inspect = _dereq_('../state').inspect; + + return function inspection(Promise) { Promise.prototype.inspect = function() { - return this._handler.inspect(); + return inspect(Promise._handler(this)); }; return Promise; }; }); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{}],12:[function(_dereq_,module,exports){ +},{"../state":20}],12:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -837,6 +954,7 @@ define(function() { return Promise; /** + * @deprecated Use github.com/cujojs/most streams and most.iterate * Generate a (potentially infinite) stream of promised values: * x, f(x), f(f(x)), etc. until condition(x) returns true * @param {function} f function to generate a new x from the previous x @@ -854,6 +972,7 @@ define(function() { } /** + * @deprecated Use github.com/cujojs/most streams and most.unfold * Generate a (potentially infinite) stream of promised values * by applying handler(generator(seed)) iteratively until * condition(seed) returns true. @@ -895,6 +1014,7 @@ define(function() { return function progress(Promise) { /** + * @deprecated * Register a progress handler for this promise * @param {function} onProgress * @returns {Promise} @@ -917,9 +1037,15 @@ define(function() { (function(define) { 'use strict'; define(function(_dereq_) { - var timer = _dereq_('../timer'); + var env = _dereq_('../env'); var TimeoutError = _dereq_('../TimeoutError'); + function setTimeout(f, ms, x, y) { + return env.setTimer(function() { + f(x, y, ms); + }, ms); + } + return function timed(Promise) { /** * Return a new promise whose fulfillment value is revealed only @@ -929,57 +1055,61 @@ define(function(_dereq_) { */ Promise.prototype.delay = function(ms) { var p = this._beget(); - var h = p._handler; - - this._handler.map(function delay(x) { - timer.set(function() { h.resolve(x); }, ms); - }, h); - + this._handler.fold(handleDelay, ms, void 0, p._handler); return p; }; + function handleDelay(ms, x, h) { + setTimeout(resolveDelay, ms, x, h); + } + + function resolveDelay(x, h) { + h.resolve(x); + } + /** * Return a new promise that rejects after ms milliseconds unless * this promise fulfills earlier, in which case the returned promise * fulfills with the same value. * @param {number} ms milliseconds * @param {Error|*=} reason optional rejection reason to use, defaults - * to an Error if not provided + * to a TimeoutError if not provided * @returns {Promise} */ Promise.prototype.timeout = function(ms, reason) { - var hasReason = arguments.length > 1; var p = this._beget(); var h = p._handler; - var t = timer.set(onTimeout, ms); + var t = setTimeout(onTimeout, ms, reason, p._handler); - this._handler.chain(h, + this._handler.visit(h, function onFulfill(x) { - timer.clear(t); - this.resolve(x); // this = p._handler + env.clearTimer(t); + this.resolve(x); // this = h }, function onReject(x) { - timer.clear(t); - this.reject(x); // this = p._handler + env.clearTimer(t); + this.reject(x); // this = h }, h.notify); return p; - - function onTimeout() { - h.reject(hasReason - ? reason : new TimeoutError('timed out after ' + ms + 'ms')); - } }; + function onTimeout(reason, h, ms) { + var e = typeof reason === 'undefined' + ? new TimeoutError('timed out after ' + ms + 'ms') + : reason; + h.reject(e); + } + return Promise; }; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{"../TimeoutError":6,"../timer":19}],15:[function(_dereq_,module,exports){ +},{"../TimeoutError":6,"../env":17}],15:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -987,20 +1117,27 @@ define(function(_dereq_) { (function(define) { 'use strict'; define(function(_dereq_) { - var timer = _dereq_('../timer'); + var setTimer = _dereq_('../env').setTimer; + var format = _dereq_('../format'); return function unhandledRejection(Promise) { + var logError = noop; var logInfo = noop; + var localConsole; if(typeof console !== 'undefined') { - logError = typeof console.error !== 'undefined' - ? function (e) { console.error(e); } - : function (e) { console.log(e); }; + // Alias console to prevent things like uglify's drop_console option from + // removing console.log/error. Unhandled rejections fall into the same + // category as uncaught exceptions, and build tools shouldn't silence them. + localConsole = console; + logError = typeof localConsole.error !== 'undefined' + ? function (e) { localConsole.error(e); } + : function (e) { localConsole.log(e); }; - logInfo = typeof console.info !== 'undefined' - ? function (e) { console.info(e); } - : function (e) { console.log(e); }; + logInfo = typeof localConsole.info !== 'undefined' + ? function (e) { localConsole.info(e); } + : function (e) { localConsole.log(e); }; } Promise.onPotentiallyUnhandledRejection = function(rejection) { @@ -1017,12 +1154,12 @@ define(function(_dereq_) { var tasks = []; var reported = []; - var running = false; + var running = null; function report(r) { if(!r.handled) { reported.push(r); - logError('Potentially unhandled rejection [' + r.id + '] ' + formatError(r.value)); + logError('Potentially unhandled rejection [' + r.id + '] ' + format.formatError(r.value)); } } @@ -1030,20 +1167,19 @@ define(function(_dereq_) { var i = reported.indexOf(r); if(i >= 0) { reported.splice(i, 1); - logInfo('Handled previous rejection [' + r.id + '] ' + formatObject(r.value)); + logInfo('Handled previous rejection [' + r.id + '] ' + format.formatObject(r.value)); } } function enqueue(f, x) { tasks.push(f, x); - if(!running) { - running = true; - running = timer.set(flush, 0); + if(running === null) { + running = setTimer(flush, 0); } } function flush() { - running = false; + running = null; while(tasks.length > 0) { tasks.shift()(tasks.shift()); } @@ -1052,28 +1188,6 @@ define(function(_dereq_) { return Promise; }; - function formatError(e) { - var s = typeof e === 'object' && e.stack ? e.stack : formatObject(e); - return e instanceof Error ? s : s + ' (WARNING: non-Error used)'; - } - - function formatObject(o) { - var s = String(o); - if(s === '[object Object]' && typeof JSON !== 'undefined') { - s = tryStringify(o, s); - } - return s; - } - - function tryStringify(e, defaultValue) { - try { - return JSON.stringify(e); - } catch(e) { - // Ignore. Cannot JSON.stringify e, stick with String(e) - return defaultValue; - } - } - function throwit(e) { throw e; } @@ -1083,7 +1197,7 @@ define(function(_dereq_) { }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{"../timer":19}],16:[function(_dereq_,module,exports){ +},{"../env":17,"../format":18}],16:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -1094,21 +1208,27 @@ define(function() { return function addWith(Promise) { /** * Returns a promise whose handlers will be called with `this` set to - * the supplied `thisArg`. Subsequent promises derived from the - * returned promise will also have their handlers called with `thisArg`. - * Calling `with` with undefined or no arguments will return a promise - * whose handlers will again be called in the usual Promises/A+ way (no `this`) - * thus safely undoing any previous `with` in the promise chain. + * the supplied receiver. Subsequent promises derived from the + * returned promise will also have their handlers called with receiver + * as `this`. Calling `with` with undefined or no arguments will return + * a promise whose handlers will again be called in the usual Promises/A+ + * way (no `this`) thus safely undoing any previous `with` in the + * promise chain. * * WARNING: Promises returned from `with`/`withThis` are NOT Promises/A+ * compliant, specifically violating 2.2.5 (http://promisesaplus.com/#point-41) * - * @param {object} thisArg `this` value for all handlers attached to + * @param {object} receiver `this` value for all handlers attached to * the returned promise. * @returns {Promise} */ - Promise.prototype['with'] = Promise.prototype.withThis - = Promise.prototype._bindContext; + Promise.prototype['with'] = Promise.prototype.withThis = function(receiver) { + var p = this._beget(); + var child = p._handler; + child.receiver = receiver; + this._handler.chain(child, receiver); + return p; + }; return Promise; }; @@ -1118,6 +1238,142 @@ define(function() { },{}],17:[function(_dereq_,module,exports){ +(function (process){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +/*global process,document,setTimeout,clearTimeout,MutationObserver,WebKitMutationObserver*/ +(function(define) { 'use strict'; +define(function(_dereq_) { + /*jshint maxcomplexity:6*/ + + // Sniff "best" async scheduling option + // Prefer process.nextTick or MutationObserver, then check for + // setTimeout, and finally vertx, since its the only env that doesn't + // have setTimeout + + var MutationObs; + var capturedSetTimeout = typeof setTimeout !== 'undefined' && setTimeout; + + // Default env + var setTimer = function(f, ms) { return setTimeout(f, ms); }; + var clearTimer = function(t) { return clearTimeout(t); }; + var asap = function (f) { return capturedSetTimeout(f, 0); }; + + // Detect specific env + if (isNode()) { // Node + asap = function (f) { return process.nextTick(f); }; + + } else if (MutationObs = hasMutationObserver()) { // Modern browser + asap = initMutationObserver(MutationObs); + + } else if (!capturedSetTimeout) { // vert.x + var vertxRequire = _dereq_; + var vertx = vertxRequire('vertx'); + setTimer = function (f, ms) { return vertx.setTimer(ms, f); }; + clearTimer = vertx.cancelTimer; + asap = vertx.runOnLoop || vertx.runOnContext; + } + + return { + setTimer: setTimer, + clearTimer: clearTimer, + asap: asap + }; + + function isNode () { + return typeof process !== 'undefined' && process !== null && + typeof process.nextTick === 'function'; + } + + function hasMutationObserver () { + return (typeof MutationObserver === 'function' && MutationObserver) || + (typeof WebKitMutationObserver === 'function' && WebKitMutationObserver); + } + + function initMutationObserver(MutationObserver) { + var scheduled; + var node = document.createTextNode(''); + var o = new MutationObserver(run); + o.observe(node, { characterData: true }); + + function run() { + var f = scheduled; + scheduled = void 0; + f(); + } + + var i = 0; + return function (f) { + scheduled = f; + node.data = (i ^= 1); + }; + } +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); + +}).call(this,_dereq_("FWaASH")) +},{"FWaASH":3}],18:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function() { + + return { + formatError: formatError, + formatObject: formatObject, + tryStringify: tryStringify + }; + + /** + * Format an error into a string. If e is an Error and has a stack property, + * it's returned. Otherwise, e is formatted using formatObject, with a + * warning added about e not being a proper Error. + * @param {*} e + * @returns {String} formatted string, suitable for output to developers + */ + function formatError(e) { + var s = typeof e === 'object' && e !== null && e.stack ? e.stack : formatObject(e); + return e instanceof Error ? s : s + ' (WARNING: non-Error used)'; + } + + /** + * Format an object, detecting "plain" objects and running them through + * JSON.stringify if possible. + * @param {Object} o + * @returns {string} + */ + function formatObject(o) { + var s = String(o); + if(s === '[object Object]' && typeof JSON !== 'undefined') { + s = tryStringify(o, s); + } + return s; + } + + /** + * Try to return the result of JSON.stringify(x). If that fails, return + * defaultValue + * @param {*} x + * @param {*} defaultValue + * @returns {String|*} JSON.stringify(x) or defaultValue + */ + function tryStringify(x, defaultValue) { + try { + return JSON.stringify(x); + } catch(e) { + return defaultValue; + } + } + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],19:[function(_dereq_,module,exports){ +(function (process){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -1128,6 +1384,7 @@ define(function() { return function makePromise(environment) { var tasks = environment.scheduler; + var emitRejection = initEmitRejection(); var objectCreate = Object.create || function(proto) { @@ -1149,10 +1406,10 @@ define(function() { /** * Run the supplied resolver * @param resolver - * @returns {makePromise.DeferredHandler} + * @returns {Pending} */ function init(resolver) { - var handler = new DeferredHandler(); + var handler = new Pending(); try { resolver(promiseResolve, promiseReject, promiseNotify); @@ -1180,6 +1437,7 @@ define(function() { } /** + * @deprecated * Issue a progress event, notifying all progress listeners * @param {*} x progress event payload to pass to all listeners */ @@ -1195,6 +1453,7 @@ define(function() { Promise.never = never; Promise._defer = defer; + Promise._handler = getHandler; /** * Returns a trusted promise. If x is already a trusted promise, it is @@ -1204,7 +1463,7 @@ define(function() { */ function resolve(x) { return isPromise(x) ? x - : new Promise(Handler, new AsyncHandler(getHandler(x))); + : new Promise(Handler, new Async(getHandler(x))); } /** @@ -1213,7 +1472,7 @@ define(function() { * @returns {Promise} rejected promise */ function reject(x) { - return new Promise(Handler, new AsyncHandler(new RejectedHandler(x))); + return new Promise(Handler, new Async(new Rejected(x))); } /** @@ -1230,7 +1489,7 @@ define(function() { * @returns {Promise} */ function defer() { - return new Promise(Handler, new DeferredHandler()); + return new Promise(Handler, new Pending()); } // Transformation and flow control @@ -1242,29 +1501,23 @@ define(function() { * this promise's fulfillment. * @param {function=} onFulfilled fulfillment handler * @param {function=} onRejected rejection handler - * @deprecated @param {function=} onProgress progress handler + * @param {function=} onProgress @deprecated progress handler * @return {Promise} new promise */ - Promise.prototype.then = function(onFulfilled, onRejected) { + Promise.prototype.then = function(onFulfilled, onRejected, onProgress) { var parent = this._handler; + var state = parent.join().state(); - if (typeof onFulfilled !== 'function' && parent.join().state() > 0) { + if ((typeof onFulfilled !== 'function' && state > 0) || + (typeof onRejected !== 'function' && state < 0)) { // Short circuit: value will not change, simply share handler - return new Promise(Handler, parent); + return new this.constructor(Handler, parent); } var p = this._beget(); var child = p._handler; - parent.when({ - resolve: child.resolve, - notify: child.notify, - context: child, - receiver: parent.receiver, - fulfilled: onFulfilled, - rejected: onRejected, - progress: arguments.length > 2 ? arguments[2] : void 0 - }); + parent.chain(child, parent.receiver, onFulfilled, onRejected, onProgress); return p; }; @@ -1279,49 +1532,25 @@ define(function() { return this.then(void 0, onRejected); }; - /** - * Private function to bind a thisArg for this promise's handlers - * @private - * @param {object} thisArg `this` value for all handlers attached to - * the returned promise. - * @returns {Promise} - */ - Promise.prototype._bindContext = function(thisArg) { - return new Promise(Handler, new BoundHandler(this._handler, thisArg)); - }; - /** * Creates a new, pending promise of the same type as this promise * @private * @returns {Promise} */ Promise.prototype._beget = function() { - var parent = this._handler; - var child = new DeferredHandler(parent.receiver, parent.join().context); - return new this.constructor(Handler, child); + return begetFrom(this._handler, this.constructor); }; - /** - * Check if x is a rejected promise, and if so, delegate to handler._fatal - * @private - * @param {*} x - */ - Promise.prototype._maybeFatal = function(x) { - if(!maybeThenable(x)) { - return; - } - - var handler = getHandler(x); - var context = this._handler.context; - handler.catchError(function() { - this._fatal(context); - }, handler); - }; + function begetFrom(parent, Promise) { + var child = new Pending(parent.receiver, parent.join().context); + return new Promise(Handler, child); + } // Array combinators Promise.all = all; Promise.race = race; + Promise._traverse = traverse; /** * Return a promise that will fulfill when all promises in the @@ -1331,13 +1560,28 @@ define(function() { * @returns {Promise} promise for array of fulfillment values */ function all(promises) { - /*jshint maxcomplexity:8*/ - var resolver = new DeferredHandler(); + return traverseWith(snd, null, promises); + } + + /** + * Array> -> Promise> + * @private + * @param {function} f function to apply to each promise's value + * @param {Array} promises array of promises + * @returns {Promise} promise for transformed values + */ + function traverse(f, promises) { + return traverseWith(tryCatch2, f, promises); + } + + function traverseWith(tryMap, f, promises) { + var handler = typeof f === 'function' ? mapAt : settleAt; + + var resolver = new Pending(); var pending = promises.length >>> 0; var results = new Array(pending); - var i, h, x, s; - for (i = 0; i < promises.length; ++i) { + for (var i = 0, x; i < promises.length && !resolver.resolved; ++i) { x = promises[i]; if (x === void 0 && !(i in promises)) { @@ -1345,40 +1589,64 @@ define(function() { continue; } - if (maybeThenable(x)) { - h = isPromise(x) - ? x._handler.join() - : getHandlerUntrusted(x); - - s = h.state(); - if (s === 0) { - resolveOne(resolver, results, h, i); - } else if (s > 0) { - results[i] = h.value; - --pending; - } else { - resolver.become(h); - break; - } - - } else { - results[i] = x; - --pending; - } + traverseAt(promises, handler, i, x, resolver); } if(pending === 0) { - resolver.become(new FulfilledHandler(results)); + resolver.become(new Fulfilled(results)); } return new Promise(Handler, resolver); - function resolveOne(resolver, results, handler, i) { - handler.map(function(x) { - results[i] = x; - if(--pending === 0) { - this.become(new FulfilledHandler(results)); - } - }, resolver); + + function mapAt(i, x, resolver) { + if(!resolver.resolved) { + traverseAt(promises, settleAt, i, tryMap(f, x, i), resolver); + } + } + + function settleAt(i, x, resolver) { + results[i] = x; + if(--pending === 0) { + resolver.become(new Fulfilled(results)); + } + } + } + + function traverseAt(promises, handler, i, x, resolver) { + if (maybeThenable(x)) { + var h = getHandlerMaybeThenable(x); + var s = h.state(); + + if (s === 0) { + h.fold(handler, i, void 0, resolver); + } else if (s > 0) { + handler(i, h.value, resolver); + } else { + resolver.become(h); + visitRemaining(promises, i+1, h); + } + } else { + handler(i, x, resolver); + } + } + + Promise._visitRemaining = visitRemaining; + function visitRemaining(promises, start, handler) { + for(var i=start; i 0) { - q.shift().run(); - } - - this._running = false; - - q = this._afterQueue; - while(q.length > 0) { - q.shift()(q.shift(), q.shift()); - } - }; - - return Scheduler; - -}); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); - -},{"./Queue":5}],19:[function(_dereq_,module,exports){ -/** @license MIT License (c) copyright 2010-2014 original author or authors */ -/** @author Brian Cavalier */ -/** @author John Hann */ - -(function(define) { 'use strict'; -define(function(_dereq_) { - /*global setTimeout,clearTimeout*/ - var cjsRequire, vertx, setTimer, clearTimer; - - cjsRequire = _dereq_; - - try { - vertx = cjsRequire('vertx'); - setTimer = function (f, ms) { return vertx.setTimer(ms, f); }; - clearTimer = vertx.cancelTimer; - } catch (e) { - setTimer = function(f, ms) { return setTimeout(f, ms); }; - clearTimer = function(t) { return clearTimeout(t); }; - } +define(function() { return { - set: setTimer, - clear: clearTimer + pending: toPendingState, + fulfilled: toFulfilledState, + rejected: toRejectedState, + inspect: inspect }; -}); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); + function toPendingState() { + return { state: 'pending' }; + } -},{}],20:[function(_dereq_,module,exports){ + function toRejectedState(e) { + return { state: 'rejected', reason: e }; + } + + function toFulfilledState(x) { + return { state: 'fulfilled', value: x }; + } + + function inspect(handler) { + var state = handler.state(); + return state === 0 ? toPendingState() + : state > 0 ? toFulfilledState(handler.value) + : toRejectedState(handler.value); + } + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],21:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @@ -2074,7 +2348,7 @@ define(function(_dereq_) { * when is part of the cujoJS family of libraries (http://cujojs.com/) * @author Brian Cavalier * @author John Hann - * @version 3.2.3 + * @version 3.7.2 */ (function(define) { 'use strict'; define(function (_dereq_) { @@ -2096,7 +2370,7 @@ define(function (_dereq_) { return feature(Promise); }, _dereq_('./lib/Promise')); - var slice = Array.prototype.slice; + var apply = _dereq_('./lib/apply')(Promise); // Public API @@ -2108,8 +2382,8 @@ define(function (_dereq_) { when['try'] = attempt; // call a function and return a promise when.attempt = attempt; // alias for when.try - when.iterate = Promise.iterate; // Generate a stream of promises - when.unfold = Promise.unfold; // Generate a stream of promises + when.iterate = Promise.iterate; // DEPRECATED (use cujojs/most streams) Generate a stream of promises + when.unfold = Promise.unfold; // DEPRECATED (use cujojs/most streams) Generate a stream of promises when.join = join; // Join 2 or more promises @@ -2118,10 +2392,12 @@ define(function (_dereq_) { when.any = lift(Promise.any); // One-winner race when.some = lift(Promise.some); // Multi-winner race + when.race = lift(Promise.race); // First-to-settle race when.map = map; // Array.map() for promises - when.reduce = reduce; // Array.reduce() for promises - when.reduceRight = reduceRight; // Array.reduceRight() for promises + when.filter = filter; // Array.filter() for promises + when.reduce = lift(Promise.reduce); // Array.reduce() for promises + when.reduceRight = lift(Promise.reduceRight); // Array.reduceRight() for promises when.isPromiseLike = isPromiseLike; // Is something promise-like, aka thenable @@ -2141,21 +2417,19 @@ define(function (_dereq_) { * will be invoked immediately. * @param {function?} onRejected callback to be called when x is * rejected. - * @deprecated @param {function?} onProgress callback to be called when progress updates - * are issued for x. + * @param {function?} onProgress callback to be called when progress updates + * are issued for x. @deprecated * @returns {Promise} a new promise that will fulfill with the return * value of callback or errback or the completion value of promiseOrValue if * callback and/or errback is not supplied. */ - function when(x, onFulfilled, onRejected) { + function when(x, onFulfilled, onRejected, onProgress) { var p = Promise.resolve(x); - if(arguments.length < 2) { + if (arguments.length < 2) { return p; } - return arguments.length > 3 - ? p.then(onFulfilled, onRejected, arguments[3]) - : p.then(onFulfilled, onRejected); + return p.then(onFulfilled, onRejected, onProgress); } /** @@ -2175,7 +2449,10 @@ define(function (_dereq_) { */ function lift(f) { return function() { - return _apply(f, this, slice.call(arguments)); + for(var i=0, l=arguments.length, a=new Array(l); i0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):void d(this,a).push({listener:b(e),thisp:f})},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),void f.splice(0,f.length)}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return void f.splice(0,f.length);for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return void f.splice(h,1)},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f,aggregate:function(a){var b=f();return a.forEach(function(a){a.on(function(a,c){b.emit(a,c)})}),b}}})},{}],3:[function(a,b){function c(){}var d=b.exports={};d.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){var b=a.source;if((b===window||null===b)&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var d=c.shift();d()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),d.title="browser",d.browser=!0,d.env={},d.argv=[],d.on=c,d.addListener=c,d.once=c,d.off=c,d.removeListener=c,d.removeAllListeners=c,d.emit=c,d.binding=function(){throw new Error("process.binding is not supported")},d.cwd=function(){return"/"},d.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){!function(a){"use strict";a(function(a){var b=a("./makePromise"),c=a("./scheduler"),d=a("./async");return b({scheduler:new c(d)})})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./async":7,"./makePromise":17,"./scheduler":18}],5:[function(b,c){!function(a){"use strict";a(function(){function a(a){this.head=this.tail=this.length=0,this.buffer=new Array(1<f;++f)e[f]=d[f];else{for(a=d.length,b=this.tail;a>c;++f,++c)e[f]=d[c];for(c=0;b>c;++f,++c)e[f]=d[c]}this.buffer=e,this.head=0,this.tail=this.length},a})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],6:[function(b,c){!function(a){"use strict";a(function(){function a(b){Error.call(this),this.message=b,this.name=a.name,"function"==typeof Error.captureStackTrace&&Error.captureStackTrace(this,a)}return a.prototype=Object.create(Error.prototype),a.prototype.constructor=a,a})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],7:[function(b,c){(function(d){!function(a){"use strict";a(function(a){var b,c;return b="undefined"!=typeof d&&null!==d&&"function"==typeof d.nextTick?function(a){d.nextTick(a)}:(c="function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver)?function(a,b){function c(){var a=d;d=void 0,a()}var d,e=a.createElement("div"),f=new b(c);return f.observe(e,{attributes:!0}),function(a){d=a,e.setAttribute("class","x")}}(document,c):function(a){try{return a("vertx").runOnLoop||a("vertx").runOnContext}catch(b){}var c=setTimeout;return function(a){c(a,0)}}(a)})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})}).call(this,b("FWaASH"))},{FWaASH:3}],8:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(b){return new a(function(a,c){function d(a){f.push(a),0===--e&&c(f)}var e=0,f=[];k.call(b,function(b){++e,l(b).then(a,d)}),0===e&&a()})}function c(b,c){return new a(function(a,d,e){function f(b){i>0&&(--i,j.push(b),0===i&&a(j))}function g(a){h>0&&(--h,m.push(a),0===h&&d(m))}var h,i=0,j=[],m=[];return k.call(b,function(a){++i,l(a).then(f,g,e)}),c=Math.max(c,0),h=i-c+1,i=Math.min(c,i),0===i?void a(j):void 0})}function d(a,b,c){return m(h.call(a,function(a){return l(a).then(b,c)}))}function e(a){return m(h.call(a,function(a){function b(){return a.inspect()}return a=l(a),a.then(b,b)}))}function f(a,b){function c(a,c,d){return l(a).then(function(a){return l(c).then(function(c){return b(a,c,d)})})}return arguments.length>2?i.call(a,c,arguments[2]):i.call(a,c)}function g(a,b){function c(a,c,d){return l(a).then(function(a){return l(c).then(function(c){return b(a,c,d)})})}return arguments.length>2?j.call(a,c,arguments[2]):j.call(a,c)}var h=Array.prototype.map,i=Array.prototype.reduce,j=Array.prototype.reduceRight,k=Array.prototype.forEach,l=a.resolve,m=a.all;return a.any=b,a.some=c,a.settle=e,a.map=d,a.reduce=f,a.reduceRight=g,a.prototype.spread=function(a){return this.then(m).then(function(b){return a.apply(void 0,b)})},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],9:[function(b,c){!function(a){"use strict";a(function(){function a(){throw new TypeError("catch predicate must be a function")}function b(a,b){return c(b)?a instanceof b:b(a)}function c(a){return a===Error||null!=a&&a.prototype instanceof Error}function d(a,b){return function(){return a.call(this),b}}function e(){}return function(c){function f(a,c){return function(d){return b(d,c)?a.call(this,d):g(d)}}var g=c.reject,h=c.prototype["catch"];return c.prototype.done=function(a,b){var c=this._handler;c.when({resolve:this._maybeFatal,notify:e,context:this,receiver:c.receiver,fulfilled:a,rejected:b,progress:void 0})},c.prototype["catch"]=c.prototype.otherwise=function(b){return 1===arguments.length?h.call(this,b):"function"!=typeof b?this.ensure(a):h.call(this,f(arguments[1],b))},c.prototype["finally"]=c.prototype.ensure=function(a){return"function"!=typeof a?this:(a=d(a,this),this.then(a,a))},c.prototype["else"]=c.prototype.orElse=function(a){return this.then(void 0,function(){return a})},c.prototype["yield"]=function(a){return this.then(function(){return a})},c.prototype.tap=function(a){return this.then(a)["yield"](this)},c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],10:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.fold=function(a,b){var c=this._beget();return this._handler.fold(c._handler,a,b),c},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],11:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.inspect=function(){return this._handler.inspect()},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],12:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b,d,e){return c(function(b){return[b,a(b)]},b,d,e)}function c(a,b,e,f){function g(f,g){return d(e(f)).then(function(){return c(a,b,e,g)})}return d(f).then(function(c){return d(b(c)).then(function(b){return b?c:d(a(c)).spread(g)})})}var d=a.resolve;return a.iterate=b,a.unfold=c,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],13:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.progress=function(a){return this.then(void 0,void 0,a)},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],14:[function(b,c){!function(a){"use strict";a(function(a){var b=a("../timer"),c=a("../TimeoutError");return function(a){return a.prototype.delay=function(a){var c=this._beget(),d=c._handler;return this._handler.map(function(c){b.set(function(){d.resolve(c)},a)},d),c},a.prototype.timeout=function(a,d){function e(){h.reject(f?d:new c("timed out after "+a+"ms"))}var f=arguments.length>1,g=this._beget(),h=g._handler,i=b.set(e,a);return this._handler.chain(h,function(a){b.clear(i),this.resolve(a)},function(a){b.clear(i),this.reject(a)},h.notify),g},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../TimeoutError":6,"../timer":19}],15:[function(b,c){!function(a){"use strict";a(function(a){function b(a){var b="object"==typeof a&&a.stack?a.stack:c(a);return a instanceof Error?b:b+" (WARNING: non-Error used)"}function c(a){var b=String(a);return"[object Object]"===b&&"undefined"!=typeof JSON&&(b=d(a,b)),b}function d(a,b){try{return JSON.stringify(a)}catch(a){return b}}function e(a){throw a}function f(){}var g=a("../timer");return function(a){function d(a){a.handled||(n.push(a),k("Potentially unhandled rejection ["+a.id+"] "+b(a.value)))}function h(a){var b=n.indexOf(a);b>=0&&(n.splice(b,1),l("Handled previous rejection ["+a.id+"] "+c(a.value)))}function i(a,b){m.push(a,b),o||(o=!0,o=g.set(j,0))}function j(){for(o=!1;m.length>0;)m.shift()(m.shift())}var k=f,l=f;"undefined"!=typeof console&&(k="undefined"!=typeof console.error?function(a){console.error(a)}:function(a){console.log(a)},l="undefined"!=typeof console.info?function(a){console.info(a)}:function(a){console.log(a)}),a.onPotentiallyUnhandledRejection=function(a){i(d,a)},a.onPotentiallyUnhandledRejectionHandled=function(a){i(h,a)},a.onFatalRejection=function(a){i(e,a.value)};var m=[],n=[],o=!1;return a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../timer":19}],16:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype["with"]=a.prototype.withThis=a.prototype._bindContext,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],17:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b){this._handler=a===m?b:c(a)}function c(a){function b(a){e.resolve(a)}function c(a){e.reject(a)}function d(a){e.notify(a)}var e=new n;try{a(b,c,d)}catch(f){c(f)}return e}function d(a){return k(a)?a:new b(m,new p(j(a)))}function e(a){return new b(m,new p(new t(a)))}function f(){return M}function g(){return new b(m,new n)}function h(a){function c(a,b,c,d){c.map(function(a){b[d]=a,0===--i&&this.become(new s(b))},a)}var d,e,f,g,h=new n,i=a.length>>>0,j=new Array(i);for(d=0;d0)){h.become(e);break}j[d]=e.value,--i}else j[d]=f,--i;else--i;return 0===i&&h.become(new s(j)),new b(m,h)}function i(a){if(Object(a)===a&&0===a.length)return f();var c,d,e=new n;for(c=0;c0)return new b(m,d);var e=this._beget(),f=e._handler;return d.when({resolve:f.resolve,notify:f.notify,context:f,receiver:d.receiver,fulfilled:a,rejected:c,progress:arguments.length>2?arguments[2]:void 0}),e},b.prototype["catch"]=function(a){return this.then(void 0,a)},b.prototype._bindContext=function(a){return new b(m,new q(this._handler,a))},b.prototype._beget=function(){var a=this._handler,b=new n(a.receiver,a.join().context);return new this.constructor(m,b)},b.prototype._maybeFatal=function(a){if(C(a)){var b=j(a),c=this._handler.context;b.catchError(function(){this._fatal(c)},b)}},b.all=h,b.race=i,m.prototype.when=m.prototype.resolve=m.prototype.reject=m.prototype.notify=m.prototype._fatal=m.prototype._unreport=m.prototype._report=H,m.prototype.inspect=x,m.prototype._state=0,m.prototype.state=function(){return this._state},m.prototype.join=function(){for(var a=this;void 0!==a.handler;)a=a.handler;return a},m.prototype.chain=function(a,b,c,d){this.when({resolve:H,notify:H,context:void 0,receiver:a,fulfilled:b,rejected:c,progress:d})},m.prototype.map=function(a,b){this.chain(b,a,b.reject,b.notify)},m.prototype.catchError=function(a,b){this.chain(b,b.resolve,a,b.notify)},m.prototype.fold=function(a,b,c){this.join().map(function(a){j(c).map(function(c){this.resolve(E(b,c,a,this.receiver))},this)},a)},G(m,n),n.prototype._state=0,n.prototype.inspect=function(){return this.resolved?this.join().inspect():x()},n.prototype.resolve=function(a){this.resolved||this.become(j(a))},n.prototype.reject=function(a){this.resolved||this.become(new t(a))},n.prototype.join=function(){if(this.resolved){for(var a=this;void 0!==a.handler;)if(a=a.handler,a===this)return this.handler=new w;return a}return this},n.prototype.run=function(){var a=this.consumers,b=this.join();this.consumers=void 0;for(var c=0;c0;)a.shift().run();for(this._running=!1,a=this._afterQueue;a.length>0;)a.shift()(a.shift(),a.shift())},b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./Queue":5}],19:[function(b,c){!function(a){"use strict";a(function(a){var b,c,d,e;b=a;try{c=b("vertx"),d=function(a,b){return c.setTimer(b,a)},e=c.cancelTimer}catch(f){d=function(a,b){return setTimeout(a,b)},e=function(a){return clearTimeout(a)}}return{set:d,clear:e}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{}],20:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,c){var d=z.resolve(a);return arguments.length<2?d:arguments.length>3?d.then(b,c,arguments[3]):d.then(b,c)}function c(a){return new z(a)}function d(a){return function(){return f(a,this,A.call(arguments))}}function e(a){return f(a,this,A.call(arguments,1))}function f(a,b,c){return z.all(c).then(function(c){return a.apply(b,c)})}function g(){return new h}function h(){function a(a){d._handler.resolve(a)}function b(a){d._handler.reject(a)}function c(a){d._handler.notify(a)}var d=z._defer();this.promise=d,this.resolve=a,this.reject=b,this.notify=c,this.resolver={resolve:a,reject:b,notify:c}}function i(a){return a&&"function"==typeof a.then}function j(){return z.all(arguments)}function k(a){return b(a,z.all)}function l(a){return b(a,z.settle)}function m(a,c){return b(a,function(a){return z.map(a,c)})}function n(a){var c=A.call(arguments,1);return b(a,function(a){return c.unshift(a),z.reduce.apply(z,c)})}function o(a){var c=A.call(arguments,1);return b(a,function(a){return c.unshift(a),z.reduceRight.apply(z,c)})}var p=a("./lib/decorators/timed"),q=a("./lib/decorators/array"),r=a("./lib/decorators/flow"),s=a("./lib/decorators/fold"),t=a("./lib/decorators/inspect"),u=a("./lib/decorators/iterate"),v=a("./lib/decorators/progress"),w=a("./lib/decorators/with"),x=a("./lib/decorators/unhandledRejection"),y=a("./lib/TimeoutError"),z=[q,r,s,u,v,t,w,p,x].reduce(function(a,b){return b(a)},a("./lib/Promise")),A=Array.prototype.slice;return b.promise=c,b.resolve=z.resolve,b.reject=z.reject,b.lift=d,b["try"]=e,b.attempt=e,b.iterate=z.iterate,b.unfold=z.unfold,b.join=j,b.all=k,b.settle=l,b.any=d(z.any),b.some=d(z.some),b.map=m,b.reduce=n,b.reduceRight=o,b.isPromiseLike=i,b.Promise=z,b.defer=g,b.TimeoutError=y,b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./lib/Promise":4,"./lib/TimeoutError":6,"./lib/decorators/array":8,"./lib/decorators/flow":9,"./lib/decorators/fold":10,"./lib/decorators/inspect":11,"./lib/decorators/iterate":12,"./lib/decorators/progress":13,"./lib/decorators/timed":14,"./lib/decorators/unhandledRejection":15,"./lib/decorators/with":16}],21:[function(a,b){function c(a){return this instanceof c?(this._console=this._getConsole(a||{}),this._settings=this._configure(a||{}),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),void(this._settings.autoConnect&&this.connect())):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.ConnectionError=function(a){this.name="ConnectionError",this.message=a},c.ConnectionError.prototype=new Error,c.ConnectionError.prototype.constructor=c.ConnectionError,c.ServerError=function(a){this.name="ServerError",this.message=a},c.ServerError.prototype=new Error,c.ServerError.prototype.constructor=c.ServerError,c.WebSocket=e.Client,c.prototype._getConsole=function(a){if("undefined"!=typeof a.console)return a.console;var b="undefined"!=typeof console&&console||{};return b.log=b.log||function(){},b.warn=b.warn||function(){},b.error=b.error||function(){},b},c.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,"undefined"==typeof a.callingConvention&&this._console.warn("Mopidy.js is using the default calling convention. The default will change in the future. You should explicitly specify which calling convention you use."),a.callingConvention=a.callingConvention||"by-position-only",a},c.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var d=this._pendingRequests[b];delete this._pendingRequests[b];var e=new c.ConnectionError("WebSocket closed");e.closeEvent=a,d.reject(e)}.bind(this)),this.emit("state:offline")},c.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:return f.reject(new c.ConnectionError("WebSocket is still connecting"));case c.WebSocket.CLOSING:return f.reject(new c.ConnectionError("WebSocket is closing"));case c.WebSocket.CLOSED:return f.reject(new c.ConnectionError("WebSocket is closed"));default:var b=f.defer();return a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a),b.promise}},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return void this._console.warn("Unexpected response received. Message was:",a);var b,d=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?d.resolve(a.result):a.hasOwnProperty("error")?(b=new c.ServerError(a.error.message),b.code=a.error.code,b.data=a.error.data,d.reject(b),this._console.warn("Server returned error:",a.error)):(b=new Error("Response without 'result' or 'error' received"),b.data={response:a},d.reject(b),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this)).catch(this._handleWebSocketError)},c.prototype._createApi=function(a){var b="by-position-or-by-name"===this._settings.callingConvention,c=function(a){return function(){var c={method:a};return 0===arguments.length?this._send(c):b?arguments.length>1?f.reject(new Error("Expected zero arguments, a single array, or a single object.")):Array.isArray(arguments[0])||arguments[0]===Object(arguments[0])?(c.params=arguments[0],this._send(c)):f.reject(new TypeError("Expected an array or an object.")):(c.params=Array.prototype.slice.call(arguments),this._send(c))}.bind(this)}.bind(this),d=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},e=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),g=function(b){var f=d(b),g=this._snakeToCamel(f.slice(-1)[0]),h=e(f.slice(0,-1));h[g]=c(b),h[g].description=a[b].description,h[g].params=a[b].params}.bind(this);Object.keys(a).forEach(g),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:20}]},{},[21])(21)}); \ No newline at end of file +!function(a){if("object"==typeof exports)module.exports=a();else if("function"==typeof define&&define.amd)define(a);else{var b;"undefined"!=typeof window?b=window:"undefined"!=typeof global?b=global:"undefined"!=typeof self&&(b=self),b.Mopidy=a()}}(function(){var a;return function b(a,c,d){function e(g,h){if(!c[g]){if(!a[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);throw new Error("Cannot find module '"+g+"'")}var j=c[g]={exports:{}};a[g][0].call(j.exports,function(b){var c=a[g][1][b];return e(c?c:b)},j,j.exports,b,a,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;g0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):void d(this,a).push({listener:b(e),thisp:f})},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),void f.splice(0,f.length)}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return void f.splice(0,f.length);for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return void f.splice(h,1)},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f,aggregate:function(a){var b=f();return a.forEach(function(a){a.on(function(a,c){b.emit(a,c)})}),b}}})},{}],3:[function(a,b){function c(){}var d=b.exports={};d.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){var b=a.source;if((b===window||null===b)&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var d=c.shift();d()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),d.title="browser",d.browser=!0,d.env={},d.argv=[],d.on=c,d.addListener=c,d.once=c,d.off=c,d.removeListener=c,d.removeAllListeners=c,d.emit=c,d.binding=function(){throw new Error("process.binding is not supported")},d.cwd=function(){return"/"},d.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){!function(a){"use strict";a(function(a){var b=a("./makePromise"),c=a("./Scheduler"),d=a("./env").asap;return b({scheduler:new c(d)})})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./Scheduler":5,"./env":17,"./makePromise":19}],5:[function(b,c){!function(a){"use strict";a(function(){function a(a){this._async=a,this._running=!1,this._queue=this,this._queueLen=0,this._afterQueue={},this._afterQueueLen=0;var b=this;this.drain=function(){b._drain()}}return a.prototype.enqueue=function(a){this._queue[this._queueLen++]=a,this.run()},a.prototype.afterQueue=function(a){this._afterQueue[this._afterQueueLen++]=a,this.run()},a.prototype.run=function(){this._running||(this._running=!0,this._async(this.drain))},a.prototype._drain=function(){for(var a=0;a>>0,j=i,k=[],l=0;i>l;++l)if(f=b[l],void 0!==f||l in b){if(e=a._handler(f),e.state()>0){h.become(e),a._visitRemaining(b,l,e);break}e.visit(h,c,d)}else--j;return 0===j&&h.reject(new RangeError("any(): array must not be empty")),g}function e(b,c){function d(a){this.resolved||(k.push(a),0===--n&&(l=null,this.resolve(k)))}function e(a){this.resolved||(l.push(a),0===--f&&(k=null,this.reject(l)))}var f,g,h,i=a._defer(),j=i._handler,k=[],l=[],m=b.length>>>0,n=0;for(h=0;m>h;++h)g=b[h],(void 0!==g||h in b)&&++n;for(c=Math.max(c,0),f=n-c+1,n=Math.min(c,n),c>n?j.reject(new RangeError("some(): array must contain at least "+c+" item(s), but had "+n)):0===n&&j.resolve(k),h=0;m>h;++h)g=b[h],(void 0!==g||h in b)&&a._handler(g).visit(j,d,e,j.notify);return i}function f(b,c){return a._traverse(c,b)}function g(b,c){var d=s.call(b);return a._traverse(c,d).then(function(a){return h(d,a)})}function h(b,c){for(var d=c.length,e=new Array(d),f=0,g=0;d>f;++f)c[f]&&(e[g++]=a._handler(b[f]).value);return e.length=g,e}function i(a){return p(a.map(j))}function j(c){var d=a._handler(c);return 0===d.state()?o(c).then(b.fulfilled,b.rejected):(d._unreport(),b.inspect(d))}function k(a,b){return arguments.length>2?q.call(a,m(b),arguments[2]):q.call(a,m(b))}function l(a,b){return arguments.length>2?r.call(a,m(b),arguments[2]):r.call(a,m(b))}function m(a){return function(b,c,d){return n(a,void 0,[b,c,d])}}var n=c(a),o=a.resolve,p=a.all,q=Array.prototype.reduce,r=Array.prototype.reduceRight,s=Array.prototype.slice;return a.any=d,a.some=e,a.settle=i,a.map=f,a.filter=g,a.reduce=k,a.reduceRight=l,a.prototype.spread=function(a){return this.then(p).then(function(b){return a.apply(this,b)})},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../apply":7,"../state":20}],9:[function(b,c){!function(a){"use strict";a(function(){function a(){throw new TypeError("catch predicate must be a function")}function b(a,b){return c(b)?a instanceof b:b(a)}function c(a){return a===Error||null!=a&&a.prototype instanceof Error}function d(a){return("object"==typeof a||"function"==typeof a)&&null!==a}function e(a){return a}return function(c){function f(a,c){return function(d){return b(d,c)?a.call(this,d):j(d)}}function g(a,b,c,e){var f=a.call(b);return d(f)?h(f,c,e):c(e)}function h(a,b,c){return i(a).then(function(){return b(c)})}var i=c.resolve,j=c.reject,k=c.prototype["catch"];return c.prototype.done=function(a,b){this._handler.visit(this._handler.receiver,a,b)},c.prototype["catch"]=c.prototype.otherwise=function(b){return arguments.length<2?k.call(this,b):"function"!=typeof b?this.ensure(a):k.call(this,f(arguments[1],b))},c.prototype["finally"]=c.prototype.ensure=function(a){return"function"!=typeof a?this:this.then(function(b){return g(a,this,e,b)},function(b){return g(a,this,j,b)})},c.prototype["else"]=c.prototype.orElse=function(a){return this.then(void 0,function(){return a})},c.prototype["yield"]=function(a){return this.then(function(){return a})},c.prototype.tap=function(a){return this.then(a)["yield"](this)},c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],10:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.fold=function(b,c){var d=this._beget();return this._handler.fold(function(c,d,e){a._handler(c).fold(function(a,c,d){d.resolve(b.call(this,c,a))},d,this,e)},c,d._handler.receiver,d._handler),d},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],11:[function(b,c){!function(a){"use strict";a(function(a){var b=a("../state").inspect;return function(a){return a.prototype.inspect=function(){return b(a._handler(this))},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../state":20}],12:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b,d,e){return c(function(b){return[b,a(b)]},b,d,e)}function c(a,b,e,f){function g(f,g){return d(e(f)).then(function(){return c(a,b,e,g)})}return d(f).then(function(c){return d(b(c)).then(function(b){return b?c:d(a(c)).spread(g)})})}var d=a.resolve;return a.iterate=b,a.unfold=c,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],13:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.progress=function(a){return this.then(void 0,void 0,a)},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],14:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,d,e){return c.setTimer(function(){a(d,e,b)},b)}var c=a("../env"),d=a("../TimeoutError");return function(a){function e(a,c,d){b(f,a,c,d)}function f(a,b){b.resolve(a)}function g(a,b,c){var e="undefined"==typeof a?new d("timed out after "+c+"ms"):a;b.reject(e)}return a.prototype.delay=function(a){var b=this._beget();return this._handler.fold(e,a,void 0,b._handler),b},a.prototype.timeout=function(a,d){var e=this._beget(),f=e._handler,h=b(g,a,d,e._handler);return this._handler.visit(f,function(a){c.clearTimer(h),this.resolve(a)},function(a){c.clearTimer(h),this.reject(a)},f.notify),e},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../TimeoutError":6,"../env":17}],15:[function(b,c){!function(a){"use strict";a(function(a){function b(a){throw a}function c(){}var d=a("../env").setTimer,e=a("../format");return function(a){function f(a){a.handled||(n.push(a),k("Potentially unhandled rejection ["+a.id+"] "+e.formatError(a.value)))}function g(a){var b=n.indexOf(a);b>=0&&(n.splice(b,1),l("Handled previous rejection ["+a.id+"] "+e.formatObject(a.value)))}function h(a,b){m.push(a,b),null===o&&(o=d(i,0))}function i(){for(o=null;m.length>0;)m.shift()(m.shift())}var j,k=c,l=c;"undefined"!=typeof console&&(j=console,k="undefined"!=typeof j.error?function(a){j.error(a)}:function(a){j.log(a)},l="undefined"!=typeof j.info?function(a){j.info(a)}:function(a){j.log(a)}),a.onPotentiallyUnhandledRejection=function(a){h(f,a)},a.onPotentiallyUnhandledRejectionHandled=function(a){h(g,a)},a.onFatalRejection=function(a){h(b,a.value)};var m=[],n=[],o=null;return a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../env":17,"../format":18}],16:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype["with"]=a.prototype.withThis=function(a){var b=this._beget(),c=b._handler;return c.receiver=a,this._handler.chain(c,a),b},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],17:[function(b,c){(function(d){!function(a){"use strict";a(function(a){function b(){return"undefined"!=typeof d&&null!==d&&"function"==typeof d.nextTick}function c(){return"function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver}function e(a){function b(){var a=c;c=void 0,a()}var c,d=document.createTextNode(""),e=new a(b);e.observe(d,{characterData:!0});var f=0;return function(a){c=a,d.data=f^=1}}var f,g="undefined"!=typeof setTimeout&&setTimeout,h=function(a,b){return setTimeout(a,b)},i=function(a){return clearTimeout(a)},j=function(a){return g(a,0)};if(b())j=function(a){return d.nextTick(a)};else if(f=c())j=e(f);else if(!g){var k=a,l=k("vertx");h=function(a,b){return l.setTimer(b,a)},i=l.cancelTimer,j=l.runOnLoop||l.runOnContext}return{setTimer:h,clearTimer:i,asap:j}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})}).call(this,b("FWaASH"))},{FWaASH:3}],18:[function(b,c){!function(a){"use strict";a(function(){function a(a){var c="object"==typeof a&&null!==a&&a.stack?a.stack:b(a);return a instanceof Error?c:c+" (WARNING: non-Error used)"}function b(a){var b=String(a);return"[object Object]"===b&&"undefined"!=typeof JSON&&(b=c(a,b)),b}function c(a,b){try{return JSON.stringify(a)}catch(c){return b}}return{formatError:a,formatObject:b,tryStringify:c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],19:[function(b,c){(function(b){!function(a){"use strict";a(function(){return function(a){function c(a,b){this._handler=a===u?b:d(a)}function d(a){function b(a){e.resolve(a)}function c(a){e.reject(a)}function d(a){e.notify(a)}var e=new w;try{a(b,c,d)}catch(f){c(f)}return e}function e(a){return J(a)?a:new c(u,new x(r(a)))}function f(a){return new c(u,new x(new A(a)))}function g(){return ab}function h(){return new c(u,new w)}function i(a,b){var c=new w(a.receiver,a.join().context);return new b(u,c)}function j(a){return l(T,null,a)}function k(a,b){return l(O,a,b)}function l(a,b,d){function e(c,e,g){g.resolved||m(d,f,c,a(b,e,c),g)}function f(a,b,c){k[a]=b,0===--j&&c.become(new z(k))}for(var g,h="function"==typeof b?e:f,i=new w,j=d.length>>>0,k=new Array(j),l=0;l0?b(c,f.value,e):(e.become(f),n(a,c+1,f))}else b(c,d,e)}function n(a,b,c){for(var d=b;dc&&a._unreport()}}function p(a){return"object"!=typeof a||null===a?f(new TypeError("non-iterable passed to race()")):0===a.length?g():1===a.length?e(a[0]):q(a)}function q(a){var b,d,e,f=new w;for(b=0;b0||"function"!=typeof b&&0>e)return new this.constructor(u,d);var f=this._beget(),g=f._handler;return d.chain(g,d.receiver,a,b,c),f},c.prototype["catch"]=function(a){return this.then(void 0,a)},c.prototype._beget=function(){return i(this._handler,this.constructor)},c.all=j,c.race=p,c._traverse=k,c._visitRemaining=n,u.prototype.when=u.prototype.become=u.prototype.notify=u.prototype.fail=u.prototype._unreport=u.prototype._report=U,u.prototype._state=0,u.prototype.state=function(){return this._state},u.prototype.join=function(){for(var a=this;void 0!==a.handler;)a=a.handler;return a},u.prototype.chain=function(a,b,c,d,e){this.when({resolver:a,receiver:b,fulfilled:c,rejected:d,progress:e})},u.prototype.visit=function(a,b,c,d){this.chain(Z,a,b,c,d)},u.prototype.fold=function(a,b,c,d){this.when(new I(a,b,c,d))},S(u,v),v.prototype.become=function(a){a.fail()};var Z=new v;S(u,w),w.prototype._state=0,w.prototype.resolve=function(a){this.become(r(a))},w.prototype.reject=function(a){this.resolved||this.become(new A(a))},w.prototype.join=function(){if(!this.resolved)return this;for(var a=this;void 0!==a.handler;)if(a=a.handler,a===this)return this.handler=D();return a},w.prototype.run=function(){var a=this.consumers,b=this.handler;this.handler=this.handler.join(),this.consumers=void 0;for(var c=0;c0?c(d.value):b(d.value)}return{pending:a,fulfilled:c,rejected:b,inspect:d}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],21:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,c,d){var e=x.resolve(a);return arguments.length<2?e:e.then(b,c,d)}function c(a){return new x(a)}function d(a){return function(){for(var b=0,c=arguments.length,d=new Array(c);c>b;++b)d[b]=arguments[b];return y(a,this,d)}}function e(a){for(var b=0,c=arguments.length-1,d=new Array(c);c>b;++b)d[b]=arguments[b+1];return y(a,this,d)}function f(){return new g}function g(){function a(a){d._handler.resolve(a)}function b(a){d._handler.reject(a)}function c(a){d._handler.notify(a)}var d=x._defer();this.promise=d,this.resolve=a,this.reject=b,this.notify=c,this.resolver={resolve:a,reject:b,notify:c}}function h(a){return a&&"function"==typeof a.then}function i(){return x.all(arguments)}function j(a){return b(a,x.all)}function k(a){return b(a,x.settle)}function l(a,c){return b(a,function(a){return x.map(a,c)})}function m(a,c){return b(a,function(a){return x.filter(a,c)})}var n=a("./lib/decorators/timed"),o=a("./lib/decorators/array"),p=a("./lib/decorators/flow"),q=a("./lib/decorators/fold"),r=a("./lib/decorators/inspect"),s=a("./lib/decorators/iterate"),t=a("./lib/decorators/progress"),u=a("./lib/decorators/with"),v=a("./lib/decorators/unhandledRejection"),w=a("./lib/TimeoutError"),x=[o,p,q,s,t,r,u,n,v].reduce(function(a,b){return b(a)},a("./lib/Promise")),y=a("./lib/apply")(x);return b.promise=c,b.resolve=x.resolve,b.reject=x.reject,b.lift=d,b["try"]=e,b.attempt=e,b.iterate=x.iterate,b.unfold=x.unfold,b.join=i,b.all=j,b.settle=k,b.any=d(x.any),b.some=d(x.some),b.race=d(x.race),b.map=l,b.filter=m,b.reduce=d(x.reduce),b.reduceRight=d(x.reduceRight),b.isPromiseLike=h,b.Promise=x,b.defer=f,b.TimeoutError=w,b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./lib/Promise":4,"./lib/TimeoutError":6,"./lib/apply":7,"./lib/decorators/array":8,"./lib/decorators/flow":9,"./lib/decorators/fold":10,"./lib/decorators/inspect":11,"./lib/decorators/iterate":12,"./lib/decorators/progress":13,"./lib/decorators/timed":14,"./lib/decorators/unhandledRejection":15,"./lib/decorators/with":16}],22:[function(a,b){function c(a){return this instanceof c?(this._console=this._getConsole(a||{}),this._settings=this._configure(a||{}),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),void(this._settings.autoConnect&&this.connect())):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.ConnectionError=function(a){this.name="ConnectionError",this.message=a},c.ConnectionError.prototype=Object.create(Error.prototype),c.ConnectionError.prototype.constructor=c.ConnectionError,c.ServerError=function(a){this.name="ServerError",this.message=a},c.ServerError.prototype=Object.create(Error.prototype),c.ServerError.prototype.constructor=c.ServerError,c.WebSocket=e.Client,c.when=f,c.prototype._getConsole=function(a){if("undefined"!=typeof a.console)return a.console;var b="undefined"!=typeof console&&console||{};return b.log=b.log||function(){},b.warn=b.warn||function(){},b.error=b.error||function(){},b},c.prototype._configure=function(a){var b="undefined"!=typeof document&&"https:"===document.location.protocol?"wss://":"ws://",c="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||b+c+"/mopidy/ws",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,"undefined"==typeof a.callingConvention&&this._console.warn("Mopidy.js is using the default calling convention. The default will change in the future. You should explicitly specify which calling convention you use."),a.callingConvention=a.callingConvention||"by-position-only",a},c.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var d=this._pendingRequests[b];delete this._pendingRequests[b];var e=new c.ConnectionError("WebSocket closed");e.closeEvent=a,d.reject(e)}.bind(this)),this.emit("state:offline")},c.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:return f.reject(new c.ConnectionError("WebSocket is still connecting"));case c.WebSocket.CLOSING:return f.reject(new c.ConnectionError("WebSocket is closing"));case c.WebSocket.CLOSED:return f.reject(new c.ConnectionError("WebSocket is closed"));default:var b=f.defer();return a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a),b.promise}},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return void this._console.warn("Unexpected response received. Message was:",a);var b,d=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?d.resolve(a.result):a.hasOwnProperty("error")?(b=new c.ServerError(a.error.message),b.code=a.error.code,b.data=a.error.data,d.reject(b),this._console.warn("Server returned error:",a.error)):(b=new Error("Response without 'result' or 'error' received"),b.data={response:a},d.reject(b),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this))["catch"](this._handleWebSocketError)},c.prototype._createApi=function(a){var b="by-position-or-by-name"===this._settings.callingConvention,c=function(a){return function(){var c={method:a};return 0===arguments.length?this._send(c):b?arguments.length>1?f.reject(new Error("Expected zero arguments, a single array, or a single object.")):Array.isArray(arguments[0])||arguments[0]===Object(arguments[0])?(c.params=arguments[0],this._send(c)):f.reject(new TypeError("Expected an array or an object.")):(c.params=Array.prototype.slice.call(arguments),this._send(c))}.bind(this)}.bind(this),d=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},e=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),g=function(b){var f=d(b),g=this._snakeToCamel(f.slice(-1)[0]),h=e(f.slice(0,-1));h[g]=c(b),h[g].description=a[b].description,h[g].params=a[b].params}.bind(this);Object.keys(a).forEach(g),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:21}]},{},[22])(22)}); \ No newline at end of file From 9d931adf4a02b75cf6cd9b7ff32d8f9dcacc7e9f Mon Sep 17 00:00:00 2001 From: jcass Date: Tue, 3 Feb 2015 18:30:56 +0200 Subject: [PATCH 232/495] Re-set volume explicitly to the current level whenever changing tracks. Workaround for issue #886. --- mopidy/audio/actor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 0d90394d..9059cf89 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -320,7 +320,9 @@ class Audio(pykka.ThreadingActor): :param uri: the URI to play :type uri: string """ + current_volume = self.get_volume() self._playbin.set_property('uri', uri) + self.set_volume(current_volume) def set_appsrc( self, caps, need_data=None, enough_data=None, seek_data=None): From c698e0793149362701b8d9809664665f946ab5ed Mon Sep 17 00:00:00 2001 From: Lixxia Date: Tue, 3 Feb 2015 20:56:37 -0500 Subject: [PATCH 233/495] changed documentation to make installing from source clearer. --- docs/contributing.rst | 5 ++--- docs/installation/source.rst | 7 +++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index c94ef6ad..a4433951 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -13,9 +13,8 @@ Getting started #. Make sure you have a `GitHub account `_. -#. `Submit `_ a ticket for your - issue, assuming one does not already exist. Clearly describe the issue - including steps to reproduce when it is a bug. +#. If a ticket does not already exist `Submit `_ a ticket for your + issue. Make sure to clearly describe the issue and if it is a bug: include steps to reproduce. #. Fork the repository on GitHub. diff --git a/docs/installation/source.rst b/docs/installation/source.rst index c2c4161a..4ac9c802 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -5,8 +5,10 @@ Install from source ******************* If you are on Linux, but can't install :ref:`from the APT archive -` or :ref:`from AUR `, you can install Mopidy -from source by hand. +` or :ref:`from AUR `, you can install Mopidy using the python package installer. + +If you are looking to contribute or wish to install from source using ``git`` please follow the directions +:ref:`here `. #. First of all, you need Python 2.7. Check if you have Python and what version by running:: @@ -69,6 +71,7 @@ from source by hand. sudo pip install -U mopidy + This will use pip to install the source files for the latest stable release. To upgrade Mopidy to future releases, just rerun this command. Alternatively, if you want to track Mopidy development closer, you may From 090518b96dd35c4bbe8284a701b4279bf394e7a6 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 4 Feb 2015 07:46:31 +0200 Subject: [PATCH 234/495] Add comment for Mac OS X volume reset workaround. --- mopidy/audio/actor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 9059cf89..8f374257 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -320,6 +320,9 @@ class Audio(pykka.ThreadingActor): :param uri: the URI to play :type uri: string """ + # Note: Hack to workaround issue on Mac OS X where volume level + # does not persist between track changes. + # https://github.com/mopidy/mopidy/issues/886 current_volume = self.get_volume() self._playbin.set_property('uri', uri) self.set_volume(current_volume) From a693993905b66a63601cf5765da1788829b0f798 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 7 Feb 2015 17:09:33 +0100 Subject: [PATCH 235/495] flake8: Fix new warnings after flake8 upgrade --- mopidy/audio/actor.py | 4 ++-- mopidy/audio/playlists.py | 2 +- mopidy/local/__init__.py | 2 +- mopidy/local/json.py | 2 +- mopidy/local/translator.py | 2 +- setup.cfg | 4 ++++ tests/__init__.py | 2 +- tests/utils/test_jsonrpc.py | 2 +- 8 files changed, 12 insertions(+), 8 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index ccb802a4..d72b364b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -477,8 +477,8 @@ class Audio(pykka.ThreadingActor): playbin.set_property('flags', PLAYBIN_FLAGS) # TODO: turn into config values... - playbin.set_property('buffer-size', 2*1024*1024) - playbin.set_property('buffer-duration', 2*gst.SECOND) + playbin.set_property('buffer-size', 2 * 1024 * 1024) + playbin.set_property('buffer-duration', 2 * gst.SECOND) self._signals.connect(playbin, 'source-setup', self._on_source_setup) self._signals.connect(playbin, 'about-to-finish', diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 5a362191..61bcb7a1 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -78,7 +78,7 @@ def parse_pls(data): if section.lower() != 'playlist': continue for i in range(cp.getint(section, 'numberofentries')): - yield cp.get(section, 'file%d' % (i+1)) + yield cp.get(section, 'file%d' % (i + 1)) def parse_xspf(data): diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 73d07f75..62228e91 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -27,7 +27,7 @@ class Extension(ext.Extension): schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( - minimum=1000, maximum=1000*60*60) + minimum=1000, maximum=1000 * 60 * 60) schema['scan_flush_threshold'] = config.Integer(minimum=0) schema['scan_follow_symlinks'] = config.Boolean() schema['excluded_file_extensions'] = config.List(optional=True) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 70dc68c4..38e1bf6c 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -75,7 +75,7 @@ class _BrowseCache(object): parent_uri = None child = None for i in reversed(range(len(parts))): - directory = '/'.join(parts[:i+1]) + directory = '/'.join(parts[:i + 1]) uri = translator.path_to_local_directory_uri(directory) # First dir we process is our parent diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 3cbe2066..ab9fc28f 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -59,7 +59,7 @@ def m3u_extinf_to_track(line): return Track() (runtime, title) = m.groups() if int(runtime) > 0: - return Track(name=title, length=1000*int(runtime)) + return Track(name=title, length=1000 * int(runtime)) else: return Track(name=title) diff --git a/setup.cfg b/setup.cfg index 80ab9645..0d6c1486 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,10 @@ [flake8] application-import-names = mopidy,tests exclude = .git,.tox,build,js +# Ignored flake8 warnings: +# - E402 module level import not at top of file +# - E731 do not assign a lambda expression, use a def +ignore = E402,E731 [wheel] universal = 1 diff --git a/tests/__init__.py b/tests/__init__.py index 82759578..4283e604 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -22,7 +22,7 @@ class IsA(object): try: return isinstance(rhs, self.klass) except TypeError: - return type(rhs) == type(self.klass) + return type(rhs) == type(self.klass) # flake8: noqa def __ne__(self, rhs): return not self.__eq__(rhs) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index a74000b2..d236469e 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -614,7 +614,7 @@ class JsonRpcInspectorTest(JsonRpcTestBase): 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, - 'core.tracklist': core.TracklistController, + 'core.tracklist': core.TracklistController, }) methods = inspector.describe() From 4bf7a568d1ce258aaf626f7d1e385a7107ab11df Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sat, 7 Feb 2015 16:24:51 +0100 Subject: [PATCH 236/495] Check that config is readable Implement a check on file permissions for the config files that are loaded and print debug if mopidy fails to load it due to missing file file permissions --- mopidy/config/__init__.py | 5 +++++ tests/config/test_config.py | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index db451cef..885ea3a6 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -148,6 +148,11 @@ def _load_file(parser, filename): logger.debug( 'Loading config from %s failed; it does not exist', filename) return + if not os.access(filename, os.R_OK): + logger.warning( + 'Loading config from %s failed; read permission missing', + filename) + return try: logger.info('Loading config from %s', filename) diff --git a/tests/config/test_config.py b/tests/config/test_config.py index b893c5df..8ee91d0d 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -15,6 +15,18 @@ class LoadConfigTest(unittest.TestCase): def test_load_nothing(self): self.assertEqual({}, config._load([], [], [])) + def test_load_missing_file(self): + file0 = path_to_data_dir('file0.conf') + result = config._load([file0], [], []) + self.assertEqual({}, result) + + @mock.patch('os.access') + def test_load_nonreadable_file(self, access_mock): + access_mock.return_value = False + file1 = path_to_data_dir('file1.conf') + result = config._load([file1], [], []) + self.assertEqual({}, result) + def test_load_single_default(self): default = b'[foo]\nbar = baz' expected = {'foo': {'bar': 'baz'}} From 3dde71ed5ea486408b2f64ff98f3191f0882fdd0 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 8 Feb 2015 23:05:56 +0000 Subject: [PATCH 237/495] Extract uri mapping from MpdContext --- mopidy/mpd/dispatcher.py | 135 ++++++++++++++++++++++++--------------- 1 file changed, 84 insertions(+), 51 deletions(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 5d9cecd9..78536a64 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -227,8 +227,7 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None - _invalid_browse_chars = re.compile(r'[\n\r]') - _invalid_playlist_chars = re.compile(r'[/]') + _mapping = None def __init__(self, dispatcher, session=None, config=None, core=None): self.dispatcher = dispatcher @@ -238,58 +237,19 @@ class MpdContext(object): self.core = core self.events = set() self.subscriptions = set() - self._uri_from_name = {} - self._name_from_uri = {} - self.refresh_playlists_mapping() - - def create_unique_name(self, name, uri): - stripped_name = self._invalid_browse_chars.sub(' ', name) - name = stripped_name - i = 2 - while name in self._uri_from_name: - if self._uri_from_name[name] == uri: - return name - name = '%s [%d]' % (stripped_name, i) - i += 1 - return name - - def insert_name_uri_mapping(self, name, uri): - name = self.create_unique_name(name, uri) - self._uri_from_name[name] = uri - self._name_from_uri[uri] = name - return name - - def refresh_playlists_mapping(self): - """ - Maintain map between playlists and unique playlist names to be used by - MPD - """ - if self.core is not None: - for playlist in self.core.playlists.playlists.get(): - if not playlist.name: - continue - # TODO: add scheme to name perhaps 'foo (spotify)' etc. - name = self._invalid_playlist_chars.sub('|', playlist.name) - self.insert_name_uri_mapping(name, playlist.uri) + self._mapping = MpdUriMapper(core) def lookup_playlist_from_name(self, name): """ Helper function to retrieve a playlist from its unique MPD name. """ - if not self._uri_from_name: - self.refresh_playlists_mapping() - if name not in self._uri_from_name: - return None - uri = self._uri_from_name[name] - return self.core.playlists.lookup(uri).get() + return self._mapping.playlist_from_name(name) def lookup_playlist_name_from_uri(self, uri): """ Helper function to retrieve the unique MPD playlist name from its uri. """ - if uri not in self._name_from_uri: - self.refresh_playlists_mapping() - return self._name_from_uri[uri] + return self._mapping.playlist_name_from_uri(uri) def browse(self, path, recursive=True, lookup=True): """ @@ -313,8 +273,8 @@ class MpdContext(object): path_parts = re.findall(r'[^/]+', path or '') root_path = '/'.join([''] + path_parts) - if root_path not in self._uri_from_name: - uri = None + uri = self._mapping.uri_from_name(root_path) + if uri is None: for part in path_parts: for ref in self.core.library.browse(uri).get(): if ref.type != ref.TRACK and ref.name == part: @@ -322,10 +282,7 @@ class MpdContext(object): break else: raise exceptions.MpdNoExistError('Not found') - root_path = self.insert_name_uri_mapping(root_path, uri) - - else: - uri = self._uri_from_name[root_path] + root_path = self._mapping.insert(root_path, uri) if recursive: yield (root_path, None) @@ -335,7 +292,7 @@ class MpdContext(object): base_path, future = path_and_futures.pop() for ref in future.get(): path = '/'.join([base_path, ref.name.replace('/', '')]) - path = self.insert_name_uri_mapping(path, ref.uri) + path = self._mapping.insert(path, ref.uri) if ref.type == ref.TRACK: if lookup: @@ -347,3 +304,79 @@ class MpdContext(object): if recursive: path_and_futures.append( (path, self.core.library.browse(ref.uri))) + +class MpdUriMapper(object): + """ + Maintains the mappings between uniquified MPD names and URIs. + """ + + #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. + core = None + + _invalid_browse_chars = re.compile(r'[\n\r]') + _invalid_playlist_chars = re.compile(r'[/]') + + def __init__(self, core=None): + self.core = core + self._uri_from_name = {} + self._name_from_uri = {} + self.refresh_playlists_mapping() + + def _create_unique_name(self, name, uri): + stripped_name = self._invalid_browse_chars.sub(' ', name) + name = stripped_name + i = 2 + while name in self._uri_from_name: + if self._uri_from_name[name] == uri: + return name + name = '%s [%d]' % (stripped_name, i) + i += 1 + return name + + def insert(self, name, uri): + """ + Create a unique and MPD compatible name that maps to the given uri. + """ + name = self._create_unique_name(name, uri) + self._uri_from_name[name] = uri + self._name_from_uri[uri] = name + return name + + def uri_from_name(self, name): + """ + Return the uri for the given MPD name. + """ + if name in self._uri_from_name: + return self._uri_from_name[name] + + def refresh_playlists_mapping(self): + """ + Maintain map between playlists and unique playlist names to be used by + MPD + """ + if self.core is not None: + for playlist in self.core.playlists.playlists.get(): + if not playlist.name: + continue + # TODO: add scheme to name perhaps 'foo (spotify)' etc. + name = self._invalid_playlist_chars.sub('|', playlist.name) + self.insert(name, playlist.uri) + + def playlist_from_name(self, name): + """ + Helper function to retrieve a playlist from its unique MPD name. + """ + if not self._uri_from_name: + self.refresh_playlists_mapping() + if name not in self._uri_from_name: + return None + uri = self._uri_from_name[name] + return self.core.playlists.lookup(uri).get() + + def playlist_name_from_uri(self, uri): + """ + Helper function to retrieve the unique MPD playlist name from its uri. + """ + if uri not in self._name_from_uri: + self.refresh_playlists_mapping() + return self._name_from_uri[uri] \ No newline at end of file From 655b7badf4963558a6c519c5620886374147bac7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 00:07:35 +0100 Subject: [PATCH 238/495] listener: Speed up event emitting and improve error reporting --- mopidy/listener.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/mopidy/listener.py b/mopidy/listener.py index 41f8e8e0..286466a5 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -17,7 +17,21 @@ def send(cls, event, **kwargs): listeners = pykka.ActorRegistry.get_by_class(cls) logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs) for listener in listeners: - listener.proxy().on_event(event, **kwargs) + # Save time by calling methods on Pykka actor without creating a + # throwaway actor proxy. + # + # Because we use `.tell()` there is no return channel for any errors, + # so Pykka logs them immediately. The alternative would be to use + # `.ask()` and `.get()` the returned futures to block for the listeners + # to react and return their exceptions to us. Since emitting events in + # practise is making calls upwards in the stack, blocking here would + # quickly deadlock. + listener.tell({ + 'command': 'pykka_call', + 'attr_path': ('on_event',), + 'args': (event,), + 'kwargs': kwargs, + }) class Listener(object): From dfe27a09181f8db95e32d17b370674237c9833f0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 00:09:15 +0100 Subject: [PATCH 239/495] audio: Fix event handler's argument name Every emit of this event caused an invisible exception in every audio listener (e.g. core). The exception was made visible by the change in the previous commit. --- mopidy/audio/listener.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 9472227f..280d4f86 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -37,7 +37,7 @@ class AudioListener(listener.Listener): """ pass - def position_changed(self, position_changed): + def position_changed(self, position): """ Called whenever the position of the stream changes. From d14d64cb7d3668a287f4b9dc3f7dff0d066561e4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 00:07:35 +0100 Subject: [PATCH 240/495] listener: Speed up event emitting and improve error reporting (cherry picked from commit 655b7badf4963558a6c519c5620886374147bac7) --- mopidy/listener.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/mopidy/listener.py b/mopidy/listener.py index c8ecfa53..c32960c7 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -17,7 +17,21 @@ def send(cls, event, **kwargs): listeners = pykka.ActorRegistry.get_by_class(cls) logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs) for listener in listeners: - listener.proxy().on_event(event, **kwargs) + # Save time by calling methods on Pykka actor without creating a + # throwaway actor proxy. + # + # Because we use `.tell()` there is no return channel for any errors, + # so Pykka logs them immediately. The alternative would be to use + # `.ask()` and `.get()` the returned futures to block for the listeners + # to react and return their exceptions to us. Since emitting events in + # practise is making calls upwards in the stack, blocking here would + # quickly deadlock. + listener.tell({ + 'command': 'pykka_call', + 'attr_path': ('on_event',), + 'args': (event,), + 'kwargs': kwargs, + }) class Listener(object): From faa5e01e0a7b19b5b6db1feb87c041d6c770bc90 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 00:37:41 +0100 Subject: [PATCH 241/495] docs: Update changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 40d5cedc..c7594aa3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,8 @@ Bug fix release. - Audio: Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) +- Events: Speed up event emitting. + v0.19.5 (2014-12-23) ==================== From f77b73a260e8043fdb39b8564acf104fe9b16d96 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 8 Feb 2015 23:42:12 +0000 Subject: [PATCH 242/495] Share a global MPDUriMappper between all MPD sessions --- mopidy/mpd/actor.py | 4 +++- mopidy/mpd/dispatcher.py | 24 +++++++++++++----------- mopidy/mpd/session.py | 4 ++-- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index c8123c32..cd9b9145 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -6,7 +6,7 @@ import pykka from mopidy import exceptions, zeroconf from mopidy.core import CoreListener -from mopidy.mpd import session +from mopidy.mpd import dispatcher, session from mopidy.utils import encoding, network, process logger = logging.getLogger(__name__) @@ -18,6 +18,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): self.hostname = network.format_hostname(config['mpd']['hostname']) self.port = config['mpd']['port'] + self.uri_map = dispatcher.MpdUriMapper(core) self.zeroconf_name = config['mpd']['zeroconf'] self.zeroconf_service = None @@ -29,6 +30,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): protocol_kwargs={ 'config': config, 'core': core, + 'uri_map': self.uri_map, }, max_connections=config['mpd']['max_connections'], timeout=config['mpd']['connection_timeout']) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 78536a64..a591127c 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -21,7 +21,7 @@ class MpdDispatcher(object): _noidle = re.compile(r'^noidle$') - def __init__(self, session=None, config=None, core=None): + def __init__(self, session=None, config=None, core=None, uri_map=None): self.config = config self.authenticated = False self.command_list_receiving = False @@ -29,7 +29,7 @@ class MpdDispatcher(object): self.command_list = [] self.command_list_index = None self.context = MpdContext( - self, session=session, config=config, core=core) + self, session=session, config=config, core=core, uri_map=uri_map) def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" @@ -227,9 +227,10 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None - _mapping = None + _uri_map = None - def __init__(self, dispatcher, session=None, config=None, core=None): + def __init__(self, dispatcher, session=None, config=None, core=None, + uri_map=None): self.dispatcher = dispatcher self.session = session if config is not None: @@ -237,19 +238,19 @@ class MpdContext(object): self.core = core self.events = set() self.subscriptions = set() - self._mapping = MpdUriMapper(core) + self._uri_map = uri_map def lookup_playlist_from_name(self, name): """ Helper function to retrieve a playlist from its unique MPD name. """ - return self._mapping.playlist_from_name(name) + return self._uri_map.playlist_from_name(name) def lookup_playlist_name_from_uri(self, uri): """ Helper function to retrieve the unique MPD playlist name from its uri. """ - return self._mapping.playlist_name_from_uri(uri) + return self._uri_map.playlist_name_from_uri(uri) def browse(self, path, recursive=True, lookup=True): """ @@ -273,7 +274,7 @@ class MpdContext(object): path_parts = re.findall(r'[^/]+', path or '') root_path = '/'.join([''] + path_parts) - uri = self._mapping.uri_from_name(root_path) + uri = self._uri_map.uri_from_name(root_path) if uri is None: for part in path_parts: for ref in self.core.library.browse(uri).get(): @@ -282,7 +283,7 @@ class MpdContext(object): break else: raise exceptions.MpdNoExistError('Not found') - root_path = self._mapping.insert(root_path, uri) + root_path = self._uri_map.insert(root_path, uri) if recursive: yield (root_path, None) @@ -292,7 +293,7 @@ class MpdContext(object): base_path, future = path_and_futures.pop() for ref in future.get(): path = '/'.join([base_path, ref.name.replace('/', '')]) - path = self._mapping.insert(path, ref.uri) + path = self._uri_map.insert(path, ref.uri) if ref.type == ref.TRACK: if lookup: @@ -305,6 +306,7 @@ class MpdContext(object): path_and_futures.append( (path, self.core.library.browse(ref.uri))) + class MpdUriMapper(object): """ Maintains the mappings between uniquified MPD names and URIs. @@ -379,4 +381,4 @@ class MpdUriMapper(object): """ if uri not in self._name_from_uri: self.refresh_playlists_mapping() - return self._name_from_uri[uri] \ No newline at end of file + return self._name_from_uri[uri] diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index 0e606c8f..9f7fabeb 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -18,10 +18,10 @@ class MpdSession(network.LineProtocol): encoding = protocol.ENCODING delimiter = r'\r?\n' - def __init__(self, connection, config=None, core=None): + def __init__(self, connection, config=None, core=None, uri_map=None): super(MpdSession, self).__init__(connection) self.dispatcher = dispatcher.MpdDispatcher( - session=self, config=config, core=core) + session=self, config=config, core=core, uri_map=uri_map) def on_start(self): logger.info('New MPD connection from [%s]:%s', self.host, self.port) From 317d02de3e7a181b5248bcb57045478c41d35a3e Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 8 Feb 2015 23:43:22 +0000 Subject: [PATCH 243/495] Pass MPDUrimapper into mpd test setup --- tests/mpd/protocol/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 8c744a78..f895316b 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -8,7 +8,7 @@ import pykka from mopidy import core from mopidy.backend import dummy -from mopidy.mpd import session +from mopidy.mpd import dispatcher, session class MockConnection(mock.Mock): @@ -35,9 +35,11 @@ class BaseTestCase(unittest.TestCase): self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() + self.uri_map = dispatcher.MpdUriMapper(self.core) self.connection = MockConnection() self.session = session.MpdSession( - self.connection, config=self.get_config(), core=self.core) + self.connection, config=self.get_config(), core=self.core, + uri_map=self.uri_map) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context From 037a88aece4f80d86bbcf4d5a4aa905671860215 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 8 Feb 2015 23:47:28 +0000 Subject: [PATCH 244/495] First playlist mapping refresh deferred until needed --- mopidy/mpd/dispatcher.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index a591127c..a0950625 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -322,7 +322,6 @@ class MpdUriMapper(object): self.core = core self._uri_from_name = {} self._name_from_uri = {} - self.refresh_playlists_mapping() def _create_unique_name(self, name, uri): stripped_name = self._invalid_browse_chars.sub(' ', name) From efd48d864f6f175a7941b15ee0211f7362729656 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 7 Feb 2015 17:09:33 +0100 Subject: [PATCH 245/495] flake8: Fix new warnings after flake8 upgrade (cherry picked from commit a693993905b66a63601cf5765da1788829b0f798) Conflicts: mopidy/audio/actor.py mopidy/audio/playlists.py --- mopidy/audio/actor.py | 4 ++-- mopidy/audio/playlists.py | 2 +- mopidy/local/__init__.py | 2 +- mopidy/local/json.py | 2 +- mopidy/local/translator.py | 2 +- setup.cfg | 4 ++++ tests/__init__.py | 2 +- tests/utils/test_jsonrpc.py | 2 +- 8 files changed, 12 insertions(+), 8 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 0d90394d..d2701784 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -109,8 +109,8 @@ class Audio(pykka.ThreadingActor): playbin = gst.element_factory_make('playbin2') playbin.set_property('flags', PLAYBIN_FLAGS) - playbin.set_property('buffer-size', 2*1024*1024) - playbin.set_property('buffer-duration', 2*gst.SECOND) + playbin.set_property('buffer-size', 2 * 1024 * 1024) + playbin.set_property('buffer-duration', 2 * gst.SECOND) self._connect(playbin, 'about-to-finish', self._on_about_to_finish) self._connect(playbin, 'notify::source', self._on_new_source) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index ec5fd63a..8f8232b2 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -77,7 +77,7 @@ def parse_pls(data): if section.lower() != 'playlist': continue for i in xrange(cp.getint(section, 'numberofentries')): - yield cp.get(section, 'file%d' % (i+1)) + yield cp.get(section, 'file%d' % (i + 1)) def parse_xspf(data): diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 104c43af..9b485f19 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -27,7 +27,7 @@ class Extension(ext.Extension): schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( - minimum=1000, maximum=1000*60*60) + minimum=1000, maximum=1000 * 60 * 60) schema['scan_flush_threshold'] = config.Integer(minimum=0) schema['excluded_file_extensions'] = config.List(optional=True) return schema diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 5ae04592..b3a2ff39 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -75,7 +75,7 @@ class _BrowseCache(object): parent_uri = None child = None for i in reversed(range(len(parts))): - directory = '/'.join(parts[:i+1]) + directory = '/'.join(parts[:i + 1]) uri = translator.path_to_local_directory_uri(directory) # First dir we process is our parent diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 33b67775..3c1d38ae 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -49,7 +49,7 @@ def m3u_extinf_to_track(line): return Track() (runtime, title) = m.groups() if int(runtime) > 0: - return Track(name=title, length=1000*int(runtime)) + return Track(name=title, length=1000 * int(runtime)) else: return Track(name=title) diff --git a/setup.cfg b/setup.cfg index 80ab9645..0d6c1486 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,10 @@ [flake8] application-import-names = mopidy,tests exclude = .git,.tox,build,js +# Ignored flake8 warnings: +# - E402 module level import not at top of file +# - E731 do not assign a lambda expression, use a def +ignore = E402,E731 [wheel] universal = 1 diff --git a/tests/__init__.py b/tests/__init__.py index a384669e..327ca5a8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,7 +20,7 @@ class IsA(object): try: return isinstance(rhs, self.klass) except TypeError: - return type(rhs) == type(self.klass) + return type(rhs) == type(self.klass) # flake8: noqa def __ne__(self, rhs): return not self.__eq__(rhs) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index e6f94fb3..c8d37d04 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -614,7 +614,7 @@ class JsonRpcInspectorTest(JsonRpcTestBase): 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, - 'core.tracklist': core.TracklistController, + 'core.tracklist': core.TracklistController, }) methods = inspector.describe() From 0de13994c234b3a6cd0be14ac01364814d5b66ef Mon Sep 17 00:00:00 2001 From: kingosticks Date: Mon, 9 Feb 2015 12:07:17 +0000 Subject: [PATCH 246/495] Moved MPDUriMapper to own file and updated changelog --- docs/changelog.rst | 3 ++ mopidy/mpd/actor.py | 4 +- mopidy/mpd/dispatcher.py | 76 -------------------------------- mopidy/mpd/uri_mapper.py | 79 ++++++++++++++++++++++++++++++++++ tests/mpd/protocol/__init__.py | 4 +- 5 files changed, 86 insertions(+), 80 deletions(-) create mode 100644 mopidy/mpd/uri_mapper.py diff --git a/docs/changelog.rst b/docs/changelog.rst index 5be97bd9..47bbe0b3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -46,6 +46,9 @@ v0.20.0 (UNRELEASED) - Enable browsing of artist references, in addition to albums and playlists. (PR: :issue:`884`) + +- Share a single mapping between names and URIs across all MPD sessions. (Fixes: + :issue:`934`, PR: :issue:`968`) **Audio** diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index cd9b9145..c9ffff02 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -6,7 +6,7 @@ import pykka from mopidy import exceptions, zeroconf from mopidy.core import CoreListener -from mopidy.mpd import dispatcher, session +from mopidy.mpd import session, uri_mapper from mopidy.utils import encoding, network, process logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): self.hostname = network.format_hostname(config['mpd']['hostname']) self.port = config['mpd']['port'] - self.uri_map = dispatcher.MpdUriMapper(core) + self.uri_map = uri_mapper.MpdUriMapper(core) self.zeroconf_name = config['mpd']['zeroconf'] self.zeroconf_service = None diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index a0950625..b1b2db77 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -305,79 +305,3 @@ class MpdContext(object): if recursive: path_and_futures.append( (path, self.core.library.browse(ref.uri))) - - -class MpdUriMapper(object): - """ - Maintains the mappings between uniquified MPD names and URIs. - """ - - #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. - core = None - - _invalid_browse_chars = re.compile(r'[\n\r]') - _invalid_playlist_chars = re.compile(r'[/]') - - def __init__(self, core=None): - self.core = core - self._uri_from_name = {} - self._name_from_uri = {} - - def _create_unique_name(self, name, uri): - stripped_name = self._invalid_browse_chars.sub(' ', name) - name = stripped_name - i = 2 - while name in self._uri_from_name: - if self._uri_from_name[name] == uri: - return name - name = '%s [%d]' % (stripped_name, i) - i += 1 - return name - - def insert(self, name, uri): - """ - Create a unique and MPD compatible name that maps to the given uri. - """ - name = self._create_unique_name(name, uri) - self._uri_from_name[name] = uri - self._name_from_uri[uri] = name - return name - - def uri_from_name(self, name): - """ - Return the uri for the given MPD name. - """ - if name in self._uri_from_name: - return self._uri_from_name[name] - - def refresh_playlists_mapping(self): - """ - Maintain map between playlists and unique playlist names to be used by - MPD - """ - if self.core is not None: - for playlist in self.core.playlists.playlists.get(): - if not playlist.name: - continue - # TODO: add scheme to name perhaps 'foo (spotify)' etc. - name = self._invalid_playlist_chars.sub('|', playlist.name) - self.insert(name, playlist.uri) - - def playlist_from_name(self, name): - """ - Helper function to retrieve a playlist from its unique MPD name. - """ - if not self._uri_from_name: - self.refresh_playlists_mapping() - if name not in self._uri_from_name: - return None - uri = self._uri_from_name[name] - return self.core.playlists.lookup(uri).get() - - def playlist_name_from_uri(self, uri): - """ - Helper function to retrieve the unique MPD playlist name from its uri. - """ - if uri not in self._name_from_uri: - self.refresh_playlists_mapping() - return self._name_from_uri[uri] diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py new file mode 100644 index 00000000..99413493 --- /dev/null +++ b/mopidy/mpd/uri_mapper.py @@ -0,0 +1,79 @@ +from __future__ import absolute_import, unicode_literals + +import re + + +class MpdUriMapper(object): + """ + Maintains the mappings between uniquified MPD names and URIs. + """ + + #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. + core = None + + _invalid_browse_chars = re.compile(r'[\n\r]') + _invalid_playlist_chars = re.compile(r'[/]') + + def __init__(self, core=None): + self.core = core + self._uri_from_name = {} + self._name_from_uri = {} + + def _create_unique_name(self, name, uri): + stripped_name = self._invalid_browse_chars.sub(' ', name) + name = stripped_name + i = 2 + while name in self._uri_from_name: + if self._uri_from_name[name] == uri: + return name + name = '%s [%d]' % (stripped_name, i) + i += 1 + return name + + def insert(self, name, uri): + """ + Create a unique and MPD compatible name that maps to the given uri. + """ + name = self._create_unique_name(name, uri) + self._uri_from_name[name] = uri + self._name_from_uri[uri] = name + return name + + def uri_from_name(self, name): + """ + Return the uri for the given MPD name. + """ + if name in self._uri_from_name: + return self._uri_from_name[name] + + def refresh_playlists_mapping(self): + """ + Maintain map between playlists and unique playlist names to be used by + MPD + """ + if self.core is not None: + for playlist in self.core.playlists.playlists.get(): + if not playlist.name: + continue + # TODO: add scheme to name perhaps 'foo (spotify)' etc. + name = self._invalid_playlist_chars.sub('|', playlist.name) + self.insert(name, playlist.uri) + + def playlist_from_name(self, name): + """ + Helper function to retrieve a playlist from its unique MPD name. + """ + if not self._uri_from_name: + self.refresh_playlists_mapping() + if name not in self._uri_from_name: + return None + uri = self._uri_from_name[name] + return self.core.playlists.lookup(uri).get() + + def playlist_name_from_uri(self, uri): + """ + Helper function to retrieve the unique MPD playlist name from its uri. + """ + if uri not in self._name_from_uri: + self.refresh_playlists_mapping() + return self._name_from_uri[uri] diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index f895316b..8c7b60f1 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -8,7 +8,7 @@ import pykka from mopidy import core from mopidy.backend import dummy -from mopidy.mpd import dispatcher, session +from mopidy.mpd import session, uri_mapper class MockConnection(mock.Mock): @@ -35,7 +35,7 @@ class BaseTestCase(unittest.TestCase): self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() - self.uri_map = dispatcher.MpdUriMapper(self.core) + self.uri_map = uri_mapper.MpdUriMapper(self.core) self.connection = MockConnection() self.session = session.MpdSession( self.connection, config=self.get_config(), core=self.core, From 0ec92121465077eb84423785311007e4c72ee3cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 13:13:51 +0100 Subject: [PATCH 247/495] docs: Break lines and tweak changes from PR#959 --- docs/contributing.rst | 6 ++++-- docs/installation/source.rst | 12 +++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index a4433951..165fee49 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -13,8 +13,10 @@ Getting started #. Make sure you have a `GitHub account `_. -#. If a ticket does not already exist `Submit `_ a ticket for your - issue. Make sure to clearly describe the issue and if it is a bug: include steps to reproduce. +#. If a ticket does not already exist `submit a ticket + `_ for your issue. + Make sure to clearly describe the issue, and if it is a bug: include steps + to reproduce. #. Fork the repository on GitHub. diff --git a/docs/installation/source.rst b/docs/installation/source.rst index 4ac9c802..2c4147f1 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -5,10 +5,11 @@ Install from source ******************* If you are on Linux, but can't install :ref:`from the APT archive -` or :ref:`from AUR `, you can install Mopidy using the python package installer. +` or :ref:`from AUR `, you can install Mopidy +from PyPI using the ``pip`` installer. -If you are looking to contribute or wish to install from source using ``git`` please follow the directions -:ref:`here `. +If you are looking to contribute or wish to install from source using ``git`` +please follow the directions :ref:`here `. #. First of all, you need Python 2.7. Check if you have Python and what version by running:: @@ -71,8 +72,9 @@ If you are looking to contribute or wish to install from source using ``git`` pl sudo pip install -U mopidy - This will use pip to install the source files for the latest stable release. - To upgrade Mopidy to future releases, just rerun this command. + This will use ``pip`` to install the latest release of `Mopidy from PyPI + `_. To upgrade Mopidy to future + releases, just rerun this command. Alternatively, if you want to track Mopidy development closer, you may install a snapshot of Mopidy's ``develop`` Git branch using pip:: From 75955822b70884c130d3b04bcbe8689650f0b53e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 13:16:17 +0100 Subject: [PATCH 248/495] docs: Remove ext steps from source install docs Maintaining multiple copies of instructions for how to install specific extensions doesn't scale well. --- docs/installation/source.rst | 36 +++--------------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/docs/installation/source.rst b/docs/installation/source.rst index 2c4147f1..0b4fc5aa 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -81,39 +81,9 @@ please follow the directions :ref:`here `. sudo pip install --allow-unverified=mopidy mopidy==dev -#. Optional: If you want Spotify support in Mopidy, you'll need to install - libspotify and the Mopidy-Spotify extension. - - #. Download and install the latest version of libspotify for your OS and CPU - architecture from `Spotify - `_. - - For libspotify 12.1.51 for 64-bit Linux the process is as follows:: - - wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-x86_64-release.tar.gz - tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz - cd libspotify-12.1.51-Linux-x86_64-release/ - sudo make install prefix=/usr/local - - Remember to adjust the above example for the latest libspotify version - supported by pyspotify, your OS, and your CPU architecture. - - #. If you're on Fedora, you must add a configuration file so libspotify.so - can be found:: - - echo /usr/local/lib | sudo tee /etc/ld.so.conf.d/libspotify.conf - sudo ldconfig - - #. Then install the latest release of Mopidy-Spotify using pip:: - - sudo pip install -U mopidy-spotify - -#. Optional: If you want to scrobble your played tracks to Last.fm, you need - to install Mopidy-Scrobbler:: - - sudo pip install -U mopidy-scrobbler - -#. For a full list of available Mopidy extensions, see :ref:`ext`. +#. Optional: For Spotify support, Last.fm scrobbling, or many other extra + features, install the required Mopidy extensions. For a full list of + available Mopidy extensions, see :ref:`ext`. #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. From 888af617732654dc9e6cbfd3fc6677dae5da184a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 13:19:05 +0100 Subject: [PATCH 249/495] docs: Update authors --- .mailmap | 1 + AUTHORS | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.mailmap b/.mailmap index 45935be6..8b8fd865 100644 --- a/.mailmap +++ b/.mailmap @@ -17,3 +17,4 @@ Luke Giuliani Colin Montgomerie Ignasi Fosch Christopher Schirner +Laura Barber diff --git a/AUTHORS b/AUTHORS index cd347da8..45817e75 100644 --- a/AUTHORS +++ b/AUTHORS @@ -47,3 +47,6 @@ - Lukas Vogel - Thomas Amland - Deni Bertovic +- Ali Ukani +- Dirk Groenen +- Laura Barber From 4c7ad57e73bc58632994538d080b7d42addf86ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 13:31:27 +0100 Subject: [PATCH 250/495] mpd: Capitalize abbrevations in docstrings --- mopidy/mpd/uri_mapper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py index 99413493..082f1311 100644 --- a/mopidy/mpd/uri_mapper.py +++ b/mopidy/mpd/uri_mapper.py @@ -32,7 +32,7 @@ class MpdUriMapper(object): def insert(self, name, uri): """ - Create a unique and MPD compatible name that maps to the given uri. + Create a unique and MPD compatible name that maps to the given URI. """ name = self._create_unique_name(name, uri) self._uri_from_name[name] = uri @@ -49,7 +49,7 @@ class MpdUriMapper(object): def refresh_playlists_mapping(self): """ Maintain map between playlists and unique playlist names to be used by - MPD + MPD. """ if self.core is not None: for playlist in self.core.playlists.playlists.get(): @@ -72,7 +72,7 @@ class MpdUriMapper(object): def playlist_name_from_uri(self, uri): """ - Helper function to retrieve the unique MPD playlist name from its uri. + Helper function to retrieve the unique MPD playlist name from its URI. """ if uri not in self._name_from_uri: self.refresh_playlists_mapping() From b7ed2b8681dd9e9c7b5a49a16226a6b37b7e09d5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 10 Feb 2015 23:12:03 +0100 Subject: [PATCH 251/495] core: Fix variable naming, style --- mopidy/core/actor.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index ff60f190..d53e4d38 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -107,31 +107,29 @@ class Core( CoreListener.send('mute_changed', mute=mute) def tags_changed(self, tags): - # Validity checks if not self.audio: return - if self.playback.current_tl_track is None: + + current_tl_track = self.playback.current_tl_track + if current_tl_track is None: return tags = self.audio.get_current_tags().get() if not tags: return - # Request available metadata and set a track - mt_track = convert_tags_to_track(tags) + current_track = current_tl_track.track + tags_track = convert_tags_to_track(tags) - # Merge current_tl_track with metadata in current_metadata_track - c_track = self.playback.current_tl_track.track - track_kwargs = {k: v for k, v in c_track.__dict__.items() if v} - for k, v in mt_track.__dict__.items(): - if v: - track_kwargs[k] = v + track_kwargs = {k: v for k, v in current_track.__dict__.items() if v} + track_kwargs.update( + {k: v for k, v in tags_track.__dict__.items() if v}) self.playback.current_metadata_track = TlTrack(**{ - 'tlid': self.playback.current_tl_track.tlid, + 'tlid': current_tl_track.tlid, 'track': Track(**track_kwargs)}) - # Send event to frontends + # TODO Move this into playback.current_metadata_track setter? CoreListener.send('current_metadata_changed') From 1b6db5695d6271c047d0cba1b291f7fc98c2fff4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 10 Feb 2015 23:43:16 +0100 Subject: [PATCH 252/495] core: current_metadata_track is TlTrack, not Track --- mopidy/core/playback.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 2bc2fbe6..3b359b28 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -52,6 +52,17 @@ class PlaybackController(object): Read-only. Extracted from :attr:`current_tl_track` for convenience. """ + def get_current_metadata_track(self): + return self.current_metadata_track + + current_metadata_track = None + """ + A :class:`mopidy.models.TlTrack` with updated metadata for the currently + playing track. + + :class:`None` if no track is currently playing. + """ + def get_state(self): return self._state @@ -126,15 +137,6 @@ class PlaybackController(object): mute = property(get_mute, set_mute) """Mute state as a :class:`True` if muted, :class:`False` otherwise""" - def get_current_metadata_track(self): - return self.current_metadata_track - - current_metadata_track = None - """ - The currently playing metadata :class:`mopidy.models.Track`, - or :class:`None`. - """ - # Methods # TODO: remove this. From 352de135cd12dfccf8376cfaf53078c9611d821b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 01:11:56 +0100 Subject: [PATCH 253/495] core: Deprecate properties (fixes #952) --- docs/changelog.rst | 4 ++ mopidy/core/actor.py | 12 +++- mopidy/core/playback.py | 123 +++++++++++++++++++++++++++++---------- mopidy/core/playlists.py | 10 +++- mopidy/core/tracklist.py | 120 +++++++++++++++++++++++++++++--------- 5 files changed, 203 insertions(+), 66 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 47bbe0b3..1a9eb33d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,10 @@ v0.20.0 (UNRELEASED) **Core API** +- Deprecate all properties in the core API. The previously undocumented getter + and setter methods are now the official API. This aligns the Python API with + the WebSocket/JavaScript API. (Fixes: :issue:`952`) + - Added :class:`mopidy.core.HistoryController` which keeps track of what tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index d53e4d38..370c9e0d 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -62,19 +62,27 @@ class Core( self.audio = audio def get_uri_schemes(self): + """Get list of URI schemes we can handle""" futures = [b.uri_schemes for b in self.backends] results = pykka.get_all(futures) uri_schemes = itertools.chain(*results) return sorted(uri_schemes) uri_schemes = property(get_uri_schemes) - """List of URI schemes we can handle""" + """ + .. deprecated:: 0.20 + Use :meth:`get_uri_schemes` instead. + """ def get_version(self): + """Get version of the Mopidy core API""" return versioning.get_version() version = property(get_version) - """Version of the Mopidy core API""" + """ + .. deprecated:: 0.20 + Use :meth:`get_version` instead. + """ def reached_end_of_stream(self): self.playback.on_end_of_track() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 3b359b28..72a9207c 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -19,6 +19,8 @@ class PlaybackController(object): self.backends = backends self.core = core + self._current_tl_track = None + self._current_metadata_track = None self._state = PlaybackState.STOPPED self._volume = None self._mute = False @@ -34,39 +36,80 @@ class PlaybackController(object): # Properties def get_current_tl_track(self): - return self.current_tl_track + """Get the currently playing or selected track. - current_tl_track = None + Returns a :class:`mopidy.models.TlTrack` or :class:`None`. + """ + return self._current_tl_track + + def set_current_tl_track(self, value): + """Set the currently playing or selected track. + + *Internal:* This is only for use by Mopidy's test suite. + """ + self._current_tl_track = value + + current_tl_track = property(get_current_tl_track, set_current_tl_track) """ - The currently playing or selected :class:`mopidy.models.TlTrack`, or - :class:`None`. + .. deprecated:: 0.20 + Use :meth:`get_current_tl_track` instead. """ def get_current_track(self): - return self.current_tl_track and self.current_tl_track.track + """ + Get the currently playing or selected track. + + Extracted from :meth:`get_current_tl_track` for convenience. + + Returns a :class:`mopidy.models.Track` or :class:`None`. + """ + tl_track = self.get_current_tl_track() + if tl_track is not None: + return tl_track.track current_track = property(get_current_track) """ - The currently playing or selected :class:`mopidy.models.Track`. - - Read-only. Extracted from :attr:`current_tl_track` for convenience. + .. deprecated:: 0.20 + Use :meth:`get_current_track` instead. """ def get_current_metadata_track(self): - return self.current_metadata_track + """ + Get a :class:`mopidy.models.TlTrack` with updated metadata for the + currently playing track. - current_metadata_track = None + Returns :class:`None` if no track is currently playing. + """ + return self._current_metadata_track + + current_metadata_track = property(get_current_metadata_track) """ - A :class:`mopidy.models.TlTrack` with updated metadata for the currently - playing track. - - :class:`None` if no track is currently playing. + .. deprecated:: 0.20 + Use :meth:`get_current_metadata_track` instead. """ def get_state(self): + """Get The playback state.""" + return self._state def set_state(self, new_state): + """Set the playback state. + + Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`. + + Possible states and transitions: + + .. digraph:: state_transitions + + "STOPPED" -> "PLAYING" [ label="play" ] + "STOPPED" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "STOPPED" [ label="stop" ] + "PLAYING" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "PLAYING" [ label="play" ] + "PAUSED" -> "PLAYING" [ label="resume" ] + "PAUSED" -> "STOPPED" [ label="stop" ] + """ (old_state, self._state) = (self.state, new_state) logger.debug('Changing state: %s -> %s', old_state, new_state) @@ -74,23 +117,12 @@ class PlaybackController(object): state = property(get_state, set_state) """ - The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or - :attr:`STOPPED`. - - Possible states and transitions: - - .. digraph:: state_transitions - - "STOPPED" -> "PLAYING" [ label="play" ] - "STOPPED" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "STOPPED" [ label="stop" ] - "PLAYING" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "PLAYING" [ label="play" ] - "PAUSED" -> "PLAYING" [ label="resume" ] - "PAUSED" -> "STOPPED" [ label="stop" ] + .. deprecated:: 0.20 + Use :meth:`get_state` and :meth:`set_state` instead. """ def get_time_position(self): + """Get time position in milliseconds.""" backend = self._get_backend() if backend: return backend.playback.get_time_position().get() @@ -98,9 +130,18 @@ class PlaybackController(object): return 0 time_position = property(get_time_position) - """Time position in milliseconds.""" + """ + .. deprecated:: 0.20 + Use :meth:`get_time_position` instead. + """ def get_volume(self): + """Get the volume. + + Integer in range [0..100] or :class:`None` if unknown. + + The volume scale is linear. + """ if self.mixer: return self.mixer.get_volume().get() else: @@ -108,6 +149,12 @@ class PlaybackController(object): return self._volume def set_volume(self, volume): + """Set the volume. + + The volume is defined as an integer in range [0..100]. + + The volume scale is linear. + """ if self.mixer: self.mixer.set_volume(volume) else: @@ -115,11 +162,16 @@ class PlaybackController(object): self._volume = volume volume = property(get_volume, set_volume) - """Volume as int in range [0..100] or :class:`None` if unknown. The volume - scale is linear. + """ + .. deprecated:: 0.20 + Use :meth:`get_volume` and :meth:`set_volume` instead. """ def get_mute(self): + """Get mute state. + + :class:`True` if muted, :class:`False` otherwise. + """ if self.mixer: return self.mixer.get_mute().get() else: @@ -127,6 +179,10 @@ class PlaybackController(object): return self._mute def set_mute(self, value): + """Set mute state. + + :class:`True` to mute, :class:`False` to unmute. + """ value = bool(value) if self.mixer: self.mixer.set_mute(value) @@ -135,7 +191,10 @@ class PlaybackController(object): self._mute = value mute = property(get_mute, set_mute) - """Mute state as a :class:`True` if muted, :class:`False` otherwise""" + """ + .. deprecated:: 0.20 + Use :meth:`get_mute` and :meth:`set_mute` instead. + """ # Methods diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index c896bfa7..16b29b85 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -15,6 +15,11 @@ class PlaylistsController(object): self.backends = backends self.core = core + """ + Get the available playlists. + + Returns a list of :class:`mopidy.models.Playlist`. + """ def get_playlists(self, include_tracks=True): futures = [b.playlists.playlists for b in self.backends.with_playlists.values()] @@ -26,9 +31,8 @@ class PlaylistsController(object): playlists = property(get_playlists) """ - The available playlists. - - Read-only. List of :class:`mopidy.models.Playlist`. + .. deprecated:: 0.20 + Use :meth:`get_playlists` instead. """ def create(self, name, uri_scheme=None): diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index f9560a13..5f7ddba1 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -26,32 +26,42 @@ class TracklistController(object): # Properties def get_tl_tracks(self): + """Get tracklist as list of :class:`mopidy.models.TlTrack`.""" return self._tl_tracks[:] tl_tracks = property(get_tl_tracks) """ - List of :class:`mopidy.models.TlTrack`. - - Read-only. + .. deprecated:: 0.20 + Use :meth:`get_tl_tracks` instead. """ def get_tracks(self): + """Get tracklist as list of :class:`mopidy.models.Track`.""" return [tl_track.track for tl_track in self._tl_tracks] tracks = property(get_tracks) """ - List of :class:`mopidy.models.Track` in the tracklist. - - Read-only. + .. deprecated:: 0.20 + Use :meth:`get_tracks` instead. """ def get_length(self): + """Get length of the tracklist.""" return len(self._tl_tracks) length = property(get_length) - """Length of the tracklist.""" + """ + .. deprecated:: 0.20 + Use :meth:`get_length` instead. + """ def get_version(self): + """ + Get the tracklist version. + + Integer which is increased every time the tracklist is changed. Is not + reset before Mopidy is restarted. + """ return self._version def _increase_version(self): @@ -61,32 +71,57 @@ class TracklistController(object): version = property(get_version) """ - The tracklist version. - - Read-only. Integer which is increased every time the tracklist is changed. - Is not reset before Mopidy is restarted. + .. deprecated:: 0.20 + Use :meth:`get_version` instead. """ def get_consume(self): + """Get consume mode. + + :class:`True` + Tracks are removed from the tracklist when they have been played. + :class:`False` + Tracks are not removed from the tracklist. + """ return getattr(self, '_consume', False) def set_consume(self, value): + """Set consume mode. + + :class:`True` + Tracks are removed from the tracklist when they have been played. + :class:`False` + Tracks are not removed from the tracklist. + """ if self.get_consume() != value: self._trigger_options_changed() return setattr(self, '_consume', value) consume = property(get_consume, set_consume) """ - :class:`True` - Tracks are removed from the tracklist when they have been played. - :class:`False` - Tracks are not removed from the tracklist. + .. deprecated:: 0.20 + Use :meth:`get_consume` and :meth:`set_consume` instead. """ def get_random(self): + """Get random mode. + + :class:`True` + Tracks are selected at random from the tracklist. + :class:`False` + Tracks are played in the order of the tracklist. + """ return getattr(self, '_random', False) def set_random(self, value): + """Set random mode. + + :class:`True` + Tracks are selected at random from the tracklist. + :class:`False` + Tracks are played in the order of the tracklist. + """ + if self.get_random() != value: self._trigger_options_changed() if value: @@ -96,44 +131,71 @@ class TracklistController(object): random = property(get_random, set_random) """ - :class:`True` - Tracks are selected at random from the tracklist. - :class:`False` - Tracks are played in the order of the tracklist. + .. deprecated:: 0.20 + Use :meth:`get_random` and :meth:`set_random` instead. """ def get_repeat(self): + """ + Get repeat mode. + + :class:`True` + The tracklist is played repeatedly. + :class:`False` + The tracklist is played once. + """ return getattr(self, '_repeat', False) def set_repeat(self, value): + """ + Set repeat mode. + + To repeat a single track, set both ``repeat`` and ``single``. + + :class:`True` + The tracklist is played repeatedly. + :class:`False` + The tracklist is played once. + """ + if self.get_repeat() != value: self._trigger_options_changed() return setattr(self, '_repeat', value) repeat = property(get_repeat, set_repeat) """ - :class:`True` - The tracklist is played repeatedly. To repeat a single track, select - both :attr:`repeat` and :attr:`single`. - :class:`False` - The tracklist is played once. + .. deprecated:: 0.20 + Use :meth:`get_repeat` and :meth:`set_repeat` instead. """ def get_single(self): + """ + Get single mode. + + :class:`True` + Playback is stopped after current song, unless in ``repeat`` mode. + :class:`False` + Playback continues after current song. + """ return getattr(self, '_single', False) def set_single(self, value): + """ + Set single mode. + + :class:`True` + Playback is stopped after current song, unless in ``repeat`` mode. + :class:`False` + Playback continues after current song. + """ if self.get_single() != value: self._trigger_options_changed() return setattr(self, '_single', value) single = property(get_single, set_single) """ - :class:`True` - Playback is stopped after current song, unless in :attr:`repeat` - mode. - :class:`False` - Playback continues after current song. + .. deprecated:: 0.20 + Use :meth:`get_single` and :meth:`set_single` instead. """ # Methods From 5827e45c340018111ba52e47ad748a343bd44321 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 02:16:00 +0100 Subject: [PATCH 254/495] core: Use getters/setters internally in core This fixes all the easy-to-track warnings. --- mopidy/core/actor.py | 4 +-- mopidy/core/playback.py | 72 +++++++++++++++++++++------------------- mopidy/core/tracklist.py | 36 ++++++++++---------- 3 files changed, 58 insertions(+), 54 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 370c9e0d..4eabd0ad 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -118,7 +118,7 @@ class Core( if not self.audio: return - current_tl_track = self.playback.current_tl_track + current_tl_track = self.playback.get_current_tl_track() if current_tl_track is None: return @@ -133,7 +133,7 @@ class Core( track_kwargs.update( {k: v for k, v in tags_track.__dict__.items() if v}) - self.playback.current_metadata_track = TlTrack(**{ + self.playback._current_metadata_track = TlTrack(**{ 'tlid': current_tl_track.tlid, 'track': Track(**track_kwargs)}) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 72a9207c..62e83abe 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -27,9 +27,10 @@ class PlaybackController(object): def _get_backend(self): # TODO: take in track instead - if self.current_tl_track is None: + track = self.get_current_track() + if track is None: return None - uri = self.current_tl_track.track.uri + uri = track.uri uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_playback.get(uri_scheme, None) @@ -110,7 +111,7 @@ class PlaybackController(object): "PAUSED" -> "PLAYING" [ label="resume" ] "PAUSED" -> "STOPPED" [ label="stop" ] """ - (old_state, self._state) = (self.state, new_state) + (old_state, self._state) = (self.get_state(), new_state) logger.debug('Changing state: %s -> %s', old_state, new_state) self._trigger_playback_state_changed(old_state, new_state) @@ -209,9 +210,9 @@ class PlaybackController(object): track (default), -1 for previous track. **INTERNAL** :type on_error_step: int, -1 or 1 """ - old_state = self.state + old_state = self.get_state() self.stop() - self.current_tl_track = tl_track + self.set_current_tl_track(tl_track) if old_state == PlaybackState.PLAYING: self.play(on_error_step=on_error_step) elif old_state == PlaybackState.PAUSED: @@ -224,17 +225,17 @@ class PlaybackController(object): Used by event handler in :class:`mopidy.core.Core`. """ - if self.state == PlaybackState.STOPPED: + if self.get_state() == PlaybackState.STOPPED: return - original_tl_track = self.current_tl_track + original_tl_track = self.get_current_tl_track() next_tl_track = self.core.tracklist.eot_track(original_tl_track) if next_tl_track: self.change_track(next_tl_track) else: self.stop() - self.current_tl_track = None + self.set_current_tl_track(None) self.core.tracklist.mark_played(original_tl_track) @@ -244,9 +245,10 @@ class PlaybackController(object): Used by :class:`mopidy.core.TracklistController`. """ - if self.current_tl_track not in self.core.tracklist.tl_tracks: + tracklist = self.core.tracklist.get_tl_tracks() + if self.get_current_tl_track() not in tracklist: self.stop() - self.current_tl_track = None + self.set_current_tl_track(None) def next(self): """ @@ -255,7 +257,7 @@ class PlaybackController(object): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - original_tl_track = self.current_tl_track + original_tl_track = self.get_current_tl_track() next_tl_track = self.core.tracklist.next_track(original_tl_track) if next_tl_track: @@ -265,7 +267,7 @@ class PlaybackController(object): self.change_track(next_tl_track) else: self.stop() - self.current_tl_track = None + self.set_current_tl_track(None) self.core.tracklist.mark_played(original_tl_track) @@ -276,7 +278,7 @@ class PlaybackController(object): # TODO: switch to: # backend.track(pause) # wait for state change? - self.state = PlaybackState.PAUSED + self.set_state(PlaybackState.PAUSED) self._trigger_track_playback_paused() def play(self, tl_track=None, on_error_step=1): @@ -294,11 +296,11 @@ class PlaybackController(object): assert on_error_step in (-1, 1) if tl_track is None: - if self.state == PlaybackState.PAUSED: + if self.get_state() == PlaybackState.PAUSED: return self.resume() - if self.current_tl_track is not None: - tl_track = self.current_tl_track + if self.get_current_tl_track() is not None: + tl_track = self.get_current_tl_track() else: if on_error_step == 1: tl_track = self.core.tracklist.next_track(tl_track) @@ -308,17 +310,17 @@ class PlaybackController(object): if tl_track is None: return - assert tl_track in self.core.tracklist.tl_tracks + assert tl_track in self.core.tracklist.get_tl_tracks() # TODO: switch to: # backend.play(track) # wait for state change? - if self.state == PlaybackState.PLAYING: + if self.get_state() == PlaybackState.PLAYING: self.stop() - self.current_tl_track = tl_track - self.state = PlaybackState.PLAYING + self.set_current_tl_track(tl_track) + self.set_state(PlaybackState.PLAYING) backend = self._get_backend() success = backend and backend.playback.play(tl_track.track).get() @@ -342,7 +344,7 @@ class PlaybackController(object): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - tl_track = self.current_tl_track + tl_track = self.get_current_tl_track() # TODO: switch to: # self.play(....) # wait for state change? @@ -351,11 +353,11 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" - if self.state != PlaybackState.PAUSED: + if self.get_state() != PlaybackState.PAUSED: return backend = self._get_backend() if backend and backend.playback.resume().get(): - self.state = PlaybackState.PLAYING + self.set_state(PlaybackState.PLAYING) # TODO: trigger via gst messages self._trigger_track_playback_resumed() # TODO: switch to: @@ -373,9 +375,9 @@ class PlaybackController(object): if not self.core.tracklist.tracks: return False - if self.state == PlaybackState.STOPPED: + if self.get_state() == PlaybackState.STOPPED: self.play() - elif self.state == PlaybackState.PAUSED: + elif self.get_state() == PlaybackState.PAUSED: self.resume() if time_position < 0: @@ -395,11 +397,11 @@ class PlaybackController(object): def stop(self): """Stop playing.""" - if self.state != PlaybackState.STOPPED: + if self.get_state() != PlaybackState.STOPPED: backend = self._get_backend() - time_position_before_stop = self.time_position + time_position_before_stop = self.get_time_position() if not backend or backend.playback.stop().get(): - self.state = PlaybackState.STOPPED + self.set_state(PlaybackState.STOPPED) self._trigger_track_playback_ended(time_position_before_stop) def _trigger_track_playback_paused(self): @@ -408,7 +410,8 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_paused', - tl_track=self.current_tl_track, time_position=self.time_position) + tl_track=self.get_current_tl_track(), + time_position=self.get_time_position()) def _trigger_track_playback_resumed(self): logger.debug('Triggering track playback resumed event') @@ -416,23 +419,24 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_resumed', - tl_track=self.current_tl_track, time_position=self.time_position) + tl_track=self.get_current_tl_track(), + time_position=self.get_time_position()) def _trigger_track_playback_started(self): logger.debug('Triggering track playback started event') - if self.current_tl_track is None: + if self.get_current_tl_track() is None: return listener.CoreListener.send( 'track_playback_started', - tl_track=self.current_tl_track) + tl_track=self.get_current_tl_track()) def _trigger_track_playback_ended(self, time_position_before_stop): logger.debug('Triggering track playback ended event') - if self.current_tl_track is None: + if self.get_current_tl_track() is None: return listener.CoreListener.send( 'track_playback_ended', - tl_track=self.current_tl_track, + tl_track=self.get_current_tl_track(), time_position=time_position_before_stop) def _trigger_playback_state_changed(self, old_state, new_state): diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 5f7ddba1..a9d05570 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -125,7 +125,7 @@ class TracklistController(object): if self.get_random() != value: self._trigger_options_changed() if value: - self._shuffled = self.tl_tracks + self._shuffled = self.get_tl_tracks() random.shuffle(self._shuffled) return setattr(self, '_random', value) @@ -223,9 +223,9 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ - if self.single and self.repeat: + if self.get_single() and self.get_repeat(): return tl_track - elif self.single: + elif self.get_single(): return None # Current difference between next and EOT handling is that EOT needs to @@ -248,30 +248,30 @@ class TracklistController(object): :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ - if not self.tl_tracks: + if not self.get_tl_tracks(): return None - if self.random and not self._shuffled: - if self.repeat or not tl_track: + if self.get_random() and not self._shuffled: + if self.get_repeat() or not tl_track: logger.debug('Shuffling tracks') - self._shuffled = self.tl_tracks + self._shuffled = self.get_tl_tracks() random.shuffle(self._shuffled) - if self.random: + if self.get_random(): try: return self._shuffled[0] except IndexError: return None if tl_track is None: - return self.tl_tracks[0] + return self.get_tl_tracks()[0] next_index = self.index(tl_track) + 1 - if self.repeat: - next_index %= len(self.tl_tracks) + if self.get_repeat(): + next_index %= len(self.get_tl_tracks()) try: - return self.tl_tracks[next_index] + return self.get_tl_tracks()[next_index] except IndexError: return None @@ -288,7 +288,7 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ - if self.repeat or self.consume or self.random: + if self.get_repeat() or self.get_consume() or self.get_random(): return tl_track position = self.index(tl_track) @@ -296,7 +296,7 @@ class TracklistController(object): if position in (None, 0): return None - return self.tl_tracks[position - 1] + return self.get_tl_tracks()[position - 1] def add(self, tracks=None, at_position=None, uri=None): """ @@ -500,13 +500,13 @@ class TracklistController(object): def mark_playing(self, tl_track): """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" - if self.random and tl_track in self._shuffled: + if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) def mark_unplayable(self, tl_track): """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" logger.warning('Track is not playable: %s', tl_track.track.uri) - if self.random and tl_track in self._shuffled: + if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) def mark_played(self, tl_track): @@ -517,8 +517,8 @@ class TracklistController(object): return False def _trigger_tracklist_changed(self): - if self.random: - self._shuffled = self.tl_tracks + if self.get_random(): + self._shuffled = self.get_tl_tracks() random.shuffle(self._shuffled) else: self._shuffled = [] From 8f8fa4d414e80d9039cef157eb61311b22e13666 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 01:57:13 +0100 Subject: [PATCH 255/495] core: Emit deprecation warnings The warnings appear as warning level log messages if running Python on the mopidy/ directory like this: python -W all mopidy -v or: python -W all mopidy -o loglevels/py.warnings=warning We don't suppress warnings when Pykka is the caller in general, but just when Pykka is looking at all properties to create its actor proxies. When a deprecated property is used from another Pykka actor, only the stack for the current actor thread is available for inspection, so the warning cannot show where the actual call site in the other actor thread is. Though, if the warnings are made exceptions with: python -W error mopidy then the stack traces will include the frames from all involved actor threads, showing where the original call site is. --- mopidy/core/actor.py | 5 +++-- mopidy/core/playback.py | 16 +++++++++------- mopidy/core/playlists.py | 5 +++-- mopidy/core/tracklist.py | 17 +++++++++-------- mopidy/utils/deprecation.py | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 19 deletions(-) create mode 100644 mopidy/utils/deprecation.py diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 4eabd0ad..cc1cdd9d 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -16,6 +16,7 @@ from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController from mopidy.models import TlTrack, Track from mopidy.utils import versioning +from mopidy.utils.deprecation import deprecated_property class Core( @@ -68,7 +69,7 @@ class Core( uri_schemes = itertools.chain(*results) return sorted(uri_schemes) - uri_schemes = property(get_uri_schemes) + uri_schemes = deprecated_property(get_uri_schemes) """ .. deprecated:: 0.20 Use :meth:`get_uri_schemes` instead. @@ -78,7 +79,7 @@ class Core( """Get version of the Mopidy core API""" return versioning.get_version() - version = property(get_version) + version = deprecated_property(get_version) """ .. deprecated:: 0.20 Use :meth:`get_version` instead. diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 62e83abe..fc273965 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -5,6 +5,7 @@ import urlparse from mopidy.audio import PlaybackState from mopidy.core import listener +from mopidy.utils.deprecation import deprecated_property logger = logging.getLogger(__name__) @@ -50,7 +51,8 @@ class PlaybackController(object): """ self._current_tl_track = value - current_tl_track = property(get_current_tl_track, set_current_tl_track) + current_tl_track = deprecated_property( + get_current_tl_track, set_current_tl_track) """ .. deprecated:: 0.20 Use :meth:`get_current_tl_track` instead. @@ -68,7 +70,7 @@ class PlaybackController(object): if tl_track is not None: return tl_track.track - current_track = property(get_current_track) + current_track = deprecated_property(get_current_track) """ .. deprecated:: 0.20 Use :meth:`get_current_track` instead. @@ -83,7 +85,7 @@ class PlaybackController(object): """ return self._current_metadata_track - current_metadata_track = property(get_current_metadata_track) + current_metadata_track = deprecated_property(get_current_metadata_track) """ .. deprecated:: 0.20 Use :meth:`get_current_metadata_track` instead. @@ -116,7 +118,7 @@ class PlaybackController(object): self._trigger_playback_state_changed(old_state, new_state) - state = property(get_state, set_state) + state = deprecated_property(get_state, set_state) """ .. deprecated:: 0.20 Use :meth:`get_state` and :meth:`set_state` instead. @@ -130,7 +132,7 @@ class PlaybackController(object): else: return 0 - time_position = property(get_time_position) + time_position = deprecated_property(get_time_position) """ .. deprecated:: 0.20 Use :meth:`get_time_position` instead. @@ -162,7 +164,7 @@ class PlaybackController(object): # For testing self._volume = volume - volume = property(get_volume, set_volume) + volume = deprecated_property(get_volume, set_volume) """ .. deprecated:: 0.20 Use :meth:`get_volume` and :meth:`set_volume` instead. @@ -191,7 +193,7 @@ class PlaybackController(object): # For testing self._mute = value - mute = property(get_mute, set_mute) + mute = deprecated_property(get_mute, set_mute) """ .. deprecated:: 0.20 Use :meth:`get_mute` and :meth:`set_mute` instead. diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 16b29b85..3d368c29 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -5,7 +5,8 @@ import urlparse import pykka -from . import listener +from mopidy.core import listener +from mopidy.utils.deprecation import deprecated_property class PlaylistsController(object): @@ -29,7 +30,7 @@ class PlaylistsController(object): playlists = [p.copy(tracks=[]) for p in playlists] return playlists - playlists = property(get_playlists) + playlists = deprecated_property(get_playlists) """ .. deprecated:: 0.20 Use :meth:`get_playlists` instead. diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index a9d05570..c54e6784 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -7,6 +7,7 @@ import random from mopidy import compat from mopidy.core import listener from mopidy.models import TlTrack +from mopidy.utils.deprecation import deprecated_property logger = logging.getLogger(__name__) @@ -29,7 +30,7 @@ class TracklistController(object): """Get tracklist as list of :class:`mopidy.models.TlTrack`.""" return self._tl_tracks[:] - tl_tracks = property(get_tl_tracks) + tl_tracks = deprecated_property(get_tl_tracks) """ .. deprecated:: 0.20 Use :meth:`get_tl_tracks` instead. @@ -39,7 +40,7 @@ class TracklistController(object): """Get tracklist as list of :class:`mopidy.models.Track`.""" return [tl_track.track for tl_track in self._tl_tracks] - tracks = property(get_tracks) + tracks = deprecated_property(get_tracks) """ .. deprecated:: 0.20 Use :meth:`get_tracks` instead. @@ -49,7 +50,7 @@ class TracklistController(object): """Get length of the tracklist.""" return len(self._tl_tracks) - length = property(get_length) + length = deprecated_property(get_length) """ .. deprecated:: 0.20 Use :meth:`get_length` instead. @@ -69,7 +70,7 @@ class TracklistController(object): self.core.playback.on_tracklist_change() self._trigger_tracklist_changed() - version = property(get_version) + version = deprecated_property(get_version) """ .. deprecated:: 0.20 Use :meth:`get_version` instead. @@ -97,7 +98,7 @@ class TracklistController(object): self._trigger_options_changed() return setattr(self, '_consume', value) - consume = property(get_consume, set_consume) + consume = deprecated_property(get_consume, set_consume) """ .. deprecated:: 0.20 Use :meth:`get_consume` and :meth:`set_consume` instead. @@ -129,7 +130,7 @@ class TracklistController(object): random.shuffle(self._shuffled) return setattr(self, '_random', value) - random = property(get_random, set_random) + random = deprecated_property(get_random, set_random) """ .. deprecated:: 0.20 Use :meth:`get_random` and :meth:`set_random` instead. @@ -162,7 +163,7 @@ class TracklistController(object): self._trigger_options_changed() return setattr(self, '_repeat', value) - repeat = property(get_repeat, set_repeat) + repeat = deprecated_property(get_repeat, set_repeat) """ .. deprecated:: 0.20 Use :meth:`get_repeat` and :meth:`set_repeat` instead. @@ -192,7 +193,7 @@ class TracklistController(object): self._trigger_options_changed() return setattr(self, '_single', value) - single = property(get_single, set_single) + single = deprecated_property(get_single, set_single) """ .. deprecated:: 0.20 Use :meth:`get_single` and :meth:`set_single` instead. diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py new file mode 100644 index 00000000..1b744702 --- /dev/null +++ b/mopidy/utils/deprecation.py @@ -0,0 +1,34 @@ +from __future__ import unicode_literals + +import inspect +import warnings + + +def _is_pykka_proxy_creation(): + stack = inspect.stack() + try: + calling_frame = stack[3] + except IndexError: + return False + else: + filename = calling_frame[1] + funcname = calling_frame[3] + return 'pykka' in filename and funcname == '_get_attributes' + + +def deprecated_property( + getter=None, setter=None, message='Property is deprecated'): + + def deprecated_getter(*args): + if not _is_pykka_proxy_creation(): + warnings.warn(message, DeprecationWarning, stacklevel=2) + return getter(*args) + + def deprecated_setter(*args): + if not _is_pykka_proxy_creation(): + warnings.warn(message, DeprecationWarning, stacklevel=2) + return setter(*args) + + new_getter = getter and deprecated_getter + new_setter = setter and deprecated_setter + return property(new_getter, new_setter) From 42115c56f783bc284582c7f7af4940816a167024 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 11 Feb 2015 00:48:59 +0100 Subject: [PATCH 256/495] core: Add mixer controller (fixes: #962) Deprecate volume and mute methods on playback controller. --- docs/api/core.rst | 8 ++++ docs/changelog.rst | 4 ++ mopidy/core/__init__.py | 1 + mopidy/core/actor.py | 16 +++---- mopidy/core/mixer.py | 64 ++++++++++++++++++++++++++++ mopidy/core/playback.py | 84 +++++++++++++++++-------------------- mopidy/http/handlers.py | 2 + tests/core/test_mixer.py | 28 +++++++++++++ tests/core/test_playback.py | 18 -------- 9 files changed, 153 insertions(+), 72 deletions(-) create mode 100644 mopidy/core/mixer.py create mode 100644 tests/core/test_mixer.py diff --git a/docs/api/core.rst b/docs/api/core.rst index 21ff79f5..27ab2f57 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -64,6 +64,14 @@ Manages the music library, e.g. searching for tracks to be added to a playlist. :members: +Mixer controller +================ + +Manages volume and muting. + +.. autoclass:: mopidy.core.MixerController + :members: + Core listener ============= diff --git a/docs/changelog.rst b/docs/changelog.rst index 1a9eb33d..4f72ca30 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -17,6 +17,10 @@ v0.20.0 (UNRELEASED) - Added :class:`mopidy.core.HistoryController` which keeps track of what tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`) +- Added :class:`mopidy.core.MixerController` which keeps track of volume and + mute. The old methods on :class:`mopidy.core.PlaybackController` for volume + and mute management has been deprecated. (Fixes: :issue:`962`) + - Removed ``clear_current_track`` keyword argument to :meth:`mopidy.core.Playback.stop`. It was a leaky internal abstraction, which was never intended to be used externally. diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 7fa7e299..720f9c38 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -5,6 +5,7 @@ from .actor import Core from .history import HistoryController from .library import LibraryController from .listener import CoreListener +from .mixer import MixerController from .playback import PlaybackController, PlaybackState from .playlists import PlaylistsController from .tracklist import TracklistController diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index cc1cdd9d..2f31c681 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -11,6 +11,7 @@ from mopidy.audio.utils import convert_tags_to_track from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener +from mopidy.core.mixer import MixerController from mopidy.core.playback import PlaybackController from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController @@ -31,6 +32,10 @@ class Core( """The playback history controller. An instance of :class:`mopidy.core.HistoryController`.""" + mixer = None + """The mixer controller. An instance of + :class:`mopidy.core.MixerController`.""" + playback = None """The playback controller. An instance of :class:`mopidy.core.PlaybackController`.""" @@ -49,15 +54,10 @@ class Core( self.backends = Backends(backends) self.library = LibraryController(backends=self.backends, core=self) - self.history = HistoryController() - - self.playback = PlaybackController( - mixer=mixer, backends=self.backends, core=self) - - self.playlists = PlaylistsController( - backends=self.backends, core=self) - + self.mixer = MixerController(mixer=mixer) + self.playback = PlaybackController(backends=self.backends, core=self) + self.playlists = PlaylistsController(backends=self.backends, core=self) self.tracklist = TracklistController(core=self) self.audio = audio diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py new file mode 100644 index 00000000..e6856f17 --- /dev/null +++ b/mopidy/core/mixer.py @@ -0,0 +1,64 @@ +from __future__ import absolute_import, unicode_literals + +import logging + + +logger = logging.getLogger(__name__) + + +class MixerController(object): + pykka_traversable = True + + def __init__(self, mixer): + self._mixer = mixer + self._volume = None + self._mute = False + + def get_volume(self): + """Get the volume. + + Integer in range [0..100] or :class:`None` if unknown. + + The volume scale is linear. + """ + if self._mixer: + return self._mixer.get_volume().get() + else: + # For testing + return self._volume + + def set_volume(self, volume): + """Set the volume. + + The volume is defined as an integer in range [0..100]. + + The volume scale is linear. + """ + if self._mixer: + self._mixer.set_volume(volume) + else: + # For testing + self._volume = volume + + def get_mute(self): + """Get mute state. + + :class:`True` if muted, :class:`False` otherwise. + """ + if self._mixer: + return self._mixer.get_mute().get() + else: + # For testing + return self._mute + + def set_mute(self, mute): + """Set mute state. + + :class:`True` to mute, :class:`False` to unmute. + """ + mute = bool(mute) + if self._mixer: + self._mixer.set_mute(mute) + else: + # For testing + self._mute = mute diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index fc273965..d4cdce0d 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import logging import urlparse +import warnings from mopidy.audio import PlaybackState from mopidy.core import listener @@ -11,20 +12,16 @@ from mopidy.utils.deprecation import deprecated_property logger = logging.getLogger(__name__) -# TODO: split mixing out from playback? class PlaybackController(object): pykka_traversable = True - def __init__(self, mixer, backends, core): - self.mixer = mixer + def __init__(self, backends, core): self.backends = backends self.core = core self._current_tl_track = None self._current_metadata_track = None self._state = PlaybackState.STOPPED - self._volume = None - self._mute = False def _get_backend(self): # TODO: take in track instead @@ -139,64 +136,59 @@ class PlaybackController(object): """ def get_volume(self): - """Get the volume. - - Integer in range [0..100] or :class:`None` if unknown. - - The volume scale is linear. """ - if self.mixer: - return self.mixer.get_volume().get() - else: - # For testing - return self._volume + ... deprecated:: 0.20 + Use :meth:`core.mixer.get_volume() + ` instead. + """ + warnings.warn( + 'playback.get_volume() is deprecated', DeprecationWarning) + return self.core.mixer.get_volume() def set_volume(self, volume): - """Set the volume. - - The volume is defined as an integer in range [0..100]. - - The volume scale is linear. """ - if self.mixer: - self.mixer.set_volume(volume) - else: - # For testing - self._volume = volume + ... deprecated:: 0.20 + Use :meth:`core.mixer.set_volume() + ` instead. + """ + warnings.warn( + 'playback.set_volume() is deprecated', DeprecationWarning) + return self.core.mixer.set_volume(volume) volume = deprecated_property(get_volume, set_volume) """ .. deprecated:: 0.20 - Use :meth:`get_volume` and :meth:`set_volume` instead. + Use :meth:`core.mixer.get_volume() + ` and + :meth:`core.mixer.set_volume() + ` instead. """ def get_mute(self): - """Get mute state. - - :class:`True` if muted, :class:`False` otherwise. """ - if self.mixer: - return self.mixer.get_mute().get() - else: - # For testing - return self._mute - - def set_mute(self, value): - """Set mute state. - - :class:`True` to mute, :class:`False` to unmute. + ... deprecated:: 0.20 + Use :meth:`core.mixer.get_mute() + ` instead. """ - value = bool(value) - if self.mixer: - self.mixer.set_mute(value) - else: - # For testing - self._mute = value + warnings.warn('playback.get_mute() is deprecated', DeprecationWarning) + return self.core.mixer.get_mute() + + def set_mute(self, mute): + """ + ... deprecated:: 0.20 + Use :meth:`core.mixer.set_mute() + ` instead. + """ + warnings.warn('playback.set_mute() is deprecated', DeprecationWarning) + return self.core.mixer.set_mute(mute) mute = deprecated_property(get_mute, set_mute) """ .. deprecated:: 0.20 - Use :meth:`get_mute` and :meth:`set_mute` instead. + Use :meth:`core.mixer.get_mute() + ` and + :meth:`core.mixer.set_mute() + ` instead. """ # Methods diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 721e419c..52bd8217 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -43,6 +43,7 @@ def make_jsonrpc_wrapper(core_actor): 'core.get_version': core.Core.get_version, 'core.history': core.HistoryController, 'core.library': core.LibraryController, + 'core.mixer': core.MixerController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, 'core.tracklist': core.TracklistController, @@ -54,6 +55,7 @@ def make_jsonrpc_wrapper(core_actor): 'core.get_version': core_actor.get_version, 'core.history': core_actor.history, 'core.library': core_actor.library, + 'core.mixer': core_actor.mixer, 'core.playback': core_actor.playback, 'core.playlists': core_actor.playlists, 'core.tracklist': core_actor.tracklist, diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py new file mode 100644 index 00000000..e3fa6be6 --- /dev/null +++ b/tests/core/test_mixer.py @@ -0,0 +1,28 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +from mopidy import core + + +class CoreMixerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_volume(self): + self.assertEqual(self.core.mixer.get_volume(), None) + + self.core.mixer.set_volume(30) + + self.assertEqual(self.core.mixer.get_volume(), 30) + + self.core.mixer.set_volume(70) + + self.assertEqual(self.core.mixer.get_volume(), 70) + + def test_mute(self): + self.assertEqual(self.core.mixer.get_mute(), False) + + self.core.mixer.set_mute(True) + + self.assertEqual(self.core.mixer.get_mute(), True) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index b9d19966..40741e23 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -426,21 +426,3 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.get_time_position.called) # TODO Test on_tracklist_change - - def test_volume(self): - self.assertEqual(self.core.playback.volume, None) - - self.core.playback.volume = 30 - - self.assertEqual(self.core.playback.volume, 30) - - self.core.playback.volume = 70 - - self.assertEqual(self.core.playback.volume, 70) - - def test_mute(self): - self.assertEqual(self.core.playback.mute, False) - - self.core.playback.mute = True - - self.assertEqual(self.core.playback.mute, True) From 91bcdddf56584058b513d96b033b73e0c636f3bd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 11 Feb 2015 01:10:16 +0100 Subject: [PATCH 257/495] tests: Use core.mixer for volume/mute --- tests/utils/test_jsonrpc.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index d236469e..6e309c7c 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -49,7 +49,7 @@ class JsonRpcTestBase(unittest.TestCase): 'hello': lambda: 'Hello, world!', 'calc': Calculator(), 'core': self.core, - 'core.playback': self.core.playback, + 'core.mixer': self.core.mixer, 'core.tracklist': self.core.tracklist, 'get_uri_schemes': self.core.get_uri_schemes, }, @@ -188,7 +188,7 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_on_actor_member(self): request = { 'jsonrpc': '2.0', - 'method': 'core.playback.get_volume', + 'method': 'core.mixer.get_volume', 'id': 1, } response = self.jrw.handle_data(request) @@ -215,26 +215,26 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_with_positional_params(self): request = { 'jsonrpc': '2.0', - 'method': 'core.playback.set_volume', + 'method': 'core.mixer.set_volume', 'params': [37], 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['result'], None) - self.assertEqual(self.core.playback.get_volume().get(), 37) + self.assertEqual(self.core.mixer.get_volume().get(), 37) def test_call_methods_with_named_params(self): request = { 'jsonrpc': '2.0', - 'method': 'core.playback.set_volume', + 'method': 'core.mixer.set_volume', 'params': {'volume': 37}, 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['result'], None) - self.assertEqual(self.core.playback.get_volume().get(), 37) + self.assertEqual(self.core.mixer.get_volume().get(), 37) class JsonRpcSingleNotificationTest(JsonRpcTestBase): @@ -248,17 +248,17 @@ class JsonRpcSingleNotificationTest(JsonRpcTestBase): self.assertIsNone(response) def test_notification_makes_an_observable_change(self): - self.assertEqual(self.core.playback.get_volume().get(), None) + self.assertEqual(self.core.mixer.get_volume().get(), None) request = { 'jsonrpc': '2.0', - 'method': 'core.playback.set_volume', + 'method': 'core.mixer.set_volume', 'params': [37], } response = self.jrw.handle_data(request) self.assertIsNone(response) - self.assertEqual(self.core.playback.get_volume().get(), 37) + self.assertEqual(self.core.mixer.get_volume().get(), 37) def test_notification_unknown_method_returns_nothing(self): request = { @@ -526,7 +526,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): def test_batch_of_both_successfull_and_failing_requests(self): request = [ # Call with positional params - {'jsonrpc': '2.0', 'method': 'core.playback.set_volume', + {'jsonrpc': '2.0', 'method': 'core.mixer.set_volume', 'params': [47], 'id': '1'}, # Notification {'jsonrpc': '2.0', 'method': 'core.tracklist.set_consume', From e1fa76a48e17ef951c64b1bd89119f020efe217a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 10:31:03 +0100 Subject: [PATCH 258/495] mpd: Use core.mixer for volume/mute --- mopidy/mpd/protocol/audio_output.py | 6 +++--- mopidy/mpd/protocol/playback.py | 2 +- mopidy/mpd/protocol/status.py | 4 ++-- tests/mpd/protocol/test_audio_output.py | 12 ++++++------ tests/mpd/protocol/test_playback.py | 14 +++++++------- tests/mpd/test_status.py | 2 +- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 4a5310f5..0152f852 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -13,7 +13,7 @@ def disableoutput(context, outputid): Turns an output off. """ if outputid == 0: - context.core.playback.set_mute(False) + context.core.mixer.set_mute(False) else: raise exceptions.MpdNoExistError('No such audio output') @@ -28,7 +28,7 @@ def enableoutput(context, outputid): Turns an output on. """ if outputid == 0: - context.core.playback.set_mute(True) + context.core.mixer.set_mute(True) else: raise exceptions.MpdNoExistError('No such audio output') @@ -55,7 +55,7 @@ def outputs(context): Shows information about all outputs. """ - muted = 1 if context.core.playback.get_mute().get() else 0 + muted = 1 if context.core.mixer.get_mute().get() else 0 return [ ('outputid', 0), ('outputname', 'Mute'), diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 07102492..f7856a03 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -397,7 +397,7 @@ def setvol(context, volume): - issues ``setvol 50`` without quotes around the argument. """ # NOTE: we use INT as clients can pass in +N etc. - context.core.playback.volume = min(max(0, volume), 100) + context.core.mixer.set_volume(min(max(0, volume), 100)) @protocol.commands.add('single', state=protocol.BOOL) diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index eabb9317..d33e0afa 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -175,7 +175,7 @@ def status(context): futures = { 'tracklist.length': context.core.tracklist.length, 'tracklist.version': context.core.tracklist.version, - 'playback.volume': context.core.playback.volume, + 'mixer.volume': context.core.mixer.get_volume(), 'tracklist.consume': context.core.tracklist.consume, 'tracklist.random': context.core.tracklist.random, 'tracklist.repeat': context.core.tracklist.repeat, @@ -289,7 +289,7 @@ def _status_time_total(futures): def _status_volume(futures): - volume = futures['playback.volume'].get() + volume = futures['mixer.volume'].get() if volume is not None: return volume else: diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index 137ac029..a86f24f0 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -5,12 +5,12 @@ from tests.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): - self.core.playback.mute = False + self.core.mixer.set_mute(False) self.send_request('enableoutput "0"') self.assertInResponse('OK') - self.assertEqual(self.core.playback.mute.get(), True) + self.assertEqual(self.core.mixer.get_mute().get(), True) def test_enableoutput_unknown_outputid(self): self.send_request('enableoutput "7"') @@ -18,12 +18,12 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.assertInResponse('ACK [50@0] {enableoutput} No such audio output') def test_disableoutput(self): - self.core.playback.mute = True + self.core.mixer.set_mute(True) self.send_request('disableoutput "0"') self.assertInResponse('OK') - self.assertEqual(self.core.playback.mute.get(), False) + self.assertEqual(self.core.mixer.get_mute().get(), False) def test_disableoutput_unknown_outputid(self): self.send_request('disableoutput "7"') @@ -32,7 +32,7 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): 'ACK [50@0] {disableoutput} No such audio output') def test_outputs_when_unmuted(self): - self.core.playback.mute = False + self.core.mixer.set_mute(False) self.send_request('outputs') @@ -42,7 +42,7 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_outputs_when_muted(self): - self.core.playback.mute = True + self.core.mixer.set_mute(True) self.send_request('outputs') diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 1cd62bba..ea9c59ce 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -80,37 +80,37 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_setvol_below_min(self): self.send_request('setvol "-10"') - self.assertEqual(0, self.core.playback.volume.get()) + self.assertEqual(0, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_min(self): self.send_request('setvol "0"') - self.assertEqual(0, self.core.playback.volume.get()) + self.assertEqual(0, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_middle(self): self.send_request('setvol "50"') - self.assertEqual(50, self.core.playback.volume.get()) + self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_max(self): self.send_request('setvol "100"') - self.assertEqual(100, self.core.playback.volume.get()) + self.assertEqual(100, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_above_max(self): self.send_request('setvol "110"') - self.assertEqual(100, self.core.playback.volume.get()) + self.assertEqual(100, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_plus_is_ignored(self): self.send_request('setvol "+10"') - self.assertEqual(10, self.core.playback.volume.get()) + self.assertEqual(10, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_without_quotes(self): self.send_request('setvol 50') - self.assertEqual(50, self.core.playback.volume.get()) + self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_single_off(self): diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 1015615c..75c10c94 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -52,7 +52,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): - self.core.playback.volume = 17 + self.core.mixer.set_volume(17) result = dict(status.status(self.context)) self.assertIn('volume', result) self.assertEqual(int(result['volume']), 17) From df67d708db5c6ff39b7b3d1cc02e7d5deb259c18 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 17:52:52 +0100 Subject: [PATCH 259/495] config: Add support for 'all' loglevel Equal to logging.NOTSET or 0 in the logging module. --- docs/changelog.rst | 8 +++++++- mopidy/config/types.py | 5 +++-- tests/config/test_types.py | 13 ++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1a9eb33d..5581bfec 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,12 @@ v0.20.0 (UNRELEASED) - Make the ``mopidy`` command print a friendly error message if the :mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`) +**Configuration** + +- Add support for the log level value ``all`` to the loglevels configurations. + This can be used to show absolutely all log records, including those at + custom levels below ``DEBUG``. + **Local backend** - Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: @@ -50,7 +56,7 @@ v0.20.0 (UNRELEASED) - Enable browsing of artist references, in addition to albums and playlists. (PR: :issue:`884`) - + - Share a single mapping between names and URIs across all MPD sessions. (Fixes: :issue:`934`, PR: :issue:`968`) diff --git a/mopidy/config/types.py b/mopidy/config/types.py index bed03fa2..785ec55a 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -200,8 +200,8 @@ class List(ConfigValue): class LogLevel(ConfigValue): """Log level value. - Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug`` - with any casing. + Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``, + or ``all``, with any casing. """ levels = { b'critical': logging.CRITICAL, @@ -209,6 +209,7 @@ class LogLevel(ConfigValue): b'warning': logging.WARNING, b'info': logging.INFO, b'debug': logging.DEBUG, + b'all': logging.NOTSET, } def deserialize(self, value): diff --git a/tests/config/test_types.py b/tests/config/test_types.py index 939d028b..365fa9e0 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -281,11 +281,14 @@ class ListTest(unittest.TestCase): class LogLevelTest(unittest.TestCase): - levels = {'critical': logging.CRITICAL, - 'error': logging.ERROR, - 'warning': logging.WARNING, - 'info': logging.INFO, - 'debug': logging.DEBUG} + levels = { + 'critical': logging.CRITICAL, + 'error': logging.ERROR, + 'warning': logging.WARNING, + 'info': logging.INFO, + 'debug': logging.DEBUG, + 'all': logging.NOTSET, + } def test_deserialize_conversion_success(self): value = types.LogLevel() From 79dbc652e0084b358c50bcbbedda3da13a75de56 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 18:03:13 +0100 Subject: [PATCH 260/495] log: Define TRACE log level with name and color --- docs/changelog.rst | 5 +++++ mopidy/utils/log.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5581bfec..413e2ca9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,11 @@ v0.20.0 (UNRELEASED) This can be used to show absolutely all log records, including those at custom levels below ``DEBUG``. +**Logging** + +- Add custom log level ``TRACE`` (numerical level 5), which can be used by + Mopidy and extensions to log at an even more detailed level than ``DEBUG``. + **Local backend** - Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 396c05b9..79ec723c 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -14,6 +14,9 @@ LOG_LEVELS = { 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), } +# Custom log level which has even lower priority than DEBUG +TRACE_LOG_LEVEL = 5 + class DelayedHandler(logging.Handler): def __init__(self): @@ -42,6 +45,8 @@ def bootstrap_delayed_logging(): def setup_logging(config, verbosity_level, save_debug_log): + logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE') + logging.captureWarnings(True) if config['logging']['config_file']: @@ -137,6 +142,7 @@ class ColorizingStreamHandler(logging.StreamHandler): # Map logging levels to (background, foreground, bold/intense) level_map = { + TRACE_LOG_LEVEL: (None, 'blue', False), logging.DEBUG: (None, 'blue', False), logging.INFO: (None, 'white', False), logging.WARNING: (None, 'yellow', False), From ece54b68d1010b3e0da3b2dd7666a3358e1c6d11 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 19:29:14 +0100 Subject: [PATCH 261/495] log: Support -vvvv to not filter logs at all --- docs/changelog.rst | 4 ++++ mopidy/utils/log.py | 1 + 2 files changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 413e2ca9..89090218 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,10 @@ v0.20.0 (UNRELEASED) - Make the ``mopidy`` command print a friendly error message if the :mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`) +- Add support for repeating the :cmdoption:`-v ` argument four times + to set the log level for all loggers to the lowest possible value, including + log records at levels lover than ``DEBUG`` too. + **Configuration** - Add support for the log level value ``all`` to the loglevels configurations. diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 79ec723c..3c7ee599 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -12,6 +12,7 @@ LOG_LEVELS = { 1: dict(root=logging.WARNING, mopidy=logging.DEBUG), 2: dict(root=logging.INFO, mopidy=logging.DEBUG), 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), + 4: dict(root=logging.NOTSET, mopidy=logging.NOTSET), } # Custom log level which has even lower priority than DEBUG From 12cc2ed35c1935129f828f9a0a5ebc552e8d1b9d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 31 Dec 2014 18:18:01 +0100 Subject: [PATCH 262/495] local: Call library add with tags and duration if asked to --- mopidy/local/__init__.py | 15 +++++++++++++-- mopidy/local/commands.py | 6 ++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 62228e91..2ec8b79e 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -70,6 +70,10 @@ class Library(object): #: Name of the local library implementation, must be overriden. name = None + #: Feature marker to indicate that you want add calls to be called with + #: optional arguments tags and duration. + add_supports_tags_and_duration = False + def __init__(self, config): self._config = config @@ -135,12 +139,19 @@ class Library(object): """ raise NotImplementedError - def add(self, track): + def add(self, track, tags=None, duration=None): """ - Add the given track to library. + Add the given track to library. Optional args will only be added if + `add_supports_tags_and_duration` has been set. :param track: Track to add to the library :type track: :class:`~mopidy.models.Track` + :param tags: All the tags the scanner found for the media. See + :module:`mopidy.audio.utils` for details about the tags. + :type tags: dictionary of tag keys with a list of values. + :param duration: Duration of media in milliseconds or :class:`None` if + unknown + :type duration: :class:`int` or :class:`None` """ raise NotImplementedError diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index d49ab8f8..a9920ec8 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -139,8 +139,10 @@ class ScanCommand(commands.Command): track = utils.convert_tags_to_track(tags).copy( uri=uri, length=duration, last_modified=mtime) track = translator.add_musicbrainz_coverart_to_track(track) - # TODO: add tags to call if library supports it. - library.add(track) + if library.add_supports_tags_and_duration: + library.add(track, tags=tags, duration=duration) + else: + library.add(track) logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: logger.warning('Failed %s: %s', uri, error) From 663cdf929d89055c8e7d56e38d67c446dc418b3a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 21:13:50 +0100 Subject: [PATCH 263/495] docs: Add tags/duration local addition to changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 89090218..640fca97 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -51,6 +51,9 @@ v0.20.0 (UNRELEASED) just like the other ``lookup()`` methods in Mopidy. For now, returning a single track will continue to work. (PR: :issue:`840`) +- Add support for giving local libraries direct access to tags and duration. + (Fixes: :issue:`967`) + **File scanner** - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) From 05b66ba4a360542d5561996fd4427c67f45d5ebc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 22:02:59 +0100 Subject: [PATCH 264/495] models: Add basic image model --- mopidy/models.py | 17 +++++++++++++++++ tests/test_models.py | 35 +++++++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 758b6c6d..daadf7b8 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -212,6 +212,23 @@ class Ref(ImmutableObject): return cls(**kwargs) +class Image(ImmutableObject): + """ + :param string uri: URI of the image + :param int width: Optional width of image or :class:`None` + :param int height: Optional height of image or :class:`None` + """ + + #: The image URI. Read-only. + uri = None + + #: Optional width of the image or :class:`None`. Read-only. + width = None + + #: Optional height of the image or :class:`None`. Read-only. + height = None + + class Artist(ImmutableObject): """ :param uri: artist URI diff --git a/tests/test_models.py b/tests/test_models.py index ed1586da..af8e0f82 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,8 +4,8 @@ import json import unittest from mopidy.models import ( - Album, Artist, ModelJSONEncoder, Playlist, Ref, SearchResult, TlTrack, - Track, model_json_decoder) + Album, Artist, Image, ModelJSONEncoder, Playlist, Ref, SearchResult, + TlTrack, Track, model_json_decoder) class GenericCopyTest(unittest.TestCase): @@ -74,7 +74,7 @@ class RefTest(unittest.TestCase): def test_invalid_kwarg(self): with self.assertRaises(TypeError): - SearchResult(foo='baz') + Ref(foo='baz') def test_repr_without_results(self): self.assertEquals( @@ -123,11 +123,30 @@ class RefTest(unittest.TestCase): self.assertEqual(ref.name, 'bar') self.assertEqual(ref.type, Ref.PLAYLIST) - def test_track_constructor(self): - ref = Ref.track(uri='foo', name='bar') - self.assertEqual(ref.uri, 'foo') - self.assertEqual(ref.name, 'bar') - self.assertEqual(ref.type, Ref.TRACK) + +class ImageTest(unittest.TestCase): + def test_uri(self): + uri = 'an_uri' + image = Image(uri=uri) + self.assertEqual(image.uri, uri) + with self.assertRaises(AttributeError): + image.uri = None + + def test_width(self): + image = Image(width=100) + self.assertEqual(image.width, 100) + with self.assertRaises(AttributeError): + image.width = None + + def test_height(self): + image = Image(height=100) + self.assertEqual(image.height, 100) + with self.assertRaises(AttributeError): + image.height = None + + def test_invalid_kwarg(self): + with self.assertRaises(TypeError): + Image(foo='baz') class ArtistTest(unittest.TestCase): From 34ada2784af5e2957df54f0d9874eef7301cb778 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 8 Feb 2015 23:56:38 +0100 Subject: [PATCH 265/495] flake8: fix PEP8 warnings about lambda Fix the 'lambda to def' warnings --- mopidy/audio/scan.py | 4 +- mopidy/core/actor.py | 4 +- mopidy/local/search.py | 159 ++++++++++++++++++++++--------------- mopidy/models.py | 4 +- setup.cfg | 3 +- tests/mpd/test_commands.py | 63 +++++++++++---- 6 files changed, 154 insertions(+), 83 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 2cf8f493..931a2e3a 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -25,7 +25,9 @@ class Scanner(object): sink = gst.element_factory_make('fakesink') audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') - pad_added = lambda src, pad: pad.link(sink.get_pad('sink')) + + def pad_added(src, pad): + return pad.link(sink.get_pad('sink')) self._uribin = gst.element_factory_make('uridecodebin') self._uribin.set_property('caps', audio_caps) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 75c06f69..5fc7fea1 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -113,7 +113,9 @@ class Backends(list): self.with_playlists = collections.OrderedDict() backends_by_scheme = {} - name = lambda b: b.actor_ref.actor_class.__name__ + + def name(b): + return b.actor_ref.actor_class.__name__ for b in backends: has_library = b.has_library().get() diff --git a/mopidy/local/search.py b/mopidy/local/search.py index bc46c33e..18dad82c 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -21,37 +21,52 @@ def find_exact(tracks, query=None, uris=None): else: q = value.strip() - uri_filter = lambda t: q == t.uri - track_name_filter = lambda t: q == t.name - album_filter = lambda t: q == getattr( - getattr(t, 'album', None), 'name', None) - artist_filter = lambda t: filter( - lambda a: q == a.name, t.artists) - albumartist_filter = lambda t: any([ - q == a.name - for a in getattr(t.album, 'artists', [])]) - composer_filter = lambda t: any([ - q == a.name - for a in getattr(t, 'composers', [])]) - performer_filter = lambda t: any([ - q == a.name - for a in getattr(t, 'performers', [])]) - track_no_filter = lambda t: q == t.track_no - genre_filter = lambda t: t.genre and q == t.genre - date_filter = lambda t: q == t.date - comment_filter = lambda t: q == t.comment - any_filter = lambda t: ( - uri_filter(t) or - track_name_filter(t) or - album_filter(t) or - artist_filter(t) or - albumartist_filter(t) or - composer_filter(t) or - performer_filter(t) or - track_no_filter(t) or - genre_filter(t) or - date_filter(t) or - comment_filter(t)) + def uri_filter(t): + return q == t.uri + + def track_name_filter(t): + return q == t.name + + def album_filter(t): + return q == getattr(getattr(t, 'album', None), 'name', None) + + def artist_filter(t): + return filter(lambda a: q == a.name, t.artists) + + def albumartist_filter(t): + return any([q == a.name for a in getattr(t.album, + 'artists', [])]) + + def composer_filter(t): + return any([q == a.name for a in getattr(t, 'composers', [])]) + + def performer_filter(t): + return any([q == a.name for a in getattr(t, 'performers', [])]) + + def track_no_filter(t): + return q == t.track_no + + def genre_filter(t): + return (t.genre and q == t.genre) + + def date_filter(t): + return q == t.date + + def comment_filter(t): + return q == t.comment + + def any_filter(t): + return (uri_filter(t) or + track_name_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) if field == 'uri': tracks = filter(uri_filter, tracks) @@ -102,38 +117,56 @@ def search(tracks, query=None, uris=None): else: q = value.strip().lower() - uri_filter = lambda t: bool(t.uri and q in t.uri.lower()) - track_name_filter = lambda t: bool(t.name and q in t.name.lower()) - album_filter = lambda t: bool( - t.album and t.album.name and q in t.album.name.lower()) - artist_filter = lambda t: bool(filter( - lambda a: bool(a.name and q in a.name.lower()), t.artists)) - albumartist_filter = lambda t: any([ - a.name and q in a.name.lower() - for a in getattr(t.album, 'artists', [])]) - composer_filter = lambda t: any([ - a.name and q in a.name.lower() - for a in getattr(t, 'composers', [])]) - performer_filter = lambda t: any([ - a.name and q in a.name.lower() - for a in getattr(t, 'performers', [])]) - track_no_filter = lambda t: q == t.track_no - genre_filter = lambda t: bool(t.genre and q in t.genre.lower()) - date_filter = lambda t: bool(t.date and t.date.startswith(q)) - comment_filter = lambda t: bool( - t.comment and q in t.comment.lower()) - any_filter = lambda t: ( - uri_filter(t) or - track_name_filter(t) or - album_filter(t) or - artist_filter(t) or - albumartist_filter(t) or - composer_filter(t) or - performer_filter(t) or - track_no_filter(t) or - genre_filter(t) or - date_filter(t) or - comment_filter(t)) + def uri_filter(t): + return bool(t.uri and q in t.uri.lower()) + + def track_name_filter(t): + return bool(t.name and q in t.name.lower()) + + def album_filter(t): + return bool(t.album and t.album.name + and q in t.album.name.lower()) + + def artist_filter(t): + return bool(filter(lambda a: + bool(a.name and q in a.name.lower()), t.artists)) + + def albumartist_filter(t): + return any([a.name and q in a.name.lower() + for a in getattr(t.album, 'artists', [])]) + + def composer_filter(t): + return any([a.name and q in a.name.lower() + for a in getattr(t, 'composers', [])]) + + def performer_filter(t): + return any([a.name and q in a.name.lower() + for a in getattr(t, 'performers', [])]) + + def track_no_filter(t): + return q == t.track_no + + def genre_filter(t): + return bool(t.genre and q in t.genre.lower()) + + def date_filter(t): + return bool(t.date and t.date.startswith(q)) + + def comment_filter(t): + return bool(t.comment and q in t.comment.lower()) + + def any_filter(t): + return (uri_filter(t) or + track_name_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) if field == 'uri': tracks = filter(uri_filter, tracks) diff --git a/mopidy/models.py b/mopidy/models.py index 758b6c6d..1818edfd 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -367,7 +367,9 @@ class Track(ImmutableObject): last_modified = None def __init__(self, *args, **kwargs): - get = lambda key: frozenset(kwargs.pop(key, None) or []) + def get(key): + return frozenset(kwargs.pop(key, None) or []) + self.__dict__['artists'] = get('artists') self.__dict__['composers'] = get('composers') self.__dict__['performers'] = get('performers') diff --git a/setup.cfg b/setup.cfg index 0d6c1486..834ca945 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,8 +3,7 @@ application-import-names = mopidy,tests exclude = .git,.tox,build,js # Ignored flake8 warnings: # - E402 module level import not at top of file -# - E731 do not assign a lambda expression, use a def -ignore = E402,E731 +ignore = E402 [wheel] universal = 1 diff --git a/tests/mpd/test_commands.py b/tests/mpd/test_commands.py index e0903e9f..a281d10e 100644 --- a/tests/mpd/test_commands.py +++ b/tests/mpd/test_commands.py @@ -64,7 +64,8 @@ class TestCommands(unittest.TestCase): pass def test_register_second_command_to_same_name_fails(self): - func = lambda context: True + def func(context): + pass self.commands.add('foo')(func) with self.assertRaises(Exception): @@ -88,7 +89,10 @@ class TestCommands(unittest.TestCase): def test_function_has_required_and_optional_args_succeeds(self): sentinel = object() - func = lambda context, required, optional=None: sentinel + + def func(context, required, optional=None): + return sentinel + self.commands.add('bar')(func) self.assertEqual(sentinel, self.commands.call(['bar', 'arg'])) self.assertEqual(sentinel, self.commands.call(['bar', 'arg', 'arg'])) @@ -111,12 +115,16 @@ class TestCommands(unittest.TestCase): def test_function_has_required_and_varargs_fails(self): with self.assertRaises(TypeError): - func = lambda context, required, *args: True + def func(context, required, *args): + pass + self.commands.add('test')(func) def test_function_has_optional_and_varargs_fails(self): with self.assertRaises(TypeError): - func = lambda context, optional=None, *args: True + def func(context, optional=None, *args): + pass + self.commands.add('test')(func) def test_function_hash_keywordargs_fails(self): @@ -158,7 +166,9 @@ class TestCommands(unittest.TestCase): self.assertEqual('test', self.commands.call(['foo', 'test'])) def test_call_passes_required_and_optional_argument(self): - func = lambda context, required, optional=None: (required, optional) + def func(context, required, optional=None): + return (required, optional) + self.commands.add('foo')(func) self.assertEqual(('arg', None), self.commands.call(['foo', 'arg'])) self.assertEqual( @@ -182,20 +192,29 @@ class TestCommands(unittest.TestCase): def test_validator_gets_applied_to_required_arg(self): sentinel = object() - func = lambda context, required: required + + def func(context, required): + return required + self.commands.add('test', required=lambda v: sentinel)(func) self.assertEqual(sentinel, self.commands.call(['test', 'foo'])) def test_validator_gets_applied_to_optional_arg(self): sentinel = object() - func = lambda context, optional=None: optional + + def func(context, optional=None): + return optional + self.commands.add('foo', optional=lambda v: sentinel)(func) self.assertEqual(sentinel, self.commands.call(['foo', '123'])) def test_validator_skips_optional_default(self): sentinel = object() - func = lambda context, optional=sentinel: optional + + def func(context, optional=sentinel): + return optional + self.commands.add('foo', optional=lambda v: None)(func) self.assertEqual(sentinel, self.commands.call(['foo'])) @@ -203,28 +222,38 @@ class TestCommands(unittest.TestCase): def test_validator_applied_to_non_existent_arg_fails(self): self.commands.add('foo')(lambda context, arg: arg) with self.assertRaises(TypeError): - func = lambda context, wrong_arg: wrong_arg + def func(context, wrong_arg): + return wrong_arg + self.commands.add('bar', arg=lambda v: v)(func) def test_validator_called_context_fails(self): return # TODO: how to handle this with self.assertRaises(TypeError): - func = lambda context: True + def func(context): + pass + self.commands.add('bar', context=lambda v: v)(func) def test_validator_value_error_is_converted(self): def validdate(value): raise ValueError - func = lambda context, arg: True + def func(context, arg): + pass + self.commands.add('bar', arg=validdate)(func) with self.assertRaises(exceptions.MpdArgError): self.commands.call(['bar', 'test']) def test_auth_required_gets_stored(self): - func1 = lambda context: context - func2 = lambda context: context + def func1(context): + pass + + def func2(context): + pass + self.commands.add('foo')(func1) self.commands.add('bar', auth_required=False)(func2) @@ -232,8 +261,12 @@ class TestCommands(unittest.TestCase): self.assertFalse(self.commands.handlers['bar'].auth_required) def test_list_command_gets_stored(self): - func1 = lambda context: context - func2 = lambda context: context + def func1(context): + pass + + def func2(context): + pass + self.commands.add('foo')(func1) self.commands.add('bar', list_command=False)(func2) From c0b0e3657a24dd9306cc14c61cbe294025b6af91 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 22:38:42 +0100 Subject: [PATCH 266/495] core: Add core.library.get_images --- mopidy/backend/__init__.py | 8 +++++++ mopidy/core/library.py | 16 +++++++++++++ tests/core/test_library.py | 46 +++++++++++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/mopidy/backend/__init__.py b/mopidy/backend/__init__.py index 45268f9f..3dc3a28c 100644 --- a/mopidy/backend/__init__.py +++ b/mopidy/backend/__init__.py @@ -92,6 +92,14 @@ class LibraryProvider(object): """ return [] + def get_images(self, uris): + """ + See :meth:`mopidy.core.LibraryController.get_images`. + + *MAY be implemented by subclass.* + """ + return {} + # TODO: replace with search(query, exact=True, ...) def find_exact(self, query=None, uris=None): """ diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 2ada23d4..bdff2ccd 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -72,6 +72,22 @@ class LibraryController(object): return [] return backend.library.browse(uri).get() + def get_images(self, uris): + """Lookup the images for the given URIs + + :param list uris: list of uris to find images for + :rtype: dict mapping uris to :class:`mopidy.models.Image` instances + """ + futures = [ + backend.library.get_images(backend_uris) + for (backend, backend_uris) + in self._get_backends_to_uris(uris).items() if backend_uris] + + images = {} + for result in pykka.get_all(futures): + images.update(result) + return images + def find_exact(self, query=None, uris=None, **kwargs): """ Search the library for tracks where ``field`` is ``values``. diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 9bd3b244..cece44e1 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -5,7 +5,7 @@ import unittest import mock from mopidy import backend, core -from mopidy.models import Ref, SearchResult, Track +from mopidy.models import Image, Ref, SearchResult, Track class CoreLibraryTest(unittest.TestCase): @@ -14,6 +14,8 @@ class CoreLibraryTest(unittest.TestCase): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.library1 = mock.Mock(spec=backend.LibraryProvider) + self.library1.get_images().get.return_value = {} + self.library1.get_images.reset_mock() self.library1.root_directory.get.return_value = dummy1_root self.backend1.library = self.library1 @@ -21,6 +23,8 @@ class CoreLibraryTest(unittest.TestCase): self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2', 'du2'] self.library2 = mock.Mock(spec=backend.LibraryProvider) + self.library2.get_images().get.return_value = {} + self.library2.get_images.reset_mock() self.library2.root_directory.get.return_value = dummy2_root self.backend2.library = self.library2 @@ -33,6 +37,46 @@ class CoreLibraryTest(unittest.TestCase): self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) + def test_get_images_returns_empty_dict_for_no_uris(self): + self.assertEqual({}, self.core.library.get_images([])) + + def test_get_images_returns_empty_dict_for_unknown_uri(self): + self.assertEqual({}, self.core.library.get_images(['dummy4:bar'])) + + def test_get_images_returns_empty_dict_for_library_less_uri(self): + self.assertEqual({}, self.core.library.get_images(['dummy3:foo'])) + + def test_get_images_maps_uri_to_backend(self): + self.core.library.get_images(['dummy1:track']) + self.library1.get_images.assert_called_once_with(['dummy1:track']) + self.library2.get_images.assert_not_called() + + def test_get_images_maps_uri_to_backends(self): + self.core.library.get_images(['dummy1:track', 'dummy2:track']) + self.library1.get_images.assert_called_once_with(['dummy1:track']) + self.library2.get_images.assert_called_once_with(['dummy2:track']) + + def test_get_images_returns_images(self): + self.library1.get_images().get.return_value = { + 'dummy1:track': Image(uri='uri')} + self.library1.get_images.reset_mock() + + result = self.core.library.get_images(['dummy1:track']) + self.assertEqual({'dummy1:track': Image(uri='uri')}, result) + + def test_get_images_merges_results(self): + self.library1.get_images().get.return_value = { + 'dummy1:track': Image(uri='uri1')} + self.library1.get_images.reset_mock() + self.library2.get_images().get.return_value = { + 'dummy2:track': Image(uri='uri2')} + self.library2.get_images.reset_mock() + + result = self.core.library.get_images(['dummy1:track', 'dummy2:track']) + expected = {'dummy1:track': Image(uri='uri1'), + 'dummy2:track': Image(uri='uri2')} + self.assertEqual(expected, result) + def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): result = self.core.library.browse(None) From a3133afe6bf8fc21d3930f4124159cf68058da80 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 22:41:21 +0100 Subject: [PATCH 267/495] docs: Add changelog entry for core.library.get_images --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 89090218..b979944e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,6 +21,9 @@ v0.20.0 (UNRELEASED) :meth:`mopidy.core.Playback.stop`. It was a leaky internal abstraction, which was never intended to be used externally. +- Add :meth:`mopidy.core.LibraryController.get_images` for looking up images + for any URI backends know about. (Fixes :issue:`973`) + **Commands** - Make the ``mopidy`` command print a friendly error message if the From 38158b4430e03c62e512224a3ba8da9bd199b3cb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 22:48:05 +0100 Subject: [PATCH 268/495] local: Minor docstring review corrections --- mopidy/local/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 2ec8b79e..31ec6426 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -70,8 +70,8 @@ class Library(object): #: Name of the local library implementation, must be overriden. name = None - #: Feature marker to indicate that you want add calls to be called with - #: optional arguments tags and duration. + #: Feature marker to indicate that you want :meth:`add()` calls to be + #: called with optional arguments tags and duration. add_supports_tags_and_duration = False def __init__(self, config): @@ -142,7 +142,7 @@ class Library(object): def add(self, track, tags=None, duration=None): """ Add the given track to library. Optional args will only be added if - `add_supports_tags_and_duration` has been set. + :attr:`add_supports_tags_and_duration` has been set. :param track: Track to add to the library :type track: :class:`~mopidy.models.Track` From b1b6fb78082361725da81945d8eb3de65a1d18d1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 23:08:27 +0100 Subject: [PATCH 269/495] tests: Re-add incorrectly removed test case --- tests/test_models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_models.py b/tests/test_models.py index af8e0f82..e7aec877 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -123,6 +123,12 @@ class RefTest(unittest.TestCase): self.assertEqual(ref.name, 'bar') self.assertEqual(ref.type, Ref.PLAYLIST) + def test_track_constructor(self): + ref = Ref.track(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.TRACK) + class ImageTest(unittest.TestCase): def test_uri(self): From 533948f8f8d803a0c741d2ede7405b11adeca755 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 23:10:20 +0100 Subject: [PATCH 270/495] core: Make sure we return list of images in get_images tests --- tests/core/test_library.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index cece44e1..fb1f9228 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -58,23 +58,23 @@ class CoreLibraryTest(unittest.TestCase): def test_get_images_returns_images(self): self.library1.get_images().get.return_value = { - 'dummy1:track': Image(uri='uri')} + 'dummy1:track': [Image(uri='uri')]} self.library1.get_images.reset_mock() result = self.core.library.get_images(['dummy1:track']) - self.assertEqual({'dummy1:track': Image(uri='uri')}, result) + self.assertEqual({'dummy1:track': [Image(uri='uri')]}, result) def test_get_images_merges_results(self): self.library1.get_images().get.return_value = { - 'dummy1:track': Image(uri='uri1')} + 'dummy1:track': [Image(uri='uri1')]} self.library1.get_images.reset_mock() self.library2.get_images().get.return_value = { - 'dummy2:track': Image(uri='uri2')} + 'dummy2:track': [Image(uri='uri2')]} self.library2.get_images.reset_mock() result = self.core.library.get_images(['dummy1:track', 'dummy2:track']) - expected = {'dummy1:track': Image(uri='uri1'), - 'dummy2:track': Image(uri='uri2')} + expected = {'dummy1:track': [Image(uri='uri1')], + 'dummy2:track': [Image(uri='uri2')]} self.assertEqual(expected, result) def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): From 5a6eb78137f2c18f673c46a87fa08eaa5d255129 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 23:11:29 +0100 Subject: [PATCH 271/495] docs: Update changelog for PR#958 --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 89090218..dcd17af7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -110,6 +110,9 @@ v0.20.0 (UNRELEASED) make sense for a server such as Mopidy. Currently the only way to find out if it is in use and will be missed is to go ahead and remove it. +- Add workaround for volume not persisting across tracks on OS X. + (Issue: :issue:`886`, PR: :issue:`958`) + **Stream backend** - Add basic tests for the stream library provider. From 8e4b01912741b7ac548b823b2f897854e96fb612 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 23:33:01 +0100 Subject: [PATCH 272/495] audio: Update #886 workaround to work with new audio APIs --- mopidy/audio/actor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 4b47d539..133c8424 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -546,12 +546,16 @@ class Audio(pykka.ThreadingActor): # XXX: Hack to workaround issue on Mac OS X where volume level # does not persist between track changes. mopidy/mopidy#886 - current_volume = self.get_volume() + if self.mixer is not None: + current_volume = self.mixer.get_volume() + else: + current_volume = None self._tags = {} # TODO: add test for this somehow self._playbin.set_property('uri', uri) - self.set_volume(current_volume) + if self.mixer is not None and current_volume is not None: + self.mixer.set_volume(current_volume) def set_appsrc( self, caps, need_data=None, enough_data=None, seek_data=None): From b7c71b84d5968333495c3997304fad53ceb96179 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 23:51:20 +0100 Subject: [PATCH 273/495] core: Update get_images documenatation --- mopidy/core/library.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index bdff2ccd..b3832d5f 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -75,8 +75,15 @@ class LibraryController(object): def get_images(self, uris): """Lookup the images for the given URIs - :param list uris: list of uris to find images for - :rtype: dict mapping uris to :class:`mopidy.models.Image` instances + Backends can use this to return image URIs for any URI they know about + be it tracks, albums, playlists... The lookup result is a dictionary + mapping the provided URIs to lists of images. + + Unknown URIs or URIs the corresponding backend couldn't find anything + for will simply return an empty list for that URI. + + :param list uris: list of URIsto find images for + :rtype: {uri: [:class:`mopidy.models.Image`]} """ futures = [ backend.library.get_images(backend_uris) From 07f2d46ccfdfbf1ca51f91c6dc9a1f13495c453d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 23:49:47 +0100 Subject: [PATCH 274/495] softwaremixer: Update comment after audio refactoring --- mopidy/softwaremixer/mixer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/softwaremixer/mixer.py b/mopidy/softwaremixer/mixer.py index dadbbec8..85441c57 100644 --- a/mopidy/softwaremixer/mixer.py +++ b/mopidy/softwaremixer/mixer.py @@ -28,9 +28,9 @@ class SoftwareMixer(pykka.ThreadingActor, mixer.Mixer): self._audio_mixer = mixer_ref # The Mopidy startup procedure will set the initial volume of a - # mixer, but this happens before the audio actor is injected into the - # software mixer and has no effect. Thus, we need to set the initial - # volume again. + # mixer, but this happens before the audio actor's mixer is injected + # into the software mixer actor and has no effect. Thus, we need to set + # the initial volume again. if self._initial_volume is not None: self.set_volume(self._initial_volume) if self._initial_mute is not None: From 3c4683c3199a283b0bc2e3cf1b753bb0931eb1a1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 23:50:07 +0100 Subject: [PATCH 275/495] softwaremixer: Remove comment What mixer is used is already logged by the code that starts the mixer. Some mixers have additional information about what controls, etc. are used, but in this case the only thing we add is that GStreamer is used. --- mopidy/softwaremixer/mixer.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mopidy/softwaremixer/mixer.py b/mopidy/softwaremixer/mixer.py index 85441c57..d94a0be2 100644 --- a/mopidy/softwaremixer/mixer.py +++ b/mopidy/softwaremixer/mixer.py @@ -21,9 +21,6 @@ class SoftwareMixer(pykka.ThreadingActor, mixer.Mixer): self._initial_volume = None self._initial_mute = None - # TODO: shouldn't this be logged by thing that choose us? - logger.info('Mixing using GStreamer software mixing') - def setup(self, mixer_ref): self._audio_mixer = mixer_ref From ddd872cdeafaa809a71a7323286bfadd5b2550ca Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Feb 2015 00:06:57 +0100 Subject: [PATCH 276/495] core: Always return an answer for all URIs in get_images Also make sure that results are tuples instead of lists so we don't accidentally give out mutable state. --- mopidy/core/library.py | 13 +++++++------ tests/core/test_library.py | 20 ++++++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index b3832d5f..822836a6 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -82,18 +82,19 @@ class LibraryController(object): Unknown URIs or URIs the corresponding backend couldn't find anything for will simply return an empty list for that URI. - :param list uris: list of URIsto find images for - :rtype: {uri: [:class:`mopidy.models.Image`]} + :param list uris: list of URIs to find images for + :rtype: {uri: tuple of :class:`mopidy.models.Image`} """ futures = [ backend.library.get_images(backend_uris) for (backend, backend_uris) in self._get_backends_to_uris(uris).items() if backend_uris] - images = {} - for result in pykka.get_all(futures): - images.update(result) - return images + results = {uri: tuple() for uri in uris} + for r in pykka.get_all(futures): + for uri, images in r.items(): + results[uri] += tuple(images) + return results def find_exact(self, query=None, uris=None, **kwargs): """ diff --git a/tests/core/test_library.py b/tests/core/test_library.py index fb1f9228..ccf1b349 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -40,11 +40,13 @@ class CoreLibraryTest(unittest.TestCase): def test_get_images_returns_empty_dict_for_no_uris(self): self.assertEqual({}, self.core.library.get_images([])) - def test_get_images_returns_empty_dict_for_unknown_uri(self): - self.assertEqual({}, self.core.library.get_images(['dummy4:bar'])) + def test_get_images_returns_empty_result_for_unknown_uri(self): + result = self.core.library.get_images(['dummy4:track']) + self.assertEqual({'dummy4:track': tuple()}, result) - def test_get_images_returns_empty_dict_for_library_less_uri(self): - self.assertEqual({}, self.core.library.get_images(['dummy3:foo'])) + def test_get_images_returns_empty_result_for_library_less_uri(self): + result = self.core.library.get_images(['dummy3:track']) + self.assertEqual({'dummy3:track': tuple()}, result) def test_get_images_maps_uri_to_backend(self): self.core.library.get_images(['dummy1:track']) @@ -62,7 +64,7 @@ class CoreLibraryTest(unittest.TestCase): self.library1.get_images.reset_mock() result = self.core.library.get_images(['dummy1:track']) - self.assertEqual({'dummy1:track': [Image(uri='uri')]}, result) + self.assertEqual({'dummy1:track': (Image(uri='uri'),)}, result) def test_get_images_merges_results(self): self.library1.get_images().get.return_value = { @@ -72,9 +74,11 @@ class CoreLibraryTest(unittest.TestCase): 'dummy2:track': [Image(uri='uri2')]} self.library2.get_images.reset_mock() - result = self.core.library.get_images(['dummy1:track', 'dummy2:track']) - expected = {'dummy1:track': [Image(uri='uri1')], - 'dummy2:track': [Image(uri='uri2')]} + result = self.core.library.get_images( + ['dummy1:track', 'dummy2:track', 'dummy3:track', 'dummy4:track']) + expected = {'dummy1:track': (Image(uri='uri1'),), + 'dummy2:track': (Image(uri='uri2'),), + 'dummy3:track': tuple(), 'dummy4:track': tuple()} self.assertEqual(expected, result) def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): From e4f2796e81e3c8ae6c86b1320653a9172afa5c55 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 00:41:22 +0100 Subject: [PATCH 277/495] docs: Add model addition to changelog --- docs/changelog.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 86566e99..4206d455 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,11 @@ This changelog is used to track all major changes to Mopidy. v0.20.0 (UNRELEASED) ==================== +**Models** + +- Add :class:`mopidy.models.Image` model to be returned by + :meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`) + **Core API** - Deprecate all properties in the core API. The previously undocumented getter From 96572eacdfe9267b5a57369a61529bf06c762c30 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Feb 2015 00:51:52 +0100 Subject: [PATCH 278/495] audio: Add proxy support to scanner --- mopidy/audio/actor.py | 17 +---------------- mopidy/audio/scan.py | 7 ++++++- mopidy/audio/utils.py | 26 ++++++++++++++++++++++++-- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 133c8424..6ef48a6b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -244,20 +244,6 @@ class SoftwareMixer(object): self._mixer.trigger_mute_changed(self._last_mute) -def setup_proxy(element, config): - # TODO: reuse in scanner code - if not config.get('hostname'): - return - - proxy = "%s://%s:%d" % (config.get('scheme', 'http'), - config.get('hostname'), - config.get('port', 80)) - - element.set_property('proxy', proxy) - element.set_property('proxy-id', config.get('username')) - element.set_property('proxy-pw', config.get('password')) - - class _Handler(object): def __init__(self, audio): self._audio = audio @@ -531,8 +517,7 @@ class Audio(pykka.ThreadingActor): else: self._appsrc.reset() - if hasattr(source.props, 'proxy'): - setup_proxy(source, self._config['proxy']) + utils.setup_proxy(source, self._config['proxy']) def set_uri(self, uri): """ diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 931a2e3a..38b86437 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -16,10 +16,11 @@ class Scanner(object): Helper to get tags and other relevant info from URIs. :param timeout: timeout for scanning a URI in ms + :param proxy_config: dictionary containing proxy config strings. :type event: int """ - def __init__(self, timeout=1000): + def __init__(self, timeout=1000, proxy_config=None): self._timeout_ms = timeout sink = gst.element_factory_make('fakesink') @@ -29,9 +30,13 @@ class Scanner(object): def pad_added(src, pad): return pad.link(sink.get_pad('sink')) + def source_setup(element, source): + utils.setup_proxy(source, proxy_config or {}) + self._uribin = gst.element_factory_make('uridecodebin') self._uribin.set_property('caps', audio_caps) self._uribin.connect('pad-added', pad_added) + self._uribin.connect('source-setup', source_setup) self._pipe = gst.element_factory_make('pipeline') self._pipe.add(self._uribin) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 8581fd61..1a8bf6a7 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -82,7 +82,8 @@ def _artists(tags, artist_name, artist_id=None): def convert_tags_to_track(tags): """Convert our normalized tags to a track. - :param :class:`dict` tags: dictionary of tag keys with a list of values + :param tags: dictionary of tag keys with a list of values + :type tags: :class:`dict` :rtype: :class:`mopidy.models.Track` """ album_kwargs = {} @@ -130,6 +131,26 @@ def convert_tags_to_track(tags): return Track(**track_kwargs) +def setup_proxy(element, config): + """Configure a GStreamer element with proxy settings. + + :param element: element to setup proxy in. + :type element: :class:`gst.GstElement` + :param config: proxy settings to use. + :type config: :class:`dict` + """ + if not hasattr(element.props, 'proxy') or not config.get('hostname'): + return + + proxy = "%s://%s:%d" % (config.get('scheme', 'http'), + config.get('hostname'), + config.get('port', 80)) + + element.set_property('proxy', proxy) + element.set_property('proxy-id', config.get('username')) + element.set_property('proxy-pw', config.get('password')) + + def convert_taglist(taglist): """Convert a :class:`gst.Taglist` to plain Python types. @@ -147,7 +168,8 @@ def convert_taglist(taglist): .. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\ 0.10.36/gstreamer/html/gstreamer-GstTagList.html - :param gst.Taglist taglist: A GStreamer taglist to be converted. + :param taglist: A GStreamer taglist to be converted. + :type taglist: :class:`gst.Taglist` :rtype: dictionary of tag keys with a list of values. """ result = {} From 0e4e872d6bda4b71a2b0c6b3011427b0fbe30a50 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Feb 2015 00:52:20 +0100 Subject: [PATCH 279/495] stream: Hook stream scanner up to proxy settings --- docs/changelog.rst | 3 +++ mopidy/stream/actor.py | 7 ++++--- tests/stream/test_library.py | 8 ++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 01476404..c39f56e7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -124,6 +124,9 @@ v0.20.0 (UNRELEASED) - Add basic tests for the stream library provider. +- Add support for proxies when doing initial metadata lookup for stream. + (Fixes :issue:`390`) + **Mopidy.js client library** This version has been released to npm as Mopidy.js v0.5.0. diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 9599d9d3..58fd966a 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -20,7 +20,8 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): self.library = StreamLibraryProvider( backend=self, timeout=config['stream']['timeout'], - blacklist=config['stream']['metadata_blacklist']) + blacklist=config['stream']['metadata_blacklist'], + proxy=config['proxy']) self.playback = backend.PlaybackProvider(audio=audio, backend=self) self.playlists = None @@ -29,9 +30,9 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): class StreamLibraryProvider(backend.LibraryProvider): - def __init__(self, backend, timeout, blacklist): + def __init__(self, backend, timeout, blacklist, proxy): super(StreamLibraryProvider, self).__init__(backend) - self._scanner = scan.Scanner(timeout=timeout) + self._scanner = scan.Scanner(timeout=timeout, proxy_config=proxy) self._blacklist_re = re.compile( r'^(%s)$' % '|'.join(fnmatch.translate(u) for u in blacklist)) diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py index 7ed871cb..93292376 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -25,19 +25,19 @@ class LibraryProviderTest(unittest.TestCase): self.uri = path_to_uri(path_to_data_dir('song1.wav')) def test_lookup_ignores_unknown_scheme(self): - library = actor.StreamLibraryProvider(self.backend, 1000, []) + library = actor.StreamLibraryProvider(self.backend, 1000, [], {}) self.assertFalse(library.lookup('http://example.com')) def test_lookup_respects_blacklist(self): - library = actor.StreamLibraryProvider(self.backend, 100, [self.uri]) + library = actor.StreamLibraryProvider(self.backend, 10, [self.uri], {}) self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) def test_lookup_respects_blacklist_globbing(self): blacklist = [path_to_uri(path_to_data_dir('')) + '*'] - library = actor.StreamLibraryProvider(self.backend, 100, blacklist) + library = actor.StreamLibraryProvider(self.backend, 100, blacklist, {}) self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) def test_lookup_converts_uri_metadata_to_track(self): - library = actor.StreamLibraryProvider(self.backend, 100, []) + library = actor.StreamLibraryProvider(self.backend, 100, [], {}) self.assertEqual([Track(length=4406, uri=self.uri)], library.lookup(self.uri)) From 333bc69777e5e97c98072c7cd5522643726c8d7b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 00:58:09 +0100 Subject: [PATCH 280/495] jsonrpc: Don't use mixer in tests --- tests/utils/test_jsonrpc.py | 42 ++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index 6e309c7c..535df175 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -13,6 +13,9 @@ from mopidy.utils import jsonrpc class Calculator(object): + def __init__(self): + self._mem = None + def model(self): return 'TI83' @@ -23,6 +26,12 @@ class Calculator(object): def sub(self, a, b): return a - b + def set_mem(self, value): + self._mem = value + + def get_mem(self): + return self._mem + def describe(self): return { 'add': 'Returns the sum of the terms', @@ -43,13 +52,14 @@ class JsonRpcTestBase(unittest.TestCase): def setUp(self): # noqa: N802 self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() + self.calc = Calculator() self.jrw = jsonrpc.JsonRpcWrapper( objects={ 'hello': lambda: 'Hello, world!', - 'calc': Calculator(), + 'calc': self.calc, 'core': self.core, - 'core.mixer': self.core.mixer, + 'core.playback': self.core.playback, 'core.tracklist': self.core.tracklist, 'get_uri_schemes': self.core.get_uri_schemes, }, @@ -188,12 +198,12 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_on_actor_member(self): request = { 'jsonrpc': '2.0', - 'method': 'core.mixer.get_volume', + 'method': 'core.playback.get_time_position', 'id': 1, } response = self.jrw.handle_data(request) - self.assertEqual(response['result'], None) + self.assertEqual(response['result'], 0) def test_call_method_which_is_a_directly_mounted_actor_member(self): # 'get_uri_schemes' isn't a regular callable, but a Pykka @@ -215,26 +225,24 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_with_positional_params(self): request = { 'jsonrpc': '2.0', - 'method': 'core.mixer.set_volume', - 'params': [37], + 'method': 'calc.add', + 'params': [3, 4], 'id': 1, } response = self.jrw.handle_data(request) - self.assertEqual(response['result'], None) - self.assertEqual(self.core.mixer.get_volume().get(), 37) + self.assertEqual(response['result'], 7) def test_call_methods_with_named_params(self): request = { 'jsonrpc': '2.0', - 'method': 'core.mixer.set_volume', - 'params': {'volume': 37}, + 'method': 'calc.add', + 'params': {'a': 3, 'b': 4}, 'id': 1, } response = self.jrw.handle_data(request) - self.assertEqual(response['result'], None) - self.assertEqual(self.core.mixer.get_volume().get(), 37) + self.assertEqual(response['result'], 7) class JsonRpcSingleNotificationTest(JsonRpcTestBase): @@ -248,17 +256,17 @@ class JsonRpcSingleNotificationTest(JsonRpcTestBase): self.assertIsNone(response) def test_notification_makes_an_observable_change(self): - self.assertEqual(self.core.mixer.get_volume().get(), None) + self.assertEqual(self.calc.get_mem(), None) request = { 'jsonrpc': '2.0', - 'method': 'core.mixer.set_volume', + 'method': 'calc.set_mem', 'params': [37], } response = self.jrw.handle_data(request) self.assertIsNone(response) - self.assertEqual(self.core.mixer.get_volume().get(), 37) + self.assertEqual(self.calc.get_mem(), 37) def test_notification_unknown_method_returns_nothing(self): request = { @@ -526,7 +534,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): def test_batch_of_both_successfull_and_failing_requests(self): request = [ # Call with positional params - {'jsonrpc': '2.0', 'method': 'core.mixer.set_volume', + {'jsonrpc': '2.0', 'method': 'core.playback.seek', 'params': [47], 'id': '1'}, # Notification {'jsonrpc': '2.0', 'method': 'core.tracklist.set_consume', @@ -547,7 +555,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): self.assertEqual(len(response), 5) response = dict((row['id'], row) for row in response) - self.assertEqual(response['1']['result'], None) + self.assertEqual(response['1']['result'], False) self.assertEqual(response['2']['result'], None) self.assertEqual(response[None]['error']['code'], -32600) self.assertEqual(response['5']['error']['code'], -32601) From 886c2b92d8dcc40577341245f7973d4a2d31aa90 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 00:58:37 +0100 Subject: [PATCH 281/495] core: Use a mixer mock in tests --- tests/core/test_mixer.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index e3fa6be6..80e6f7ef 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -2,27 +2,34 @@ from __future__ import absolute_import, unicode_literals import unittest -from mopidy import core +import mock + +from mopidy import core, mixer class CoreMixerTest(unittest.TestCase): def setUp(self): # noqa: N802 - self.core = core.Core(mixer=None, backends=[]) + self.mixer = mock.Mock(spec=mixer.Mixer) + self.core = core.Core(mixer=self.mixer, backends=[]) - def test_volume(self): - self.assertEqual(self.core.mixer.get_volume(), None) - - self.core.mixer.set_volume(30) + def test_get_volume(self): + self.mixer.get_volume.return_value.get.return_value = 30 self.assertEqual(self.core.mixer.get_volume(), 30) + self.mixer.get_volume.assert_called_once_with() - self.core.mixer.set_volume(70) + def test_set_volume(self): + self.core.mixer.set_volume(30) - self.assertEqual(self.core.mixer.get_volume(), 70) + self.mixer.set_volume.assert_called_once_with(30) - def test_mute(self): - self.assertEqual(self.core.mixer.get_mute(), False) - - self.core.mixer.set_mute(True) + def test_get_mute(self): + self.mixer.get_mute.return_value.get.return_value = True self.assertEqual(self.core.mixer.get_mute(), True) + self.mixer.get_mute.assert_called_once_with() + + def test_set_mute(self): + self.core.mixer.set_mute(True) + + self.mixer.set_mute.assert_called_once_with(True) From 160afbcd265cfc0602213f7b07c6e4fa92f82cf2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:14:36 +0100 Subject: [PATCH 282/495] mpd: Use DummyMixer in tests --- mopidy/mixer.py | 22 ++++++++++++++++++++++ tests/mpd/protocol/__init__.py | 6 ++++-- tests/mpd/test_status.py | 6 ++++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/mopidy/mixer.py b/mopidy/mixer.py index e277fe55..b9fc41ca 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, unicode_literals import logging +import pykka + from mopidy import listener @@ -147,3 +149,23 @@ class MixerListener(listener.Listener): :type mute: bool """ pass + + +class DummyMixer(pykka.ThreadingActor, Mixer): + + def __init__(self): + super(DummyMixer, self).__init__() + self._volume = None + self._mute = None + + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + + def get_mute(self): + return self._mute + + def set_mute(self, mute): + self._mute = mute diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 8c7b60f1..ba446cb0 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -6,7 +6,7 @@ import mock import pykka -from mopidy import core +from mopidy import core, mixer from mopidy.backend import dummy from mopidy.mpd import session, uri_mapper @@ -32,8 +32,10 @@ class BaseTestCase(unittest.TestCase): } def setUp(self): # noqa: N802 + self.mixer = mixer.DummyMixer.start().proxy() self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + self.core = core.Core.start( + mixer=self.mixer, backends=[self.backend]).proxy() self.uri_map = uri_mapper.MpdUriMapper(self.core) self.connection = MockConnection() diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 75c10c94..8dbfb1e4 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -4,7 +4,7 @@ import unittest import pykka -from mopidy import core +from mopidy import core, mixer from mopidy.backend import dummy from mopidy.core import PlaybackState from mopidy.models import Track @@ -21,8 +21,10 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): # noqa: N802 + self.mixer = mixer.DummyMixer.start().proxy() self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + self.core = core.Core.start( + mixer=self.mixer, backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context From f7e218b72a09615259b4d77e9169f5237a4cae32 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 00:58:52 +0100 Subject: [PATCH 283/495] core: Remove test-only code paths in MixerController --- mopidy/core/mixer.py | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index e6856f17..4d77f8bc 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -21,11 +21,8 @@ class MixerController(object): The volume scale is linear. """ - if self._mixer: + if self._mixer is not None: return self._mixer.get_volume().get() - else: - # For testing - return self._volume def set_volume(self, volume): """Set the volume. @@ -34,31 +31,22 @@ class MixerController(object): The volume scale is linear. """ - if self._mixer: + if self._mixer is not None: self._mixer.set_volume(volume) - else: - # For testing - self._volume = volume def get_mute(self): """Get mute state. - :class:`True` if muted, :class:`False` otherwise. + :class:`True` if muted, :class:`False` unmuted, :class:`None` if + unknown. """ - if self._mixer: + if self._mixer is not None: return self._mixer.get_mute().get() - else: - # For testing - return self._mute def set_mute(self, mute): """Set mute state. :class:`True` to mute, :class:`False` to unmute. """ - mute = bool(mute) - if self._mixer: - self._mixer.set_mute(mute) - else: - # For testing - self._mute = mute + if self._mixer is not None: + self._mixer.set_mute(bool(mute)) From df95a988b72c66221b2c12e6524995e174f46915 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:30:17 +0100 Subject: [PATCH 284/495] backend: Move DummyBackend into tests package --- tests/core/test_events.py | 5 +++-- mopidy/backend/dummy.py => tests/dummy_backend.py | 2 +- tests/mpd/protocol/__init__.py | 5 +++-- tests/mpd/test_dispatcher.py | 5 +++-- tests/mpd/test_status.py | 6 ++++-- tests/utils/test_jsonrpc.py | 5 +++-- 6 files changed, 17 insertions(+), 11 deletions(-) rename mopidy/backend/dummy.py => tests/dummy_backend.py (98%) diff --git a/tests/core/test_events.py b/tests/core/test_events.py index 7226673d..942f9b5f 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -7,14 +7,15 @@ import mock import pykka from mopidy import core -from mopidy.backend import dummy from mopidy.models import Track +from tests import dummy_backend + @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): # noqa: N802 - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 diff --git a/mopidy/backend/dummy.py b/tests/dummy_backend.py similarity index 98% rename from mopidy/backend/dummy.py rename to tests/dummy_backend.py index dfddf5ae..05b0fbff 100644 --- a/mopidy/backend/dummy.py +++ b/tests/dummy_backend.py @@ -12,7 +12,7 @@ from mopidy import backend from mopidy.models import Playlist, Ref, SearchResult -def create_dummy_backend_proxy(config=None, audio=None): +def create_proxy(config=None, audio=None): return DummyBackend.start(config=config, audio=audio).proxy() diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index ba446cb0..ed4920f2 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -7,9 +7,10 @@ import mock import pykka from mopidy import core, mixer -from mopidy.backend import dummy from mopidy.mpd import session, uri_mapper +from tests import dummy_backend + class MockConnection(mock.Mock): def __init__(self, *args, **kwargs): @@ -33,7 +34,7 @@ class BaseTestCase(unittest.TestCase): def setUp(self): # noqa: N802 self.mixer = mixer.DummyMixer.start().proxy() - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index 1a230451..63981668 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -5,10 +5,11 @@ import unittest import pykka from mopidy import core -from mopidy.backend import dummy from mopidy.mpd.dispatcher import MpdDispatcher from mopidy.mpd.exceptions import MpdAckError +from tests import dummy_backend + class MpdDispatcherTest(unittest.TestCase): def setUp(self): # noqa: N802 @@ -17,7 +18,7 @@ class MpdDispatcherTest(unittest.TestCase): 'password': None, } } - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = MpdDispatcher(config=config) diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 8dbfb1e4..069addca 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -5,12 +5,14 @@ import unittest import pykka from mopidy import core, mixer -from mopidy.backend import dummy from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status +from tests import dummy_backend + + PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING STOPPED = PlaybackState.STOPPED @@ -22,7 +24,7 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mixer = mixer.DummyMixer.start().proxy() - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index 535df175..4471a4a0 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -8,9 +8,10 @@ import mock import pykka from mopidy import core, models -from mopidy.backend import dummy from mopidy.utils import jsonrpc +from tests import dummy_backend + class Calculator(object): def __init__(self): @@ -50,7 +51,7 @@ class Calculator(object): class JsonRpcTestBase(unittest.TestCase): def setUp(self): # noqa: N802 - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.calc = Calculator() From 016024a081852de7390318c79b2f7ffc4af59a3b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:31:33 +0100 Subject: [PATCH 285/495] backend: Convert from package to module --- mopidy/{backend/__init__.py => backend.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mopidy/{backend/__init__.py => backend.py} (100%) diff --git a/mopidy/backend/__init__.py b/mopidy/backend.py similarity index 100% rename from mopidy/backend/__init__.py rename to mopidy/backend.py From b554a64aadfc42b5dced8b3431db0a05c852d1c7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:35:07 +0100 Subject: [PATCH 286/495] mixer: Move DummyMixer into tests package --- mopidy/mixer.py | 22 ---------------------- tests/dummy_mixer.py | 29 +++++++++++++++++++++++++++++ tests/mpd/protocol/__init__.py | 6 +++--- tests/mpd/test_status.py | 6 +++--- 4 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 tests/dummy_mixer.py diff --git a/mopidy/mixer.py b/mopidy/mixer.py index b9fc41ca..e277fe55 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -2,8 +2,6 @@ from __future__ import absolute_import, unicode_literals import logging -import pykka - from mopidy import listener @@ -149,23 +147,3 @@ class MixerListener(listener.Listener): :type mute: bool """ pass - - -class DummyMixer(pykka.ThreadingActor, Mixer): - - def __init__(self): - super(DummyMixer, self).__init__() - self._volume = None - self._mute = None - - def get_volume(self): - return self._volume - - def set_volume(self, volume): - self._volume = volume - - def get_mute(self): - return self._mute - - def set_mute(self, mute): - self._mute = mute diff --git a/tests/dummy_mixer.py b/tests/dummy_mixer.py new file mode 100644 index 00000000..f7d90b17 --- /dev/null +++ b/tests/dummy_mixer.py @@ -0,0 +1,29 @@ +from __future__ import unicode_literals + +import pykka + +from mopidy import mixer + + +def create_proxy(config=None): + return DummyMixer.start(config=None).proxy() + + +class DummyMixer(pykka.ThreadingActor, mixer.Mixer): + + def __init__(self, config): + super(DummyMixer, self).__init__() + self._volume = None + self._mute = None + + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + + def get_mute(self): + return self._mute + + def set_mute(self, mute): + self._mute = mute diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index ed4920f2..b07a5ba3 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -6,10 +6,10 @@ import mock import pykka -from mopidy import core, mixer +from mopidy import core from mopidy.mpd import session, uri_mapper -from tests import dummy_backend +from tests import dummy_backend, dummy_mixer class MockConnection(mock.Mock): @@ -33,7 +33,7 @@ class BaseTestCase(unittest.TestCase): } def setUp(self): # noqa: N802 - self.mixer = mixer.DummyMixer.start().proxy() + self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 069addca..e130353b 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -4,13 +4,13 @@ import unittest import pykka -from mopidy import core, mixer +from mopidy import core from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status -from tests import dummy_backend +from tests import dummy_backend, dummy_mixer PAUSED = PlaybackState.PAUSED @@ -23,7 +23,7 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): # noqa: N802 - self.mixer = mixer.DummyMixer.start().proxy() + self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() From 82b94693f9f319d4dd34a3d347143c8e3579e9ec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:39:57 +0100 Subject: [PATCH 287/495] docs: Add Image to model relation graph --- docs/api/models.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/api/models.rst b/docs/api/models.rst index 11ec017c..270f3896 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -37,6 +37,8 @@ Data model relations SearchResult -> Album [ label="has 0..n" ] SearchResult -> Track [ label="has 0..n" ] + Image + Data model API ============== From 9f199b12cefd2fb0a82cf7395aa05f3634530ef9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:51:08 +0100 Subject: [PATCH 288/495] docs: Update authors --- .mailmap | 1 + AUTHORS | 1 + 2 files changed, 2 insertions(+) diff --git a/.mailmap b/.mailmap index 8b8fd865..3ea843b1 100644 --- a/.mailmap +++ b/.mailmap @@ -18,3 +18,4 @@ Colin Montgomerie Ignasi Fosch Christopher Schirner Laura Barber +John Cass diff --git a/AUTHORS b/AUTHORS index 45817e75..08685991 100644 --- a/AUTHORS +++ b/AUTHORS @@ -49,4 +49,5 @@ - Deni Bertovic - Ali Ukani - Dirk Groenen +- John Cass - Laura Barber From a3b7c8d44f8525350f83c84e7927f518a431922c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 09:33:05 +0100 Subject: [PATCH 289/495] audio: Fix AttributeError on shutdown (fix #985) Given the right timings, there was possible to get a stack trace at shutdown if the audio actor was teared down first and a music delivery from libspotify/pyspotify called audio.push() after the teardown. --- mopidy/audio/actor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 6ef48a6b..63b0eebe 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -128,6 +128,9 @@ class _Appsrc(object): self._source = source def push(self, buffer_): + if self._source is None: + return False + if buffer_ is None: gst_logger.debug('Sending appsrc end-of-stream event.') return self._source.emit('end-of-stream') == gst.FLOW_OK From 5270aa65e2c2fe035ccbb00f482cec35bc4b8786 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:47:15 +0100 Subject: [PATCH 290/495] audio: Move DummyAudio into tests package --- mopidy/audio/__init__.py | 1 - tests/audio/test_actor.py | 3 +- mopidy/audio/dummy.py => tests/dummy_audio.py | 42 ++++++++++--------- tests/local/test_events.py | 6 +-- tests/local/test_playback.py | 6 +-- tests/local/test_playlists.py | 6 +-- tests/local/test_tracklist.py | 6 +-- 7 files changed, 36 insertions(+), 34 deletions(-) rename mopidy/audio/dummy.py => tests/dummy_audio.py (65%) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 1d47e682..a74d4456 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, unicode_literals # flake8: noqa from .actor import Audio -from .dummy import DummyAudio from .listener import AudioListener from .constants import PlaybackState from .utils import ( diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 43f7c076..fbc440de 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -15,11 +15,10 @@ import mock import pykka from mopidy import audio -from mopidy.audio import dummy as dummy_audio from mopidy.audio.constants import PlaybackState from mopidy.utils.path import path_to_uri -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir # We want to make sure both our real audio class and the fake one behave # correctly. So each test is first run against the real class, then repeated diff --git a/mopidy/audio/dummy.py b/tests/dummy_audio.py similarity index 65% rename from mopidy/audio/dummy.py rename to tests/dummy_audio.py index 95b9d0fb..64639e91 100644 --- a/mopidy/audio/dummy.py +++ b/tests/dummy_audio.py @@ -8,14 +8,17 @@ from __future__ import absolute_import, unicode_literals import pykka -from .constants import PlaybackState -from .listener import AudioListener +from mopidy import audio + + +def create_proxy(config=None, mixer=None): + return DummyAudio.start(config, mixer).proxy() class DummyAudio(pykka.ThreadingActor): def __init__(self, config=None, mixer=None): super(DummyAudio, self).__init__() - self.state = PlaybackState.STOPPED + self.state = audio.PlaybackState.STOPPED self._volume = 0 self._position = 0 self._callback = None @@ -42,21 +45,21 @@ class DummyAudio(pykka.ThreadingActor): def set_position(self, position): self._position = position - AudioListener.send('position_changed', position=position) + audio.AudioListener.send('position_changed', position=position) return True def start_playback(self): - return self._change_state(PlaybackState.PLAYING) + return self._change_state(audio.PlaybackState.PLAYING) def pause_playback(self): - return self._change_state(PlaybackState.PAUSED) + return self._change_state(audio.PlaybackState.PAUSED) def prepare_change(self): self._uri = None return True def stop_playback(self): - return self._change_state(PlaybackState.STOPPED) + return self._change_state(audio.PlaybackState.STOPPED) def get_volume(self): return self._volume @@ -84,21 +87,22 @@ class DummyAudio(pykka.ThreadingActor): if not self._uri: return False - if self.state == PlaybackState.STOPPED and self._uri: - AudioListener.send('position_changed', position=0) - AudioListener.send('stream_changed', uri=self._uri) + if self.state == audio.PlaybackState.STOPPED and self._uri: + audio.AudioListener.send('position_changed', position=0) + audio.AudioListener.send('stream_changed', uri=self._uri) - if new_state == PlaybackState.STOPPED: + if new_state == audio.PlaybackState.STOPPED: self._uri = None - AudioListener.send('stream_changed', uri=self._uri) + audio.AudioListener.send('stream_changed', uri=self._uri) old_state, self.state = self.state, new_state - AudioListener.send('state_changed', old_state=old_state, - new_state=new_state, target_state=None) + audio.AudioListener.send( + 'state_changed', + old_state=old_state, new_state=new_state, target_state=None) - if new_state == PlaybackState.PLAYING: + if new_state == audio.PlaybackState.PLAYING: self._tags['audio-codec'] = [u'fake info...'] - AudioListener.send('tags_changed', tags=['audio-codec']) + audio.AudioListener.send('tags_changed', tags=['audio-codec']) return self._state_change_result @@ -114,9 +118,9 @@ class DummyAudio(pykka.ThreadingActor): if not self._uri or not self._callback: self._tags = {} - AudioListener.send('reached_end_of_stream') + audio.AudioListener.send('reached_end_of_stream') else: - AudioListener.send('position_changed', position=0) - AudioListener.send('stream_changed', uri=self._uri) + audio.AudioListener.send('position_changed', position=0) + audio.AudioListener.send('stream_changed', uri=self._uri) return wrapper diff --git a/tests/local/test_events.py b/tests/local/test_events.py index ae2ec66a..945347df 100644 --- a/tests/local/test_events.py +++ b/tests/local/test_events.py @@ -6,10 +6,10 @@ import mock import pykka -from mopidy import audio, backend, core +from mopidy import backend, core from mopidy.local import actor -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir @mock.patch.object(backend.BackendListener, 'send') @@ -24,7 +24,7 @@ class LocalBackendEventsTest(unittest.TestCase): } def setUp(self): # noqa: N802 - self.audio = audio.DummyAudio.start().proxy() + self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core.start(backends=[self.backend]).proxy() diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 0edd89c5..5f1ff525 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -7,12 +7,12 @@ import mock import pykka -from mopidy import audio, core +from mopidy import core from mopidy.core import PlaybackState from mopidy.local import actor from mopidy.models import Track -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -40,7 +40,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.add([track]) def setUp(self): # noqa: N802 - self.audio = audio.DummyAudio.start().proxy() + self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) diff --git a/tests/local/test_playlists.py b/tests/local/test_playlists.py index c9aa299a..3e9c280e 100644 --- a/tests/local/test_playlists.py +++ b/tests/local/test_playlists.py @@ -7,11 +7,11 @@ import unittest import pykka -from mopidy import audio, core +from mopidy import core from mopidy.local import actor from mopidy.models import Playlist, Track -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir from tests.local import generate_song @@ -29,7 +29,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.config['local']['playlists_dir'] = tempfile.mkdtemp() self.playlists_dir = self.config['local']['playlists_dir'] - self.audio = audio.DummyAudio.start().proxy() + self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index d74d436c..5c85ac19 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -5,12 +5,12 @@ import unittest import pykka -from mopidy import audio, core +from mopidy import core from mopidy.core import PlaybackState from mopidy.local import actor from mopidy.models import Playlist, TlTrack, Track -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -27,7 +27,7 @@ class LocalTracklistProviderTest(unittest.TestCase): Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): # noqa: N802 - self.audio = audio.DummyAudio.start().proxy() + self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core(mixer=None, backends=[self.backend]) From 2d958d21b5f954460f6f033faeeaf0ea9fac1109 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 12:11:15 +0100 Subject: [PATCH 291/495] docs: Fix references and typos --- docs/changelog.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4f4e4a5d..988a0fcd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,16 +19,16 @@ v0.20.0 (UNRELEASED) and setter methods are now the official API. This aligns the Python API with the WebSocket/JavaScript API. (Fixes: :issue:`952`) -- Added :class:`mopidy.core.HistoryController` which keeps track of what - tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`) +- Add :class:`mopidy.core.HistoryController` which keeps track of what tracks + have been played. (Fixes: :issue:`423`, PR: :issue:`803`) -- Added :class:`mopidy.core.MixerController` which keeps track of volume and +- Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. The old methods on :class:`mopidy.core.PlaybackController` for volume and mute management has been deprecated. (Fixes: :issue:`962`) -- Removed ``clear_current_track`` keyword argument to - :meth:`mopidy.core.Playback.stop`. It was a leaky internal abstraction, - which was never intended to be used externally. +- Remove ``clear_current_track`` keyword argument to + :meth:`mopidy.core.PlaybackController.stop`. It was a leaky internal + abstraction, which was never intended to be used externally. - Add :meth:`mopidy.core.LibraryController.get_images` for looking up images for any URI backends know about. (Fixes :issue:`973`) @@ -38,7 +38,7 @@ v0.20.0 (UNRELEASED) - Make the ``mopidy`` command print a friendly error message if the :mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`) -- Add support for repeating the :cmdoption:`-v ` argument four times +- Add support for repeating the :option:`-v ` argument four times to set the log level for all loggers to the lowest possible value, including log records at levels lover than ``DEBUG`` too. @@ -71,7 +71,7 @@ v0.20.0 (UNRELEASED) - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) - Add symlink support with loop protection to file finder (Fixes: :issue:`858`, - PR: :isusue:`874`) + PR: :issue:`874`) **MPD frontend** From d60c037fdc5578b3910720ade879161c958ec588 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 12:24:33 +0100 Subject: [PATCH 292/495] docs: Update number of times -v is accepted --- docs/command.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/command.rst b/docs/command.rst index 881fb513..ea9ccce7 100644 --- a/docs/command.rst +++ b/docs/command.rst @@ -43,7 +43,7 @@ Options .. cmdoption:: --verbose, -v - Show more output. Repeat up to 3 times for even more. + Show more output. Repeat up to four times for even more. .. cmdoption:: --save-debug-log From 92910e4362ccb4bd45d5abb4c2306ad343a83265 Mon Sep 17 00:00:00 2001 From: Ali Ukani Date: Mon, 16 Feb 2015 02:00:40 -0500 Subject: [PATCH 293/495] Fix flake8 tests Fixes "W503 line break before binary operator" --- mopidy/core/actor.py | 4 ++-- mopidy/core/tracklist.py | 4 ++-- mopidy/local/search.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index bc8df64d..19e49838 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -98,8 +98,8 @@ class Core( # We ignore cases when target state is set as this is buffering # updates (at least for now) and we need to get #234 fixed... - if (new_state == PlaybackState.PAUSED and not target_state - and self.playback.state != PlaybackState.PAUSED): + if (new_state == PlaybackState.PAUSED and not target_state and + self.playback.state != PlaybackState.PAUSED): self.playback.state = new_state self.playback._trigger_track_playback_paused() diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index c54e6784..08d08329 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -392,8 +392,8 @@ class TracklistController(object): criteria = criteria or kwargs matches = self._tl_tracks for (key, values) in criteria.items(): - if (not isinstance(values, collections.Iterable) - or isinstance(values, compat.string_types)): + if (not isinstance(values, collections.Iterable) or + isinstance(values, compat.string_types)): # Fail hard if anyone is using the <0.17 calling style raise ValueError('Filter values must be iterable: %r' % values) if key == 'tlid': diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 18dad82c..e63d0f8d 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -124,8 +124,8 @@ def search(tracks, query=None, uris=None): return bool(t.name and q in t.name.lower()) def album_filter(t): - return bool(t.album and t.album.name - and q in t.album.name.lower()) + return bool(t.album and t.album.name and + q in t.album.name.lower()) def artist_filter(t): return bool(filter(lambda a: From fc21d466f0455d441a3540a89508a26a47a9b6ad Mon Sep 17 00:00:00 2001 From: Ali Ukani Date: Mon, 26 Jan 2015 16:16:46 -0500 Subject: [PATCH 294/495] local: use limit and offset when searching json library Fixes the json local library's search behavior. Uses limit and offset arguments when returning search results. --- mopidy/local/json.py | 7 ++--- mopidy/local/search.py | 31 +++++++++++++++++++--- tests/local/test_json.py | 55 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 38e1bf6c..f6ff879b 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -157,11 +157,12 @@ class JsonLibrary(local.Library): def search(self, query=None, limit=100, offset=0, uris=None, exact=False): tracks = self._tracks.values() - # TODO: pass limit and offset into search helpers if exact: - return search.find_exact(tracks, query=query, uris=uris) + return search.find_exact( + tracks, query=query, limit=limit, offset=offset, uris=uris) else: - return search.search(tracks, query=query, uris=uris) + return search.search( + tracks, query=query, limit=limit, offset=offset, uris=uris) def begin(self): return compat.itervalues(self._tracks) diff --git a/mopidy/local/search.py b/mopidy/local/search.py index e63d0f8d..9e56d73b 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -3,7 +3,18 @@ from __future__ import absolute_import, unicode_literals from mopidy.models import SearchResult -def find_exact(tracks, query=None, uris=None): +def find_exact(tracks, query=None, limit=100, offset=0, uris=None): + """ + Filter a list of tracks where ``field`` is ``values``. + + :param list tracks: a list of :class:`~mopidy.models.Track` + :param dict query: one or more queries to search for + :param int limit: maximum number of results to return + :param int offset: offset into result set to use. + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` + :rtype: :class:`~mopidy.models.SearchResult` + """ # TODO Only return results within URI roots given by ``uris`` if query is None: @@ -96,10 +107,22 @@ def find_exact(tracks, query=None, uris=None): raise LookupError('Invalid lookup field: %s' % field) # TODO: add local:search: - return SearchResult(uri='local:search', tracks=tracks) + return SearchResult( + uri='local:search', tracks=tracks[offset:offset+limit]) -def search(tracks, query=None, uris=None): +def search(tracks, query=None, limit=100, offset=0, uris=None): + """ + Filter a list of tracks where ``field`` is like ``values``. + + :param list tracks: a list of :class:`~mopidy.models.Track` + :param dict query: one or more queries to search for + :param int limit: maximum number of results to return + :param int offset: offset into result set to use. + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` + :rtype: :class:`~mopidy.models.SearchResult` + """ # TODO Only return results within URI roots given by ``uris`` if query is None: @@ -195,7 +218,7 @@ def search(tracks, query=None, uris=None): else: raise LookupError('Invalid lookup field: %s' % field) # TODO: add local:search: - return SearchResult(uri='local:search', tracks=tracks) + return SearchResult(uri='local:search', tracks=tracks[offset:offset+limit]) def _validate_query(query): diff --git a/tests/local/test_json.py b/tests/local/test_json.py index 0d62c2e3..545833d5 100644 --- a/tests/local/test_json.py +++ b/tests/local/test_json.py @@ -1,9 +1,13 @@ from __future__ import absolute_import, unicode_literals + import unittest from mopidy.local import json -from mopidy.models import Ref +from mopidy.models import Ref, Track + + +from tests import path_to_data_dir class BrowseCacheTest(unittest.TestCase): @@ -38,3 +42,52 @@ class BrowseCacheTest(unittest.TestCase): def test_lookup_foo_baz(self): result = self.cache.lookup('local:directory:foo/unknown') self.assertEqual([], result) + + +class JsonLibraryTest(unittest.TestCase): + + config = { + 'local': { + 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), + 'playlists_dir': b'', + 'library': 'json', + }, + } + + def setUp(self): # noqa: N802 + self.library = json.JsonLibrary(self.config) + + def _create_tracks(self, count): + for i in xrange(count): + self.library.add(Track(uri='local:track:%d' % i)) + + def test_search_should_default_limit_results(self): + self._create_tracks(101) + + result = self.library.search() + result_exact = self.library.search(exact=True) + + self.assertEqual(len(result.tracks), 100) + self.assertEqual(len(result_exact.tracks), 100) + + def test_search_should_limit_results(self): + self._create_tracks(100) + + result = self.library.search(limit=35) + result_exact = self.library.search(exact=True, limit=35) + + self.assertEqual(len(result.tracks), 35) + self.assertEqual(len(result_exact.tracks), 35) + + def test_search_should_offset_results(self): + self._create_tracks(200) + + expected = self.library.search(limit=110).tracks[10:] + expected_exact = self.library.search(exact=True, limit=110).tracks[10:] + + result = self.library.search(offset=10).tracks + result_exact = self.library.search(offset=10, exact=True).tracks + + self.assertEqual(expected, result) + self.assertEqual(expected_exact, result_exact) From ead147f482995baeac8349871c916c9df6d34840 Mon Sep 17 00:00:00 2001 From: Ali Ukani Date: Mon, 16 Feb 2015 00:47:48 -0500 Subject: [PATCH 295/495] Fix flake8 errors, prepare for Python 3 port Fixes flake8 warnings Reword docstring for find_exact Use range instead of xrange in preparation for porting to Python 3 --- mopidy/local/search.py | 9 +++++---- tests/local/test_json.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 9e56d73b..1f82366f 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -8,7 +8,7 @@ def find_exact(tracks, query=None, limit=100, offset=0, uris=None): Filter a list of tracks where ``field`` is ``values``. :param list tracks: a list of :class:`~mopidy.models.Track` - :param dict query: one or more queries to search for + :param dict query: one or more field/value pairs to search for :param int limit: maximum number of results to return :param int offset: offset into result set to use. :param uris: zero or more URI roots to limit the search to @@ -108,7 +108,7 @@ def find_exact(tracks, query=None, limit=100, offset=0, uris=None): # TODO: add local:search: return SearchResult( - uri='local:search', tracks=tracks[offset:offset+limit]) + uri='local:search', tracks=tracks[offset:offset + limit]) def search(tracks, query=None, limit=100, offset=0, uris=None): @@ -116,7 +116,7 @@ def search(tracks, query=None, limit=100, offset=0, uris=None): Filter a list of tracks where ``field`` is like ``values``. :param list tracks: a list of :class:`~mopidy.models.Track` - :param dict query: one or more queries to search for + :param dict query: one or more field/value pairs to search for :param int limit: maximum number of results to return :param int offset: offset into result set to use. :param uris: zero or more URI roots to limit the search to @@ -218,7 +218,8 @@ def search(tracks, query=None, limit=100, offset=0, uris=None): else: raise LookupError('Invalid lookup field: %s' % field) # TODO: add local:search: - return SearchResult(uri='local:search', tracks=tracks[offset:offset+limit]) + return SearchResult(uri='local:search', + tracks=tracks[offset:offset + limit]) def _validate_query(query): diff --git a/tests/local/test_json.py b/tests/local/test_json.py index 545833d5..6d57c4d0 100644 --- a/tests/local/test_json.py +++ b/tests/local/test_json.py @@ -59,7 +59,7 @@ class JsonLibraryTest(unittest.TestCase): self.library = json.JsonLibrary(self.config) def _create_tracks(self, count): - for i in xrange(count): + for i in range(count): self.library.add(Track(uri='local:track:%d' % i)) def test_search_should_default_limit_results(self): From f5e63c406389ee80e17d06e6fe1d7c32936ccda1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 16 Feb 2015 10:34:27 +0100 Subject: [PATCH 296/495] flake8: Fix warnings after flake8 upgrade --- docs/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index be748381..fbfb11aa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,11 +34,11 @@ class Mock(object): elif name == 'get_user_config_dir': # glib.get_user_config_dir() return str - elif (name[0] == name[0].upper() + elif (name[0] == name[0].upper() and # gst.PadTemplate - and not name.startswith('PadTemplate') + not name.startswith('PadTemplate') and # dbus.String() - and not name == 'String'): + not name == 'String'): return type(name, (), {}) else: return Mock() From 305d88c9209cd07cf71164ce6b49a4b2d6a73fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakab=20Krist=C3=B3f?= Date: Tue, 17 Feb 2015 00:01:01 +0100 Subject: [PATCH 297/495] Update arch.rst yaourt only syncs AUR packages if the -a flag is given --- docs/installation/arch.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/arch.rst b/docs/installation/arch.rst index 3f85bf51..e58d6cf5 100644 --- a/docs/installation/arch.rst +++ b/docs/installation/arch.rst @@ -14,7 +14,7 @@ If you are running Arch Linux, you can install Mopidy using the To upgrade Mopidy to future releases, just upgrade your system using:: - yaourt -Syu + yaourt -Syua #. Optional: If you want to use any Mopidy extensions, like Spotify support or Last.fm scrobbling, AUR also has `packages for several Mopidy extensions From e4ba4b3e5f5691aa7eaef83a8cb9936f32771d9c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 00:13:24 +0100 Subject: [PATCH 298/495] mpd: Support blacklisting MPD commands in the server. Default blacklist set to listall and listallinfo. This change has been done to avoid clients being able to call "bad" MPD commands which are often misused to try and keep a client db. Note that this change will break some MPD clients, but the blacklist can be controlled via config to allow opting out for now. --- docs/changelog.rst | 5 +++++ docs/ext/mpd.rst | 7 +++++++ mopidy/mpd/__init__.py | 1 + mopidy/mpd/dispatcher.py | 5 +++++ mopidy/mpd/exceptions.py | 9 +++++++++ mopidy/mpd/ext.conf | 1 + mopidy/utils/deprecation.py | 1 + tests/mpd/test_dispatcher.py | 17 +++++++++++------ 8 files changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 988a0fcd..f403b269 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -84,6 +84,11 @@ v0.20.0 (UNRELEASED) - Share a single mapping between names and URIs across all MPD sessions. (Fixes: :issue:`934`, PR: :issue:`968`) +- Add support for blacklisting MPD commands. This is used to prevent clients + from using `listall` and `listallinfo` which recursively lookup the entire + "database". If you insist on using a client that needs these commands change + :confval:`mpd/command_blacklist`. + **Audio** - Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index ecfab949..4f85e88f 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -99,3 +99,10 @@ See :ref:`config` for general help on configuring Mopidy. ``$hostname`` and ``$port`` can be used in the name. Set to an empty string to disable Zeroconf for MPD. + +.. confval:: mpd/command_blacklist + + List of MPD commands which are disabled by the server. By default this + setting blacklists `listall` and `listallinfo`. These commands don't fit + well with many of Mopidy's backends and are better left disabled unless + you know what you are doing. diff --git a/mopidy/mpd/__init__.py b/mopidy/mpd/__init__.py index 05c83baa..b2438b07 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -24,6 +24,7 @@ class Extension(ext.Extension): schema['max_connections'] = config.Integer(minimum=1) schema['connection_timeout'] = config.Integer(minimum=1) schema['zeroconf'] = config.String(optional=True) + schema['command_blacklist'] = config.List(optional=True) return schema def validate_environment(self): diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index b1b2db77..eece86d9 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -163,6 +163,11 @@ class MpdDispatcher(object): def _call_handler(self, request): tokens = tokenize.split(request) + # TODO: check that blacklist items are valid commands? + blacklist = self.config['mpd'].get('command_blacklist', []) + if tokens and tokens[0] in blacklist: + logger.warning('Client sent us blacklisted command: %s', tokens[0]) + raise exceptions.MpdDisabled(command=tokens[0]) try: return protocol.commands.call(tokens, context=self.context) except exceptions.MpdAckError as exc: diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index e7ab0068..f62b61da 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -87,3 +87,12 @@ class MpdNotImplemented(MpdAckError): def __init__(self, *args, **kwargs): super(MpdNotImplemented, self).__init__(*args, **kwargs) self.message = 'Not implemented' + + +class MpdDisabled(MpdAckError): + error_code = 0 + + def __init__(self, *args, **kwargs): + super(MpdDisabled, self).__init__(*args, **kwargs) + assert self.command is not None, 'command must be given explicitly' + self.message = '"%s" has been disabled in the server' % self.command diff --git a/mopidy/mpd/ext.conf b/mopidy/mpd/ext.conf index c62c37ef..fe9a0494 100644 --- a/mopidy/mpd/ext.conf +++ b/mopidy/mpd/ext.conf @@ -6,3 +6,4 @@ password = max_connections = 20 connection_timeout = 60 zeroconf = Mopidy MPD server on $hostname +command_blacklist = listall,listallinfo diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index 1b744702..06b0fc7c 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -5,6 +5,7 @@ import warnings def _is_pykka_proxy_creation(): + return False stack = inspect.stack() try: calling_frame = stack[3] diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index 63981668..d6b11e43 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -16,6 +16,7 @@ class MpdDispatcherTest(unittest.TestCase): config = { 'mpd': { 'password': None, + 'command_blacklist': ['disabled'], } } self.backend = dummy_backend.create_proxy() @@ -26,14 +27,18 @@ class MpdDispatcherTest(unittest.TestCase): pykka.ActorRegistry.stop_all() def test_call_handler_for_unknown_command_raises_exception(self): - try: + with self.assertRaises(MpdAckError) as cm: self.dispatcher._call_handler('an_unknown_command with args') - self.fail('Should raise exception') - except MpdAckError as e: - self.assertEqual( - e.get_mpd_ack(), - 'ACK [5@0] {} unknown command "an_unknown_command"') + + self.assertEqual( + cm.exception.get_mpd_ack(), + 'ACK [5@0] {} unknown command "an_unknown_command"') def test_handling_unknown_request_yields_error(self): result = self.dispatcher.handle_request('an unhandled request') self.assertEqual(result[0], 'ACK [5@0] {} unknown command "an"') + + def test_handling_blacklisted_command(self): + result = self.dispatcher.handle_request('disabled') + self.assertEqual(result[0], 'ACK [0@0] {disabled} "disabled" has been ' + 'disabled in the server') From 52814715b453c1845b71bb52ac9bebcf18f2146e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 20:57:22 +0100 Subject: [PATCH 299/495] mpd: Fix review comments for commands blacklist --- docs/changelog.rst | 2 +- docs/ext/mpd.rst | 4 ++-- mopidy/mpd/exceptions.py | 1 + mopidy/utils/deprecation.py | 1 - 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f403b269..e1082b09 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -85,7 +85,7 @@ v0.20.0 (UNRELEASED) :issue:`934`, PR: :issue:`968`) - Add support for blacklisting MPD commands. This is used to prevent clients - from using `listall` and `listallinfo` which recursively lookup the entire + from using ``listall`` and ``listallinfo`` which recursively lookup the entire "database". If you insist on using a client that needs these commands change :confval:`mpd/command_blacklist`. diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index 4f85e88f..b02226a2 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -103,6 +103,6 @@ See :ref:`config` for general help on configuring Mopidy. .. confval:: mpd/command_blacklist List of MPD commands which are disabled by the server. By default this - setting blacklists `listall` and `listallinfo`. These commands don't fit - well with many of Mopidy's backends and are better left disabled unless + setting blacklists ``listall`` and ``listallinfo``. These commands don't + fit well with many of Mopidy's backends and are better left disabled unless you know what you are doing. diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index f62b61da..62e16ec3 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -90,6 +90,7 @@ class MpdNotImplemented(MpdAckError): class MpdDisabled(MpdAckError): + # NOTE: this is a custom error for mopidy that does not exists in MPD. error_code = 0 def __init__(self, *args, **kwargs): diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index 06b0fc7c..1b744702 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -5,7 +5,6 @@ import warnings def _is_pykka_proxy_creation(): - return False stack = inspect.stack() try: calling_frame = stack[3] From 88c978bdca70d6e20f27ff515a6b673bef4a3c4c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 21:13:25 +0100 Subject: [PATCH 300/495] backend: Add a default get_images impl. --- mopidy/backend.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 3dc3a28c..70591b3e 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import copy -from mopidy import listener +from mopidy import listener, models class Backend(object): @@ -97,8 +97,19 @@ class LibraryProvider(object): See :meth:`mopidy.core.LibraryController.get_images`. *MAY be implemented by subclass.* + + Default implementation will simply call lookup and try and use the + album art for any tracks returned. Most extensions should replace this + with something smarter or simply return an empty dictionary. """ - return {} + result = {} + for uri in uris: + for track in self.lookup(uri): + if track.album and track.album.images: + for image_uri in track.album.images: + image = models.Image(uri=image_uri) + result.setdefault(uri, []).append(image) + return result # TODO: replace with search(query, exact=True, ...) def find_exact(self, query=None, uris=None): From 19b7daed325980cdfbd5e2cbf3a0b4946a69081c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 21:14:24 +0100 Subject: [PATCH 301/495] mpd: Fix typos in previous commit. --- mopidy/mpd/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index 62e16ec3..6fc925a3 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -90,7 +90,7 @@ class MpdNotImplemented(MpdAckError): class MpdDisabled(MpdAckError): - # NOTE: this is a custom error for mopidy that does not exists in MPD. + # NOTE: This is a custom error for Mopidy that does not exist in MPD. error_code = 0 def __init__(self, *args, **kwargs): From 2ff2a3719e3d9b356e397f6611b07a21a2fea52e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 21:55:39 +0100 Subject: [PATCH 302/495] backend: Add test for get_images fallback --- tests/backend/test_backend.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/backend/test_backend.py diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py new file mode 100644 index 00000000..7c939132 --- /dev/null +++ b/tests/backend/test_backend.py @@ -0,0 +1,19 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +from mopidy import models + +from tests import dummy_backend + + +class LibraryTest(unittest.TestCase): + def test_default_get_images_impl_falls_back_to_album_image(self): + album = models.Album(images=['imageuri']) + track = models.Track(uri='trackuri', album=album) + + library = dummy_backend.DummyLibraryProvider(backend=None) + library.dummy_library.append(track) + + expected = {'trackuri': [models.Image(uri='imageuri')]} + self.assertEqual(library.get_images(['trackuri']), expected) From 7520b13aa11ee4a0b98a08d49fbce87f0ff35ba5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 22:35:49 +0100 Subject: [PATCH 303/495] mpd: Update listall/info docs --- mopidy/mpd/protocol/music_db.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index c143df31..f08e51f2 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -359,6 +359,13 @@ def listall(context, uri=None): ``listall [URI]`` Lists all songs and directories in ``URI``. + + Do not use this command. Do not manage a client-side copy of MPD's + database. That is fragile and adds huge overhead. It will break with + large databases. Instead, query MPD whenever you need something. + + + .. warning:: This command is disabled by default in Mopidy installs. """ result = [] for path, track_ref in context.browse(uri, lookup=False): @@ -381,6 +388,13 @@ def listallinfo(context, uri=None): Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. + + Do not use this command. Do not manage a client-side copy of MPD's + database. That is fragile and adds huge overhead. It will break with + large databases. Instead, query MPD whenever you need something. + + + .. warning:: This command is disabled by default in Mopidy installs. """ result = [] for path, lookup_future in context.browse(uri): From 2ae68d971a56b06c47e894d6496ae175f6a26282 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Wed, 25 Feb 2015 17:35:06 +0100 Subject: [PATCH 304/495] Fix #998: Remove event already sent by PlaylistsController. --- mopidy/local/playlists.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py index deeae2b5..2c53d91a 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/local/playlists.py @@ -57,8 +57,6 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): playlists.append(playlist) self.playlists = playlists - # TODO: send what scheme we loaded them for? - backend.BackendListener.send('playlists_loaded') logger.info( 'Loaded %d local playlists from %s', From 96a3cb6ef5918ee8e6292f2bda4418c41f130c54 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Wed, 25 Feb 2015 17:48:41 +0100 Subject: [PATCH 305/495] Remove obsolete unit test. --- tests/local/test_events.py | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 tests/local/test_events.py diff --git a/tests/local/test_events.py b/tests/local/test_events.py deleted file mode 100644 index 945347df..00000000 --- a/tests/local/test_events.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import unittest - -import mock - -import pykka - -from mopidy import backend, core -from mopidy.local import actor - -from tests import dummy_audio, path_to_data_dir - - -@mock.patch.object(backend.BackendListener, 'send') -class LocalBackendEventsTest(unittest.TestCase): - config = { - 'local': { - 'media_dir': path_to_data_dir(''), - 'data_dir': path_to_data_dir(''), - 'playlists_dir': b'', - 'library': 'json', - } - } - - def setUp(self): # noqa: N802 - self.audio = dummy_audio.create_proxy() - self.backend = actor.LocalBackend.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() - - def tearDown(self): # noqa: N802 - pykka.ActorRegistry.stop_all() - - def test_playlists_refresh_sends_playlists_loaded_event(self, send): - send.reset_mock() - self.core.playlists.refresh().get() - self.assertEqual(send.call_args[0][0], 'playlists_loaded') From dd54fdb0868e9ae3ce47b436d4422132b85efb88 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Mon, 23 Feb 2015 08:20:16 +0100 Subject: [PATCH 306/495] Fix #937: Local playlists refactoring. --- mopidy/local/playlists.py | 107 +++++++++++++++++-------------- mopidy/local/translator.py | 16 ++++- tests/local/test_playlists.py | 114 ++++++++++++++++++++++------------ 3 files changed, 151 insertions(+), 86 deletions(-) diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py index deeae2b5..10a97b39 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/local/playlists.py @@ -3,15 +3,14 @@ from __future__ import absolute_import, division, unicode_literals import glob import logging import os -import shutil +import sys from mopidy import backend from mopidy.models import Playlist -from mopidy.utils import formatting, path +from .translator import local_playlist_uri_to_path, path_to_local_playlist_uri from .translator import parse_m3u - logger = logging.getLogger(__name__) @@ -23,18 +22,27 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self.refresh() def create(self, name): - name = formatting.slugify(name) - uri = 'local:playlist:%s.m3u' % name - playlist = Playlist(uri=uri, name=name) - return self.save(playlist) + playlist = self._save_m3u(Playlist(name=name)) + old_playlist = self.lookup(playlist.uri) + if old_playlist is not None: + index = self._playlists.index(old_playlist) + self._playlists[index] = playlist + else: + self._playlists.append(playlist) + logger.info('Created playlist %s', playlist.uri) + return playlist def delete(self, uri): playlist = self.lookup(uri) if not playlist: + logger.warn('Trying to delete unknown playlist %s', uri) return - + path = local_playlist_uri_to_path(uri, self._playlists_dir) + if os.path.exists(path): + os.remove(path) + else: + logger.warn('Trying to delete missing playlist file %s', path) self._playlists.remove(playlist) - self._delete_m3u(playlist.uri) def lookup(self, uri): # TODO: store as {uri: playlist}? @@ -45,12 +53,14 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): def refresh(self): playlists = [] - for m3u in glob.glob(os.path.join(self._playlists_dir, '*.m3u')): - name = os.path.splitext(os.path.basename(m3u))[0] - uri = 'local:playlist:%s' % name + encoding = sys.getfilesystemencoding() + for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')): + relpath = os.path.basename(path) + name = os.path.splitext(relpath)[0].decode(encoding) + uri = path_to_local_playlist_uri(relpath) tracks = [] - for track in parse_m3u(m3u, self._media_dir): + for track in parse_m3u(path, self._media_dir): tracks.append(track) playlist = Playlist(uri=uri, name=name, tracks=tracks) @@ -67,38 +77,53 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' - old_playlist = self.lookup(playlist.uri) + uri = playlist.uri + # TODO: require existing (created) playlist - currently, this + # is a *should* in https://docs.mopidy.com/en/latest/api/core/ + try: + index = self._playlists.index(self.lookup(uri)) + except ValueError: + logger.warn('Saving playlist with new URI %s', uri) + index = -1 - if old_playlist and playlist.name != old_playlist.name: - playlist = playlist.copy(name=formatting.slugify(playlist.name)) - playlist = self._rename_m3u(playlist) - - self._save_m3u(playlist) - - if old_playlist is not None: - index = self._playlists.index(old_playlist) + playlist = self._save_m3u(playlist) + if index >= 0 and uri != playlist.uri: + path = local_playlist_uri_to_path(uri, self._playlists_dir) + if os.path.exists(path): + os.remove(path) + else: + logger.warn('Trying to delete missing playlist file %s', path) + if index >= 0: self._playlists[index] = playlist else: self._playlists.append(playlist) - return playlist - def _m3u_uri_to_path(self, uri): - # TODO: create uri handling helpers for local uri types. - file_path = path.uri_to_path(uri).split(':', 1)[1] - file_path = os.path.join(self._playlists_dir, file_path) - path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) - return file_path - def _write_m3u_extinf(self, file_handle, track): title = track.name.encode('latin-1', 'replace') runtime = track.length // 1000 if track.length else -1 file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n') - def _save_m3u(self, playlist): - file_path = self._m3u_uri_to_path(playlist.uri) + def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()): + name = name.encode(encoding, errors='replace') + name = os.path.basename(name) + name = name.decode(encoding) + return name + + def _save_m3u(self, playlist, encoding=sys.getfilesystemencoding()): + if playlist.name: + name = self._sanitize_m3u_name(playlist.name, encoding) + uri = path_to_local_playlist_uri(name.encode(encoding) + b'.m3u') + path = local_playlist_uri_to_path(uri, self._playlists_dir) + elif playlist.uri: + uri = playlist.uri + path = local_playlist_uri_to_path(uri, self._playlists_dir) + name, _ = os.path.splitext(os.path.basename(path).decode(encoding)) + else: + raise ValueError('M3U playlist needs name or URI') extended = any(track.name for track in playlist.tracks) - with open(file_path, 'w') as file_handle: + + with open(path, 'w') as file_handle: if extended: file_handle.write('#EXTM3U\n') for track in playlist.tracks: @@ -106,17 +131,5 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self._write_m3u_extinf(file_handle, track) file_handle.write(track.uri + '\n') - def _delete_m3u(self, uri): - file_path = self._m3u_uri_to_path(uri) - if os.path.exists(file_path): - os.remove(file_path) - - def _rename_m3u(self, playlist): - dst_name = formatting.slugify(playlist.name) - dst_uri = 'local:playlist:%s.m3u' % dst_name - - src_file_path = self._m3u_uri_to_path(playlist.uri) - dst_file_path = self._m3u_uri_to_path(dst_uri) - - shutil.move(src_file_path, dst_file_path) - return playlist.copy(uri=dst_uri) + # assert playlist name matches file name/uri + return playlist.copy(uri=uri, name=name) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index ab9fc28f..d0c19c27 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -37,8 +37,15 @@ def local_track_uri_to_path(uri, media_dir): return os.path.join(media_dir, file_path) +def local_playlist_uri_to_path(uri, playlists_dir): + if not uri.startswith('local:playlist:'): + raise ValueError('Invalid URI %s' % uri) + file_path = uri_to_path(uri).split(b':', 1)[1] + return os.path.join(playlists_dir, file_path) + + def path_to_local_track_uri(relpath): - """Convert path releative to media_dir to local track URI.""" + """Convert path relative to media_dir to local track URI.""" if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') return b'local:track:%s' % urllib.quote(relpath) @@ -51,6 +58,13 @@ def path_to_local_directory_uri(relpath): return b'local:directory:%s' % urllib.quote(relpath) +def path_to_local_playlist_uri(relpath): + """Convert path relative to playlists_dir to local playlist URI.""" + if isinstance(relpath, compat.text_type): + relpath = relpath.encode('utf-8') + return b'local:playlist:%s' % urllib.quote(relpath) + + def m3u_extinf_to_track(line): """Convert extended M3U directive to track template.""" m = M3U_EXTINF_RE.match(line) diff --git a/tests/local/test_playlists.py b/tests/local/test_playlists.py index 3e9c280e..d52fed82 100644 --- a/tests/local/test_playlists.py +++ b/tests/local/test_playlists.py @@ -9,6 +9,7 @@ import pykka from mopidy import core from mopidy.local import actor +from mopidy.local.translator import local_playlist_uri_to_path from mopidy.models import Playlist, Track from tests import dummy_audio, path_to_data_dir @@ -41,49 +42,50 @@ class LocalPlaylistsProviderTest(unittest.TestCase): shutil.rmtree(self.playlists_dir) def test_created_playlist_is_persisted(self): - path = os.path.join(self.playlists_dir, 'test.m3u') + uri = 'local:playlist:test.m3u' + path = local_playlist_uri_to_path(uri, self.playlists_dir) self.assertFalse(os.path.exists(path)) - self.core.playlists.create('test') + playlist = self.core.playlists.create('test') + self.assertEqual('test', playlist.name) + self.assertEqual(uri, playlist.uri) self.assertTrue(os.path.exists(path)) - def test_create_slugifies_playlist_name(self): - path = os.path.join(self.playlists_dir, 'test-foo-bar.m3u') - self.assertFalse(os.path.exists(path)) - - playlist = self.core.playlists.create('test FOO baR') - self.assertEqual('test-foo-bar', playlist.name) - self.assertTrue(os.path.exists(path)) - - def test_create_slugifies_names_which_tries_to_change_directory(self): - path = os.path.join(self.playlists_dir, 'test-foo-bar.m3u') - self.assertFalse(os.path.exists(path)) - + def test_create_sanitizes_playlist_name(self): playlist = self.core.playlists.create('../../test FOO baR') - self.assertEqual('test-foo-bar', playlist.name) + self.assertEqual('test FOO baR', playlist.name) + path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + self.assertEqual(self.playlists_dir, os.path.dirname(path)) self.assertTrue(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - path1 = os.path.join(self.playlists_dir, 'test1.m3u') - path2 = os.path.join(self.playlists_dir, 'test2-foo-bar.m3u') + uri1 = 'local:playlist:test1.m3u' + uri2 = 'local:playlist:test2.m3u' + + path1 = local_playlist_uri_to_path(uri1, self.playlists_dir) + path2 = local_playlist_uri_to_path(uri2, self.playlists_dir) playlist = self.core.playlists.create('test1') - + self.assertEqual('test1', playlist.name) + self.assertEqual(uri1, playlist.uri) self.assertTrue(os.path.exists(path1)) self.assertFalse(os.path.exists(path2)) - playlist = playlist.copy(name='test2 FOO baR') - playlist = self.core.playlists.save(playlist) - - self.assertEqual('test2-foo-bar', playlist.name) + playlist = self.core.playlists.save(playlist.copy(name='test2')) + self.assertEqual('test2', playlist.name) + self.assertEqual(uri2, playlist.uri) self.assertFalse(os.path.exists(path1)) self.assertTrue(os.path.exists(path2)) def test_deleted_playlist_is_removed(self): - path = os.path.join(self.playlists_dir, 'test.m3u') + uri = 'local:playlist:test.m3u' + path = local_playlist_uri_to_path(uri, self.playlists_dir) + self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('test') + self.assertEqual('test', playlist.name) + self.assertEqual(uri, playlist.uri) self.assertTrue(os.path.exists(path)) self.core.playlists.delete(playlist.uri) @@ -92,24 +94,22 @@ class LocalPlaylistsProviderTest(unittest.TestCase): def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) playlist = self.core.playlists.create('test') - playlist_path = os.path.join(self.playlists_dir, 'test.m3u') - playlist = playlist.copy(tracks=[track]) - playlist = self.core.playlists.save(playlist) + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) - with open(playlist_path) as playlist_file: - contents = playlist_file.read() + with open(path) as f: + contents = f.read() self.assertEqual(track.uri, contents.strip()) def test_extended_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1), name='Test', length=60000) playlist = self.core.playlists.create('test') - playlist_path = os.path.join(self.playlists_dir, 'test.m3u') - playlist = playlist.copy(tracks=[track]) - playlist = self.core.playlists.save(playlist) + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) - with open(playlist_path) as playlist_file: - contents = playlist_file.read().splitlines() + with open(path) as f: + contents = f.read().splitlines() self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri]) @@ -123,7 +123,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assert_(backend.playlists.playlists) self.assertEqual( - 'local:playlist:test', backend.playlists.playlists[0].uri) + playlist.uri, backend.playlists.playlists[0].uri) self.assertEqual( playlist.name, backend.playlists.playlists[0].name) self.assertEqual( @@ -154,7 +154,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assert_(not self.core.playlists.playlists) def test_delete_non_existant_playlist(self): - self.core.playlists.delete('file:///unknown/playlist') + self.core.playlists.delete('local:playlist:unknown') def test_delete_playlist_removes_it_from_the_collection(self): playlist = self.core.playlists.create('test') @@ -164,6 +164,19 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertNotIn(playlist, self.core.playlists.playlists) + def test_delete_playlist_without_file(self): + playlist = self.core.playlists.create('test') + self.assertIn(playlist, self.core.playlists.playlists) + + path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + self.assertTrue(os.path.exists(path)) + + os.remove(path) + self.assertFalse(os.path.exists(path)) + + self.core.playlists.delete(playlist.uri) + self.assertNotIn(playlist, self.core.playlists.playlists) + def test_filter_without_criteria(self): self.assertEqual( self.core.playlists.playlists, self.core.playlists.filter()) @@ -201,9 +214,13 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertEqual(original_playlist, looked_up_playlist) - @unittest.SkipTest def test_refresh(self): - pass + playlist = self.core.playlists.create('test') + self.assertIn(playlist, self.core.playlists.playlists) + + self.core.playlists.refresh() + + self.assertIn(playlist, self.core.playlists.playlists) def test_save_replaces_existing_playlist_with_updated_playlist(self): playlist1 = self.core.playlists.create('test1') @@ -214,6 +231,27 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertNotIn(playlist1, self.core.playlists.playlists) self.assertIn(playlist2, self.core.playlists.playlists) + def test_create_replaces_existing_playlist_with_updated_playlist(self): + track = Track(uri=generate_song(1)) + playlist1 = self.core.playlists.create('test') + playlist1 = self.core.playlists.save(playlist1.copy(tracks=[track])) + self.assertIn(playlist1, self.core.playlists.playlists) + + playlist2 = self.core.playlists.create('test') + self.assertEqual(playlist1.uri, playlist2.uri) + self.assertNotIn(playlist1, self.core.playlists.playlists) + self.assertIn(playlist2, self.core.playlists.playlists) + + def test_save_playlist_with_new_uri(self): + # you *should* not do this + uri = 'local:playlist:test.m3u' + playlist = self.core.playlists.save(Playlist(uri=uri)) + self.assertIn(playlist, self.core.playlists.playlists) + self.assertEqual(uri, playlist.uri) + self.assertEqual('test', playlist.name) + path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + self.assertTrue(os.path.exists(path)) + def test_playlist_with_unknown_track(self): track = Track(uri='file:///dev/null') playlist = self.core.playlists.create('test') @@ -224,7 +262,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assert_(backend.playlists.playlists) self.assertEqual( - 'local:playlist:test', backend.playlists.playlists[0].uri) + 'local:playlist:test.m3u', backend.playlists.playlists[0].uri) self.assertEqual( playlist.name, backend.playlists.playlists[0].name) self.assertEqual( From 0ea39694272141158c1ae492b968f6cd69cabbff Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 21:02:57 +0100 Subject: [PATCH 307/495] config: Debug log ignored sections (fixes: #694) --- mopidy/config/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 885ea3a6..24b4f279 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -175,6 +175,7 @@ def _validate(raw_config, schemas): # Get validated config config = {} errors = {} + sections = set(raw_config) for schema in schemas: values = raw_config.get(schema.name, {}) result, error = schema.deserialize(values) @@ -182,6 +183,12 @@ def _validate(raw_config, schemas): errors[schema.name] = error if result: config[schema.name] = result + if schema.name in sections: + sections.remove(schema.name) + + for section in sections: + logger.debug('Ignoring unknown config section: %s', section) + return config, errors From b11d89d72fc3f69028d3a1499753d1853f4f8660 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 21:28:05 +0100 Subject: [PATCH 308/495] config: Convert the loglevel schema to a generic map schema --- mopidy/config/__init__.py | 2 +- mopidy/config/schemas.py | 19 +++++++++---------- tests/config/test_schemas.py | 4 ++-- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 885ea3a6..a6cb9d94 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -22,7 +22,7 @@ _logging_schema['debug_format'] = String() _logging_schema['debug_file'] = Path() _logging_schema['config_file'] = Path(optional=True) -_loglevels_schema = LogLevelConfigSchema('loglevels') +_loglevels_schema = MapConfigSchema('loglevels', LogLevel()) _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 56826a53..f1b3a8c1 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -94,17 +94,16 @@ class ConfigSchema(collections.OrderedDict): return result -class LogLevelConfigSchema(object): - """Special cased schema for handling a config section with loglevels. +class MapConfigSchema(object): + """Special cased schema for handling mulitple keys with the same type. - Expects the config keys to be logger names and the values to be log levels - as understood by the :class:`LogLevel` config value. Does not sub-class - :class:`ConfigSchema`, but implements the same serialize/deserialize - interface. + Does not sub-class :class:`ConfigSchema`, but implements the same + serialize/deserialize interface. """ - def __init__(self, name): + + def __init__(self, name, value_type): self.name = name - self._config_value = types.LogLevel() + self._value_type = value_type def deserialize(self, values): errors = {} @@ -112,7 +111,7 @@ class LogLevelConfigSchema(object): for key, value in values.items(): try: - result[key] = self._config_value.deserialize(value) + result[key] = self._value_type.deserialize(value) except ValueError as e: # deserialization failed result[key] = None errors[key] = str(e) @@ -121,5 +120,5 @@ class LogLevelConfigSchema(object): def serialize(self, values, display=False): result = collections.OrderedDict() for key in sorted(values.keys()): - result[key] = self._config_value.serialize(values[key], display) + result[key] = self._value_type.serialize(values[key], display) return result diff --git a/tests/config/test_schemas.py b/tests/config/test_schemas.py index 8412b899..502bf61c 100644 --- a/tests/config/test_schemas.py +++ b/tests/config/test_schemas.py @@ -86,9 +86,9 @@ class ConfigSchemaTest(unittest.TestCase): self.assertNotIn('foo', errors) -class LogLevelConfigSchemaTest(unittest.TestCase): +class MapConfigSchemaTest(unittest.TestCase): def test_conversion(self): - schema = schemas.LogLevelConfigSchema('test') + schema = schemas.MapConfigSchema('test', types.LogLevel()) result, errors = schema.deserialize( {'foo.bar': 'DEBUG', 'baz': 'INFO'}) From 5c833e106bf741825e4a6b0ed9922ce6b7215898 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 22:16:30 +0100 Subject: [PATCH 309/495] logging: Add support for per logger colors (fixes: #808) --- docs/changelog.rst | 2 ++ docs/config.rst | 8 ++++++ mopidy/config/__init__.py | 4 ++- mopidy/config/types.py | 13 ++++++++- mopidy/utils/log.py | 55 +++++++++++++++++++++------------------ 5 files changed, 55 insertions(+), 27 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e1082b09..7b3e97b0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -53,6 +53,8 @@ v0.20.0 (UNRELEASED) - Add custom log level ``TRACE`` (numerical level 5), which can be used by Mopidy and extensions to log at an even more detailed level than ``DEBUG``. +- Add support for per logger color overrides. (Fixes: :issue:`808`) + **Local backend** - Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: diff --git a/docs/config.rst b/docs/config.rst index 03bb83ac..82556dc8 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -128,6 +128,14 @@ Logging configuration The ``loglevels`` config section can be used to change the log level for specific parts of Mopidy during development or debugging. Each key in the config section should match the name of a logger. The value is the log + level to use for that logger, one of ``black``, ``red``, ``green``, + ``yellow``, ``blue``, ``magenta``, ``cyan`` or ``white``. + +.. confval:: logcolors/* + + The ``logcolors`` config section can be used to change the log color for + specific parts of Mopidy during development or debugging. Each key in the + config section should match the name of a logger. The value is the log level to use for that logger, one of ``debug``, ``info``, ``warning``, ``error``, or ``critical``. diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index a6cb9d94..7c4f8755 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -23,6 +23,7 @@ _logging_schema['debug_file'] = Path() _logging_schema['config_file'] = Path(optional=True) _loglevels_schema = MapConfigSchema('loglevels', LogLevel()) +_logcolors_schema = MapConfigSchema('logcolors', LogColor()) _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() @@ -42,7 +43,8 @@ _proxy_schema['password'] = Secret(optional=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema # _outputs_schema = config.AudioOutputConfigSchema() -_schemas = [_logging_schema, _loglevels_schema, _audio_schema, _proxy_schema] +_schemas = [_logging_schema, _loglevels_schema, _logcolors_schema, + _audio_schema, _proxy_schema] _INITIAL_HELP = """ # For further information about options in this file see: diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 785ec55a..d074458b 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -6,7 +6,7 @@ import socket from mopidy import compat from mopidy.config import validators -from mopidy.utils import path +from mopidy.utils import log, path def decode(value): @@ -197,6 +197,17 @@ class List(ConfigValue): return b'\n ' + b'\n '.join(encode(v) for v in value if v) +class LogColor(ConfigValue): + def deserialize(self, value): + validators.validate_choice(value.lower(), log.COLORS) + return value.lower() + + def serialize(self, value, display=False): + if value.lower() in log.COLORS: + return value.lower() + return b'' + + class LogLevel(ConfigValue): """Log level value. diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 3c7ee599..6343a866 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -82,7 +82,7 @@ def setup_console_logging(config, verbosity_level): formatter = logging.Formatter(log_format) if config['logging']['color']: - handler = ColorizingStreamHandler() + handler = ColorizingStreamHandler(config.get('logcolors', {})) else: handler = logging.StreamHandler() handler.addFilter(verbosity_filter) @@ -117,6 +117,11 @@ class VerbosityFilter(logging.Filter): return record.levelno >= required_log_level +#: Available log colors. +COLORS = [b'black', b'red', b'green', b'yellow', b'blue', b'magenta', b'cyan', + b'white'] + + class ColorizingStreamHandler(logging.StreamHandler): """ Stream handler which colorizes the log using ANSI escape sequences. @@ -130,17 +135,6 @@ class ColorizingStreamHandler(logging.StreamHandler): Licensed under the new BSD license. """ - color_map = { - 'black': 0, - 'red': 1, - 'green': 2, - 'yellow': 3, - 'blue': 4, - 'magenta': 5, - 'cyan': 6, - 'white': 7, - } - # Map logging levels to (background, foreground, bold/intense) level_map = { TRACE_LOG_LEVEL: (None, 'blue', False), @@ -150,11 +144,18 @@ class ColorizingStreamHandler(logging.StreamHandler): logging.ERROR: (None, 'red', False), logging.CRITICAL: ('red', 'white', True), } + # Map logger name to foreground colors + logger_map = {} + csi = '\x1b[' reset = '\x1b[0m' is_windows = platform.system() == 'Windows' + def __init__(self, logger_colors): + super(ColorizingStreamHandler, self).__init__() + self.logger_map = logger_colors + @property def is_tty(self): isatty = getattr(self.stream, 'isatty', None) @@ -173,19 +174,23 @@ class ColorizingStreamHandler(logging.StreamHandler): message = logging.StreamHandler.format(self, record) if not self.is_tty or self.is_windows: return message - return self.colorize(message, record) - - def colorize(self, message, record): + for name, color in self.logger_map.iteritems(): + if record.name.startswith(name): + return self.colorize(message, fg=color) if record.levelno in self.level_map: bg, fg, bold = self.level_map[record.levelno] - params = [] - if bg in self.color_map: - params.append(str(self.color_map[bg] + 40)) - if fg in self.color_map: - params.append(str(self.color_map[fg] + 30)) - if bold: - params.append('1') - if params: - message = ''.join(( - self.csi, ';'.join(params), 'm', message, self.reset)) + return self.colorize(message, bg=bg, fg=fg, bold=bold) + return message + + def colorize(self, message, bg=None, fg=None, bold=False): + params = [] + if bg in COLORS: + params.append(str(COLORS.index(bg) + 40)) + if fg in COLORS: + params.append(str(COLORS.index(fg) + 30)) + if bold: + params.append('1') + if params: + message = ''.join(( + self.csi, ';'.join(params), 'm', message, self.reset)) return message From 3b41b268809a8e92ee38e2077eb91f43458ab4be Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 22:57:49 +0100 Subject: [PATCH 310/495] config: Fix review comments --- docs/config.rst | 10 +++++----- mopidy/config/schemas.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 82556dc8..69945ab8 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -128,16 +128,16 @@ Logging configuration The ``loglevels`` config section can be used to change the log level for specific parts of Mopidy during development or debugging. Each key in the config section should match the name of a logger. The value is the log - level to use for that logger, one of ``black``, ``red``, ``green``, - ``yellow``, ``blue``, ``magenta``, ``cyan`` or ``white``. + level to use for that logger, one of ``debug``, ``info``, ``warning``, + ``error``, or ``critical``. .. confval:: logcolors/* The ``logcolors`` config section can be used to change the log color for specific parts of Mopidy during development or debugging. Each key in the - config section should match the name of a logger. The value is the log - level to use for that logger, one of ``debug``, ``info``, ``warning``, - ``error``, or ``critical``. + config section should match the name of a logger. The value is the color + to use for that logger, one of ``black``, ``red``, ``green``, ``yellow``, + ``blue``, ``magenta``, ``cyan`` or ``white``. .. _the Python logging docs: http://docs.python.org/2/library/logging.config.html diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index f1b3a8c1..2b055663 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -95,7 +95,7 @@ class ConfigSchema(collections.OrderedDict): class MapConfigSchema(object): - """Special cased schema for handling mulitple keys with the same type. + """Schema for handling multiple unknown keys with the same type. Does not sub-class :class:`ConfigSchema`, but implements the same serialize/deserialize interface. From 57012670b71ea73c26177ba13a94ba004d82baaa Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 22:58:46 +0100 Subject: [PATCH 311/495] config: Fixing review comments --- mopidy/config/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 24b4f279..434831e4 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -177,14 +177,13 @@ def _validate(raw_config, schemas): errors = {} sections = set(raw_config) for schema in schemas: + sections.discard(schema.name) values = raw_config.get(schema.name, {}) result, error = schema.deserialize(values) if error: errors[schema.name] = error if result: config[schema.name] = result - if schema.name in sections: - sections.remove(schema.name) for section in sections: logger.debug('Ignoring unknown config section: %s', section) From 4a3dfdd415a3c262d170ab97e2da3a7314bd08b1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 23:28:56 +0100 Subject: [PATCH 312/495] docs: Update changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index e1082b09..43d16723 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -48,6 +48,8 @@ v0.20.0 (UNRELEASED) This can be used to show absolutely all log records, including those at custom levels below ``DEBUG``. +- Add debug logging of unknown sections. (Fixes: :issue:`694`) + **Logging** - Add custom log level ``TRACE`` (numerical level 5), which can be used by From 961aafff45a1b94990f9dcb4bae45196fa1a0b71 Mon Sep 17 00:00:00 2001 From: ronaldz Date: Wed, 25 Feb 2015 21:19:47 -0500 Subject: [PATCH 313/495] Maximum was miss-spelled in the local Scan command's help text --- mopidy/local/commands.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index a9920ec8..ba34b22b 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -61,8 +61,7 @@ class ScanCommand(commands.Command): super(ScanCommand, self).__init__() self.add_argument('--limit', action='store', type=int, dest='limit', default=None, - help='Maxmimum number of tracks to scan') - + help='Maximum number of tracks to scan') def run(self, args, config): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] @@ -121,7 +120,9 @@ class ScanCommand(commands.Command): logger.info('Scanning...') uris_to_update = sorted(uris_to_update, key=lambda v: v.lower()) + print("Before: ", uris_to_update) uris_to_update = uris_to_update[:args.limit] + print("After: ", uris_to_update) scanner = scan.Scanner(scan_timeout) progress = _Progress(flush_threshold, len(uris_to_update)) From 87ea3c974557c484b4831084167f2c00c45e8a13 Mon Sep 17 00:00:00 2001 From: ronaldz Date: Wed, 25 Feb 2015 21:36:02 -0500 Subject: [PATCH 314/495] Added a --force argument. Related to issue #910 --- mopidy/local/commands.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index ba34b22b..80ce5c1d 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -62,6 +62,9 @@ class ScanCommand(commands.Command): self.add_argument('--limit', action='store', type=int, dest='limit', default=None, help='Maximum number of tracks to scan') + self.add_argument('--force', + action='store_true', dest='force', default=False, + help='Force rescan of all media files') def run(self, args, config): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] @@ -95,8 +98,8 @@ class ScanCommand(commands.Command): mtime = file_mtimes.get(abspath) if mtime is None: logger.debug('Missing file %s', track.uri) - uris_to_remove.add(track.uri) - elif mtime > track.last_modified: + uris_to_remove.add(track.uri) + elif mtime > track.last_modified or args.force: uris_to_update.add(track.uri) uris_in_library.add(track.uri) @@ -120,9 +123,7 @@ class ScanCommand(commands.Command): logger.info('Scanning...') uris_to_update = sorted(uris_to_update, key=lambda v: v.lower()) - print("Before: ", uris_to_update) uris_to_update = uris_to_update[:args.limit] - print("After: ", uris_to_update) scanner = scan.Scanner(scan_timeout) progress = _Progress(flush_threshold, len(uris_to_update)) From 301f7320471da715853505baf2d7d89a204b0561 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 27 Feb 2015 22:22:28 +0100 Subject: [PATCH 315/495] Improve default get_images() implementation with album/artist URIs. --- mopidy/backend.py | 6 +++--- tests/backend/test_backend.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 70591b3e..fca01eb0 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -104,11 +104,11 @@ class LibraryProvider(object): """ result = {} for uri in uris: + image_uris = set() for track in self.lookup(uri): if track.album and track.album.images: - for image_uri in track.album.images: - image = models.Image(uri=image_uri) - result.setdefault(uri, []).append(image) + image_uris.update(track.album.images) + result[uri] = list(map(lambda u: models.Image(uri=u), image_uris)) return result # TODO: replace with search(query, exact=True, ...) diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index 7c939132..7c6cc82b 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -17,3 +17,14 @@ class LibraryTest(unittest.TestCase): expected = {'trackuri': [models.Image(uri='imageuri')]} self.assertEqual(library.get_images(['trackuri']), expected) + + def test_default_get_images_impl_no_album_image(self): + # default implementation now returns an empty list if no + # images are found, though it's not required to + track = models.Track(uri='trackuri') + + library = dummy_backend.DummyLibraryProvider(backend=None) + library.dummy_library.append(track) + + expected = {'trackuri': []} + self.assertEqual(library.get_images(['trackuri']), expected) From f65195a6769b3898e3bad81d16021e5920ab14be Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 27 Feb 2015 22:39:25 +0100 Subject: [PATCH 316/495] More pythonic implementation. --- mopidy/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index fca01eb0..c713d083 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -108,7 +108,7 @@ class LibraryProvider(object): for track in self.lookup(uri): if track.album and track.album.images: image_uris.update(track.album.images) - result[uri] = list(map(lambda u: models.Image(uri=u), image_uris)) + result[uri] = [models.Image(uri=u) for u in image_uris] return result # TODO: replace with search(query, exact=True, ...) From fbd534efbfe8d4f1cb7f446e0ac6b3e62a045250 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 1 Mar 2015 15:19:12 +0100 Subject: [PATCH 317/495] Don't change to playing state when seeking in paused state Do not switch state from paused to playing when seeking --- docs/changelog.rst | 3 +++ mopidy/core/playback.py | 2 -- tests/core/test_playback.py | 33 +++++++++++++++++++++++++++++++++ tests/local/test_playback.py | 8 +------- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e6e3bcf4..b316df49 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -33,6 +33,9 @@ v0.20.0 (UNRELEASED) - Add :meth:`mopidy.core.LibraryController.get_images` for looking up images for any URI backends know about. (Fixes :issue:`973`) +- When seeking in paused state, do not change to playing state. (Fixed + :issue:`939`) + **Commands** - Make the ``mopidy`` command print a friendly error message if the diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d4cdce0d..0d604d61 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -371,8 +371,6 @@ class PlaybackController(object): if self.get_state() == PlaybackState.STOPPED: self.play() - elif self.get_state() == PlaybackState.PAUSED: - self.resume() if time_position < 0: time_position = 0 diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 40741e23..11d63e04 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -85,6 +85,25 @@ class CorePlaybackTest(unittest.TestCase): 'track_playback_started', tl_track=self.tl_tracks[0]), ]) + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_play_when_paused_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.pause() + listener_mock.reset_mock() + + self.core.playback.play(self.tl_tracks[1]) + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='paused', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[1]), + ]) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_play_when_playing_emits_events(self, listener_mock): @@ -389,6 +408,20 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.seek.called) self.assertFalse(self.playback2.seek.called) + def test_seek_play_stay_playing(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.state = core.PlaybackState.PLAYING + self.core.playback.seek(1000) + + self.assertEqual(self.core.playback.state, core.PlaybackState.PLAYING) + + def test_seek_paused_stay_paused(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.state = core.PlaybackState.PAUSED + self.core.playback.seek(1000) + + self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_seek_emits_seeked_event(self, listener_mock): diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 5f1ff525..3ccd8d8f 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -798,6 +798,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.playback.pause() result = self.playback.seek(self.tracks[0].length - 1000) self.assert_(result, 'Seek return value was %s' % result) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_tracklist def test_seek_when_paused_updates_position(self): @@ -808,13 +809,6 @@ class LocalPlaybackProviderTest(unittest.TestCase): position = self.playback.time_position self.assertGreaterEqual(position, length - 1010) - @populate_tracklist - def test_seek_when_paused_triggers_play(self): - self.playback.play() - self.playback.pause() - self.playback.seek(0) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - @unittest.SkipTest @populate_tracklist def test_seek_beyond_end_of_song(self): From b2976dccb6a0368f9c682ed6d0439edf68e35883 Mon Sep 17 00:00:00 2001 From: ronaldz Date: Sun, 1 Mar 2015 14:40:08 -0500 Subject: [PATCH 318/495] Trailing white space and expected blank line fix --- mopidy/local/commands.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 80ce5c1d..79c1c505 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -62,9 +62,10 @@ class ScanCommand(commands.Command): self.add_argument('--limit', action='store', type=int, dest='limit', default=None, help='Maximum number of tracks to scan') - self.add_argument('--force', - action='store_true', dest='force', default=False, + self.add_argument('--force', + action='store_true', dest='force', default=False, help='Force rescan of all media files') + def run(self, args, config): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] @@ -98,7 +99,7 @@ class ScanCommand(commands.Command): mtime = file_mtimes.get(abspath) if mtime is None: logger.debug('Missing file %s', track.uri) - uris_to_remove.add(track.uri) + uris_to_remove.add(track.uri) elif mtime > track.last_modified or args.force: uris_to_update.add(track.uri) uris_in_library.add(track.uri) From 713c55321f12b57f097ef38ce50be538ce6dbfb1 Mon Sep 17 00:00:00 2001 From: ronaldz Date: Sun, 1 Mar 2015 14:56:38 -0500 Subject: [PATCH 319/495] Updated the changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index e6e3bcf4..713ba215 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -70,6 +70,8 @@ v0.20.0 (UNRELEASED) - Add support for giving local libraries direct access to tags and duration. (Fixes: :issue:`967`) +- Add "--force" option for local scan (Fixes: :issue:'910') (PR: :issue:'1010') + **File scanner** - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) From ffeb78c2cb3d2bf394c63f5121184bdd7911317d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 22:29:22 +0100 Subject: [PATCH 320/495] Only lint mopidy and tests dir --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 7b5692e3..977a4b9e 100644 --- a/tasks.py +++ b/tasks.py @@ -28,7 +28,7 @@ def test(path=None, coverage=False, watch=False, warn=False): def lint(watch=False, warn=False): if watch: return watcher(lint) - run('flake8', warn=warn) + run('flake8 mopidy tests', warn=warn) @task From aeb4815fb6ca93f69c3516753c7cdffbeba0d443 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 23:58:11 +0100 Subject: [PATCH 321/495] Update lint task and gitignore to exlude tmp/ --- .gitignore | 1 + tasks.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0edb30e0..990d75ca 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ docs/_build/ mopidy.log* nosetests.xml xunit-*.xml +tmp/ diff --git a/tasks.py b/tasks.py index 977a4b9e..03249481 100644 --- a/tasks.py +++ b/tasks.py @@ -28,7 +28,7 @@ def test(path=None, coverage=False, watch=False, warn=False): def lint(watch=False, warn=False): if watch: return watcher(lint) - run('flake8 mopidy tests', warn=warn) + run('flake8 --exclude=tmp,.git,__pycache__', warn=warn) @task From 4ee7dd73bd78b068ccf1d87de15c24e3988d9cc5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 17:07:50 +0100 Subject: [PATCH 322/495] http: Make WS broadcast more robust against disconnect race Adds some WebSocketHandler tests that actually connect using a WS client and plugs a potential race condition. Any call to write_message could fail, either due to WebSocketClosedError like in the log below, or simply due to socket errors. To play it safe we catch all errors and debug log that a broadcast failed. 2015-02-26 21:24:02,266 ERROR [HttpServer] /home/adamcik/dev/mopidy/mopidy/http/handlers.py:116 mopidy.http.handlers WebSocket request error: deque index out of range 2015-02-26 21:24:10,098 ERROR [HttpFrontend-11] build/bdist.linux-x86_64/egg/pykka/actor.py:268 pykka Unhandled exception in HttpFrontend (urn:uuid:e376bd95-c32e-4e17-ad20-7d0b3c0cf2b2): Traceback (most recent call last): File "build/bdist.linux-x86_64/egg/pykka/actor.py", line 200, in _actor_loop response = self._handle_receive(message) File "build/bdist.linux-x86_64/egg/pykka/actor.py", line 294, in _handle_receive return callee(*message['args'], **message['kwargs']) File ".../dev/mopidy/mopidy/http/actor.py", line 77, in on_event on_event(name, **data) File ".../dev/mopidy/mopidy/http/actor.py", line 84, in on_event handlers.WebSocketHandler.broadcast(message) File ".../dev/mopidy/mopidy/http/handlers.py", line 78, in broadcast client.write_message(msg) File ".../dev/mopidy-virtualenv/local/lib/python2.7/site-packages/tornado/websocket.py", line 183, in write_message raise WebSocketClosedError() WebSocketClosedError --- mopidy/http/handlers.py | 10 +++++++- tests/http/test_handlers.py | 47 +++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 52bd8217..561c34b3 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -75,7 +75,15 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): @classmethod def broadcast(cls, msg): for client in cls.clients: - client.write_message(msg) + # We could check for client.ws_connection, but we don't really + # care why the broadcast failed, we just want the rest of them + # to succeed, so catch everything. + try: + client.write_message(msg) + except Exception as e: + logger.debug('Broadcast of WebSocket message to %s failed: %s', + client.request.remote_ip, e) + # TODO: should this do the same cleanup as the on_message code? def initialize(self, core): self.jsonrpc = make_jsonrpc_wrapper(core) diff --git a/tests/http/test_handlers.py b/tests/http/test_handlers.py index 5c958d9a..5803adaf 100644 --- a/tests/http/test_handlers.py +++ b/tests/http/test_handlers.py @@ -2,8 +2,11 @@ from __future__ import absolute_import, unicode_literals import os +import mock + import tornado.testing import tornado.web +import tornado.websocket import mopidy from mopidy.http import handlers @@ -35,3 +38,47 @@ class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): response.headers['X-Mopidy-Version'], mopidy.__version__) self.assertEqual( response.headers['Cache-Control'], 'no-cache') + + +class WebSocketHandlerTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + self.core = mock.Mock() + return tornado.web.Application([ + (r'/ws/?', handlers.WebSocketHandler, {'core': self.core}) + ]) + + def connection(self): + url = self.get_url('/ws').replace('http', 'ws') + return tornado.websocket.websocket_connect(url, self.io_loop) + + @tornado.testing.gen_test + def test_invalid_json_rpc_request_doesnt_crash_handler(self): + # An uncaught error would result in no message, so this is just a + # simplistic test to verify this. + conn = yield self.connection() + conn.write_message('invalid request') + message = yield conn.read_message() + self.assertTrue(message) + + @tornado.testing.gen_test + def test_broadcast_makes_it_to_client(self): + conn = yield self.connection() + handlers.WebSocketHandler.broadcast('message') + message = yield conn.read_message() + self.assertEqual(message, 'message') + + @tornado.testing.gen_test + def test_broadcast_to_client_that_just_closed_connection(self): + conn = yield self.connection() + conn.close() + handlers.WebSocketHandler.broadcast('message') + + @tornado.testing.gen_test + def test_broadcast_to_client_without_ws_connection_present(self): + yield self.connection() + # Tornado checks for ws_connection and raises WebSocketClosedError + # if it is missing, this test case simulates winning a race were + # this has happened but we have not yet been removed from clients. + for client in handlers.WebSocketHandler.clients: + client.ws_connection = None + handlers.WebSocketHandler.broadcast('message') From 0fb6c620dfc03da2d1f4677fafd05fd620a59ed5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 17:19:43 +0100 Subject: [PATCH 323/495] docs: Add changelog entry for broadcast race --- docs/changelog.rst | 4 ++ tests/http/test_handlers.py | 76 +++++++++++++++++++------------------ 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e6e3bcf4..f832716f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -93,6 +93,10 @@ v0.20.0 (UNRELEASED) "database". If you insist on using a client that needs these commands change :confval:`mpd/command_blacklist`. +**HTTP frontend** + +- Prevent race condition in webservice broadcast from breaking the server. + **Audio** - Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a diff --git a/tests/http/test_handlers.py b/tests/http/test_handlers.py index 5803adaf..8bd82e11 100644 --- a/tests/http/test_handlers.py +++ b/tests/http/test_handlers.py @@ -40,45 +40,47 @@ class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): response.headers['Cache-Control'], 'no-cache') -class WebSocketHandlerTest(tornado.testing.AsyncHTTPTestCase): - def get_app(self): - self.core = mock.Mock() - return tornado.web.Application([ - (r'/ws/?', handlers.WebSocketHandler, {'core': self.core}) - ]) +# We aren't bothering with skipIf as then we would need to "backport" gen_test +if hasattr(tornado.websocket, 'websocket_connect'): + class WebSocketHandlerTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + self.core = mock.Mock() + return tornado.web.Application([ + (r'/ws/?', handlers.WebSocketHandler, {'core': self.core}) + ]) - def connection(self): - url = self.get_url('/ws').replace('http', 'ws') - return tornado.websocket.websocket_connect(url, self.io_loop) + def connection(self): + url = self.get_url('/ws').replace('http', 'ws') + return tornado.websocket.websocket_connect(url, self.io_loop) - @tornado.testing.gen_test - def test_invalid_json_rpc_request_doesnt_crash_handler(self): - # An uncaught error would result in no message, so this is just a - # simplistic test to verify this. - conn = yield self.connection() - conn.write_message('invalid request') - message = yield conn.read_message() - self.assertTrue(message) + @tornado.testing.gen_test + def test_invalid_json_rpc_request_doesnt_crash_handler(self): + # An uncaught error would result in no message, so this is just a + # simplistic test to verify this. + conn = yield self.connection() + conn.write_message('invalid request') + message = yield conn.read_message() + self.assertTrue(message) - @tornado.testing.gen_test - def test_broadcast_makes_it_to_client(self): - conn = yield self.connection() - handlers.WebSocketHandler.broadcast('message') - message = yield conn.read_message() - self.assertEqual(message, 'message') + @tornado.testing.gen_test + def test_broadcast_makes_it_to_client(self): + conn = yield self.connection() + handlers.WebSocketHandler.broadcast('message') + message = yield conn.read_message() + self.assertEqual(message, 'message') - @tornado.testing.gen_test - def test_broadcast_to_client_that_just_closed_connection(self): - conn = yield self.connection() - conn.close() - handlers.WebSocketHandler.broadcast('message') + @tornado.testing.gen_test + def test_broadcast_to_client_that_just_closed_connection(self): + conn = yield self.connection() + conn.stream.close() + handlers.WebSocketHandler.broadcast('message') - @tornado.testing.gen_test - def test_broadcast_to_client_without_ws_connection_present(self): - yield self.connection() - # Tornado checks for ws_connection and raises WebSocketClosedError - # if it is missing, this test case simulates winning a race were - # this has happened but we have not yet been removed from clients. - for client in handlers.WebSocketHandler.clients: - client.ws_connection = None - handlers.WebSocketHandler.broadcast('message') + @tornado.testing.gen_test + def test_broadcast_to_client_without_ws_connection_present(self): + yield self.connection() + # Tornado checks for ws_connection and raises WebSocketClosedError + # if it is missing, this test case simulates winning a race were + # this has happened but we have not yet been removed from clients. + for client in handlers.WebSocketHandler.clients: + client.ws_connection = None + handlers.WebSocketHandler.broadcast('message') From 6c5970ffc393ffcd907cf3e701271b1f4d8bd816 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 20:46:54 +0100 Subject: [PATCH 324/495] http: Make sure to decode exceptions for logging --- mopidy/http/handlers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 561c34b3..a5baf992 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -10,7 +10,7 @@ import tornado.websocket import mopidy from mopidy import core, models -from mopidy.utils import jsonrpc +from mopidy.utils import encoding, jsonrpc logger = logging.getLogger(__name__) @@ -81,8 +81,9 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): try: client.write_message(msg) except Exception as e: + error_msg = encoding.locale_decode(e) logger.debug('Broadcast of WebSocket message to %s failed: %s', - client.request.remote_ip, e) + client.request.remote_ip, error_msg) # TODO: should this do the same cleanup as the on_message code? def initialize(self, core): @@ -121,7 +122,8 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): 'Sent WebSocket message to %s: %r', self.request.remote_ip, response) except Exception as e: - logger.error('WebSocket request error: %s', e) + error_msg = encoding.locale_decode(e) + logger.error('WebSocket request error: %s', error_msg) if self.ws_connection: # Tornado 3.2+ checks if self.ws_connection is None before # using it, but not older versions. From 00b2b9538e6a1d3dc9c1b938eed603f10127bbc8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 22:46:18 +0100 Subject: [PATCH 325/495] core: Add library.list_distinct for getting distinct field values --- docs/changelog.rst | 3 +++ mopidy/backend.py | 10 ++++++++++ mopidy/core/library.py | 21 +++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 733d122c..03b25897 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -36,6 +36,9 @@ v0.20.0 (UNRELEASED) - When seeking in paused state, do not change to playing state. (Fixed :issue:`939`) +- Add :meth:`mopidy.core.LibraryController.list_distinct` for getting unique + values for a given field. (Fixes: :issue:`913`) + **Commands** - Make the ``mopidy`` command print a friendly error message if the diff --git a/mopidy/backend.py b/mopidy/backend.py index c713d083..38d4c5db 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -92,6 +92,16 @@ class LibraryProvider(object): """ return [] + def list_distinct(self, field, query=None): + """ + See :meth:`mopidy.core.LibraryController.list_distinct`. + + *MAY be implemented by subclass.* + + Default implementation will simply return an empty set. + """ + return set() + def get_images(self, uris): """ See :meth:`mopidy.core.LibraryController.get_images`. diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 822836a6..4ccfd657 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -72,6 +72,27 @@ class LibraryController(object): return [] return backend.library.browse(uri).get() + def list_distinct(self, field, query=None): + """ + List distinct values for a given field from the library. + + This has mainly been added to support the list commands the MPD + protocol supports in a more sane fashion. Other frontends are not + recommended to use this method. + + :param string field: One of ``artist``, ``albumartist``, ``album``, + ``composer``, ``performer``, ``date``or ``genre``. + :param dict query: Query to use for limiting results, see + :method:`search` for details about the query format. + :rtype: set of values corresponding to the requested field type. + """ + futures = [b.library.list_distinct(field, query) + for b in self.backends.with_library.values()] + result = set() + for r in pykka.get_all(futures): + result.update(r) + return result + def get_images(self, uris): """Lookup the images for the given URIs From ba8fc51f860861c5bfebc1db2ca4f5f8b7be0ddf Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 22:48:16 +0100 Subject: [PATCH 326/495] local: Add support for list_distinct and implement for json backend --- mopidy/local/__init__.py | 12 ++++++++++++ mopidy/local/json.py | 32 ++++++++++++++++++++++++++++++++ mopidy/local/library.py | 5 +++++ 3 files changed, 49 insertions(+) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 31ec6426..3099e240 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -89,6 +89,18 @@ class Library(object): """ raise NotImplementedError + def list_distinct(self, field, query=None): + """ + List distinct values for a given field from the library. + + :param string field: One of ``artist``, ``albumartist``, ``album``, + ``composer``, ``performer``, ``date``or ``genre``. + :param dict query: Query to use for limiting results, see + :method:`search` for details about the query format. + :rtype: set of values corresponding to the requested field type. + """ + return set() + def load(self): """ (Re)load any tracks stored in memory, if any, otherwise just return diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 38e1bf6c..dcf8ff9b 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -155,6 +155,38 @@ class JsonLibrary(local.Library): except KeyError: return [] + def list_distinct(self, field, query=None): + if field == 'artist': + def distinct(track): + return {a.name for a in track.artists} + elif field == 'albumartist': + def distinct(track): + album = track.album or models.Album() + return {a.name for a in album.artists} + elif field == 'album': + def distinct(track): + album = track.album or models.Album() + return {album.name} + elif field == 'composer': + def distinct(track): + return {a.name for a in track.composers} + elif field == 'performer': + def distinct(track): + return {a.name for a in track.performers} + elif field == 'date': + def distinct(track): + return {track.date} + elif field == 'genre': + def distinct(track): + return {track.genre} + else: + return set() + + result = set() + for track in search.search(self._tracks.values(), query).tracks: + result.update(distinct(track)) + return result + def search(self, query=None, limit=100, offset=0, uris=None, exact=False): tracks = self._tracks.values() # TODO: pass limit and offset into search helpers diff --git a/mopidy/local/library.py b/mopidy/local/library.py index f3828f1b..8a6c2a8a 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -23,6 +23,11 @@ class LocalLibraryProvider(backend.LibraryProvider): return [] return self._library.browse(uri) + def list_distinct(self, field, query=None): + if not self._library: + return set() + return self._library.list_distinct(field, query) + def refresh(self, uri=None): if not self._library: return 0 From 5fd2afa7ca6d65624396fe2f96466542161df449 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 22:48:31 +0100 Subject: [PATCH 327/495] mpd: Switch list command to using list_distinct --- docs/changelog.rst | 3 + mopidy/mpd/protocol/music_db.py | 120 ++++++---------------------- tests/dummy_backend.py | 4 + tests/mpd/protocol/test_music_db.py | 13 ++- 4 files changed, 37 insertions(+), 103 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 03b25897..0cc145fc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -101,6 +101,9 @@ v0.20.0 (UNRELEASED) "database". If you insist on using a client that needs these commands change :confval:`mpd/command_blacklist`. +- Switch the ``list`` command over to using + :meth:`mopidy.core.LibraryController.list_distinct`. (Fixes: :issue:`913`) + **HTTP frontend** - Prevent race condition in webservice broadcast from breaking the server. diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index f08e51f2..04ad7d85 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -30,6 +30,15 @@ _LIST_MAPPING = { 'genre': 'genre', 'performer': 'performer'} +_LIST_NAME_MAPPING = { + 'album': 'Album', + 'albumartist': 'AlbumArtist', + 'artist': 'Artist', + 'composer': 'Composer', + 'date': 'Date', + 'genre': 'Genre', + 'performer': 'Performer'} + def _query_from_mpd_search_parameters(parameters, mapping): query = {} @@ -246,109 +255,30 @@ def list_(context, *args): - does not add quotes around the field argument. - capitalizes the field argument. """ - parameters = list(args) - if not parameters: + params = list(args) + if not params: raise exceptions.MpdArgError('incorrect arguments') - field = parameters.pop(0).lower() + field = params.pop(0).lower() if field not in _LIST_MAPPING: raise exceptions.MpdArgError('incorrect arguments') - if len(parameters) == 1: + if len(params) == 1: if field != 'album': raise exceptions.MpdArgError('should be "Album" for 3 arguments') - return _list_album(context, {'artist': parameters}) + query = {'artist': params} + else: + try: + query = _query_from_mpd_search_parameters(params, _LIST_MAPPING) + except exceptions.MpdArgError as e: + e.message = 'not able to parse args' + raise + except ValueError: + return - try: - query = _query_from_mpd_search_parameters(parameters, _LIST_MAPPING) - except exceptions.MpdArgError as e: - e.message = 'not able to parse args' - raise - except ValueError: - return - - if field == 'artist': - return _list_artist(context, query) - if field == 'albumartist': - return _list_albumartist(context, query) - elif field == 'album': - return _list_album(context, query) - elif field == 'composer': - return _list_composer(context, query) - elif field == 'performer': - return _list_performer(context, query) - elif field == 'date': - return _list_date(context, query) - elif field == 'genre': - return _list_genre(context, query) - - -def _list_artist(context, query): - artists = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - for artist in track.artists: - if artist.name: - artists.add(('Artist', artist.name)) - return artists - - -def _list_albumartist(context, query): - albumartists = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.album: - for artist in track.album.artists: - if artist.name: - albumartists.add(('AlbumArtist', artist.name)) - return albumartists - - -def _list_album(context, query): - albums = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.album and track.album.name: - albums.add(('Album', track.album.name)) - return albums - - -def _list_composer(context, query): - composers = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - for composer in track.composers: - if composer.name: - composers.add(('Composer', composer.name)) - return composers - - -def _list_performer(context, query): - performers = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - for performer in track.performers: - if performer.name: - performers.add(('Performer', performer.name)) - return performers - - -def _list_date(context, query): - dates = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.date: - dates.add(('Date', track.date)) - return dates - - -def _list_genre(context, query): - genres = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.genre: - genres.add(('Genre', track.genre)) - return genres + name = _LIST_NAME_MAPPING[field] + result = context.core.library.list_distinct(field, query) + return [(name, value) for value in result.get()] @protocol.commands.add('listall') diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 05b0fbff..a20c5686 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -33,6 +33,7 @@ class DummyLibraryProvider(backend.LibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] + self.dummy_list_distinct_result = {} self.dummy_browse_result = {} self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() @@ -40,6 +41,9 @@ class DummyLibraryProvider(backend.LibraryProvider): def browse(self, path): return self.dummy_browse_result.get(path, []) + def list_distinct(self, field, query=None): + return self.dummy_list_distinct_result.get(field, set()) + def find_exact(self, **query): return self.dummy_find_exact_result diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 9f3b7348..30ecf27c 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -55,7 +55,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): # Count the lone track self.backend.library.dummy_find_exact_result = SearchResult( tracks=[ - Track(uri='dummy:a', name="foo", date="2001", length=4000), + Track(uri='dummy:a', name='foo', date='2001', length=4000), ]) self.send_request('count "title" "foo"') self.assertInResponse('songs: 1') @@ -613,11 +613,8 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): class MusicDatabaseListTest(protocol.BaseTestCase): def test_list(self): - self.backend.library.dummy_find_exact_result = SearchResult( - tracks=[ - Track(uri='dummy:a', name='A', artists=[ - Artist(name='A Artist')])]) - + self.backend.library.dummy_list_distinct_result = { + 'artist': set(['A Artist'])} self.send_request('list "artist" "artist" "foo"') self.assertInResponse('Artist: A Artist') @@ -891,8 +888,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_album_with_artist_name(self): - self.backend.library.dummy_find_exact_result = SearchResult( - tracks=[Track(album=Album(name='foo'))]) + self.backend.library.dummy_list_distinct_result = { + 'album': set(['foo'])} self.send_request('list "album" "anartist"') self.assertInResponse('Album: foo') From fdab423a496be0349ad94e9e78493c470c61cfa3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 2 Mar 2015 00:29:46 +0100 Subject: [PATCH 328/495] Setup flake8 exclude in setup.cfg --- setup.cfg | 2 +- tasks.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 834ca945..95211279 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [flake8] application-import-names = mopidy,tests -exclude = .git,.tox,build,js +exclude = .git,.tox,build,js,tmp # Ignored flake8 warnings: # - E402 module level import not at top of file ignore = E402 diff --git a/tasks.py b/tasks.py index 03249481..7b5692e3 100644 --- a/tasks.py +++ b/tasks.py @@ -28,7 +28,7 @@ def test(path=None, coverage=False, watch=False, warn=False): def lint(watch=False, warn=False): if watch: return watcher(lint) - run('flake8 --exclude=tmp,.git,__pycache__', warn=warn) + run('flake8', warn=warn) @task From 45baeb9974204d5f7e09f2286b0b63421bb8a2e0 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Mon, 2 Mar 2015 11:26:05 +0000 Subject: [PATCH 329/495] Fixed OpenHome link for upmpdcli --- docs/clients/upnp.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst index 9b24ae46..d0683df8 100644 --- a/docs/clients/upnp.rst +++ b/docs/clients/upnp.rst @@ -43,9 +43,9 @@ upmpdcli -------- `upmpdcli `_ is recommended, since it -is easier to setup, and offers `OpenHome ohMedia`_ -compatibility. upmpdcli exposes a UPnP MediaRenderer to the network, while -using the MPD protocol to control Mopidy. +is easier to setup, and offers `OpenHome +`_ compatibility. upmpdcli exposes a UPnP +MediaRenderer to the network, while using the MPD protocol to control Mopidy. 1. Install upmpdcli. On Debian/Ubuntu:: From 8cc9c9bbc032888cde31919bbe360cfdf5e801f5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 2 Mar 2015 22:41:09 +0100 Subject: [PATCH 330/495] core: Rename list_distinct to get_distinct --- docs/changelog.rst | 4 ++-- mopidy/backend.py | 4 ++-- mopidy/core/library.py | 4 ++-- mopidy/local/__init__.py | 2 +- mopidy/local/json.py | 2 +- mopidy/local/library.py | 4 ++-- mopidy/mpd/protocol/music_db.py | 2 +- tests/dummy_backend.py | 6 +++--- tests/mpd/protocol/test_music_db.py | 4 ++-- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0cc145fc..36dbcc1e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -36,7 +36,7 @@ v0.20.0 (UNRELEASED) - When seeking in paused state, do not change to playing state. (Fixed :issue:`939`) -- Add :meth:`mopidy.core.LibraryController.list_distinct` for getting unique +- Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique values for a given field. (Fixes: :issue:`913`) **Commands** @@ -102,7 +102,7 @@ v0.20.0 (UNRELEASED) :confval:`mpd/command_blacklist`. - Switch the ``list`` command over to using - :meth:`mopidy.core.LibraryController.list_distinct`. (Fixes: :issue:`913`) + :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`913`) **HTTP frontend** diff --git a/mopidy/backend.py b/mopidy/backend.py index 38d4c5db..f7808ac8 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -92,9 +92,9 @@ class LibraryProvider(object): """ return [] - def list_distinct(self, field, query=None): + def get_distinct(self, field, query=None): """ - See :meth:`mopidy.core.LibraryController.list_distinct`. + See :meth:`mopidy.core.LibraryController.get_distinct`. *MAY be implemented by subclass.* diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 4ccfd657..5937b2c0 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -72,7 +72,7 @@ class LibraryController(object): return [] return backend.library.browse(uri).get() - def list_distinct(self, field, query=None): + def get_distinct(self, field, query=None): """ List distinct values for a given field from the library. @@ -86,7 +86,7 @@ class LibraryController(object): :method:`search` for details about the query format. :rtype: set of values corresponding to the requested field type. """ - futures = [b.library.list_distinct(field, query) + futures = [b.library.get_distinct(field, query) for b in self.backends.with_library.values()] result = set() for r in pykka.get_all(futures): diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 3099e240..1587b63a 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -89,7 +89,7 @@ class Library(object): """ raise NotImplementedError - def list_distinct(self, field, query=None): + def get_distinct(self, field, query=None): """ List distinct values for a given field from the library. diff --git a/mopidy/local/json.py b/mopidy/local/json.py index dcf8ff9b..aa16a6df 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -155,7 +155,7 @@ class JsonLibrary(local.Library): except KeyError: return [] - def list_distinct(self, field, query=None): + def get_distinct(self, field, query=None): if field == 'artist': def distinct(track): return {a.name for a in track.artists} diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 8a6c2a8a..90a54770 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -23,10 +23,10 @@ class LocalLibraryProvider(backend.LibraryProvider): return [] return self._library.browse(uri) - def list_distinct(self, field, query=None): + def get_distinct(self, field, query=None): if not self._library: return set() - return self._library.list_distinct(field, query) + return self._library.get_distinct(field, query) def refresh(self, uri=None): if not self._library: diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 04ad7d85..62147b7d 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -277,7 +277,7 @@ def list_(context, *args): return name = _LIST_NAME_MAPPING[field] - result = context.core.library.list_distinct(field, query) + result = context.core.library.get_distinct(field, query) return [(name, value) for value in result.get()] diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index a20c5686..9c5a8c0c 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -33,7 +33,7 @@ class DummyLibraryProvider(backend.LibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] - self.dummy_list_distinct_result = {} + self.dummy_get_distinct_result = {} self.dummy_browse_result = {} self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() @@ -41,8 +41,8 @@ class DummyLibraryProvider(backend.LibraryProvider): def browse(self, path): return self.dummy_browse_result.get(path, []) - def list_distinct(self, field, query=None): - return self.dummy_list_distinct_result.get(field, set()) + def get_distinct(self, field, query=None): + return self.dummy_get_distinct_result.get(field, set()) def find_exact(self, **query): return self.dummy_find_exact_result diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 30ecf27c..613467ed 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -613,7 +613,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): class MusicDatabaseListTest(protocol.BaseTestCase): def test_list(self): - self.backend.library.dummy_list_distinct_result = { + self.backend.library.dummy_get_distinct_result = { 'artist': set(['A Artist'])} self.send_request('list "artist" "artist" "foo"') @@ -888,7 +888,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_album_with_artist_name(self): - self.backend.library.dummy_list_distinct_result = { + self.backend.library.dummy_get_distinct_result = { 'album': set(['foo'])} self.send_request('list "album" "anartist"') From 8c7c275f3ae732717573a5633d91d8d0bd3c471c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 2 Mar 2015 23:21:14 +0100 Subject: [PATCH 331/495] docs: Add changelog for issue #917 & PR #947 --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 36dbcc1e..cb76c31d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -78,6 +78,9 @@ v0.20.0 (UNRELEASED) - Add "--force" option for local scan (Fixes: :issue:'910') (PR: :issue:'1010') +- Stop ignoring ``offset`` and ``limit`` in searches. (Fixes: :issue:`917`, + PR: :issue:`949`) + **File scanner** - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) From 319c1fc1e350c66b470907e7b6134149de69bfe4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 2 Mar 2015 23:39:06 +0100 Subject: [PATCH 332/495] local: Readd support for search without limit for get_distinct support --- mopidy/local/json.py | 9 +++++---- mopidy/local/search.py | 15 +++++++++++---- tests/local/test_json.py | 2 -- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index e27015f2..969049d6 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -182,10 +182,11 @@ class JsonLibrary(local.Library): else: return set() - result = set() - for track in search.search(self._tracks.values(), query).tracks: - result.update(distinct(track)) - return result + distinct_result = set() + search_result = search.search(self._tracks.values(), query, limit=None) + for track in search_result.tracks: + distinct_result.update(distinct(track)) + return distinct_result def search(self, query=None, limit=100, offset=0, uris=None, exact=False): tracks = self._tracks.values() diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 1f82366f..9d6edea7 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -106,9 +106,12 @@ def find_exact(tracks, query=None, limit=100, offset=0, uris=None): else: raise LookupError('Invalid lookup field: %s' % field) + if limit is None: + tracks = tracks[offset:] + else: + tracks = tracks[offset:offset + limit] # TODO: add local:search: - return SearchResult( - uri='local:search', tracks=tracks[offset:offset + limit]) + return SearchResult(uri='local:search', tracks=tracks) def search(tracks, query=None, limit=100, offset=0, uris=None): @@ -217,9 +220,13 @@ def search(tracks, query=None, limit=100, offset=0, uris=None): tracks = filter(any_filter, tracks) else: raise LookupError('Invalid lookup field: %s' % field) + + if limit is None: + tracks = tracks[offset:] + else: + tracks = tracks[offset:offset + limit] # TODO: add local:search: - return SearchResult(uri='local:search', - tracks=tracks[offset:offset + limit]) + return SearchResult(uri='local:search', tracks=tracks) def _validate_query(query): diff --git a/tests/local/test_json.py b/tests/local/test_json.py index 6d57c4d0..520287ad 100644 --- a/tests/local/test_json.py +++ b/tests/local/test_json.py @@ -1,12 +1,10 @@ from __future__ import absolute_import, unicode_literals - import unittest from mopidy.local import json from mopidy.models import Ref, Track - from tests import path_to_data_dir From c0d46263608b1ad4a9cde2a7dfa2597e51c9953c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 3 Mar 2015 00:00:42 +0100 Subject: [PATCH 333/495] docs: Update changelog based on all merges since last 0.19.x merge --- docs/changelog.rst | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index cb76c31d..298a6305 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,13 +31,14 @@ v0.20.0 (UNRELEASED) abstraction, which was never intended to be used externally. - Add :meth:`mopidy.core.LibraryController.get_images` for looking up images - for any URI backends know about. (Fixes :issue:`973`) + for any URI backends know about. (Fixes :issue:`973`, PR: :issue:`981`, + :issue:`992` and :issue:`1013`) -- When seeking in paused state, do not change to playing state. (Fixed - :issue:`939`) +- When seeking in paused state, do not change to playing state. (Fixes: + :issue:`939`, PR: :issue:`1018`) - Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique - values for a given field. (Fixes: :issue:`913`) + values for a given field. (Fixes: :issue:`913`, PR: :issue:`1022`) **Commands** @@ -54,7 +55,7 @@ v0.20.0 (UNRELEASED) This can be used to show absolutely all log records, including those at custom levels below ``DEBUG``. -- Add debug logging of unknown sections. (Fixes: :issue:`694`) +- Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) **Logging** @@ -76,11 +77,19 @@ v0.20.0 (UNRELEASED) - Add support for giving local libraries direct access to tags and duration. (Fixes: :issue:`967`) -- Add "--force" option for local scan (Fixes: :issue:'910') (PR: :issue:'1010') +- Add "--force" option for local scan (Fixes: :issue:'910', PR: :issue:'1010') - Stop ignoring ``offset`` and ``limit`` in searches. (Fixes: :issue:`917`, PR: :issue:`949`) +- Removed double triggering of ``playlists_loaded`` event. + (Fixes: :issue:`998`, PR: :issue:`999`) + +- Cleanup and refactoring of local playlist code. Preserves playlist names + better and fixes bug in deletion of playlists. (Fixes: :issue:`937`, + PR: :issue:`995` and rebased into :issue:`1000`) + + **File scanner** - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) @@ -110,6 +119,7 @@ v0.20.0 (UNRELEASED) **HTTP frontend** - Prevent race condition in webservice broadcast from breaking the server. + (PR: :issue:`1020`) **Audio** @@ -160,7 +170,7 @@ v0.20.0 (UNRELEASED) - Add basic tests for the stream library provider. - Add support for proxies when doing initial metadata lookup for stream. - (Fixes :issue:`390`) + (Fixes :issue:`390`, PR: :issue:`982`) **Mopidy.js client library** @@ -656,6 +666,7 @@ guys. Thanks to everyone that has contributed! - The dummy backend used for testing many frontends have moved from :mod:`mopidy.backends.dummy` to :mod:`mopidy.backend.dummy`. + (PR: :issue:`984`) **Commands** From 8b59c4dc87971917709787459ece3c6ffd922ee1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 00:48:11 +0100 Subject: [PATCH 334/495] docs: Update authors --- .mailmap | 1 + AUTHORS | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.mailmap b/.mailmap index 3ea843b1..7c0888ae 100644 --- a/.mailmap +++ b/.mailmap @@ -19,3 +19,4 @@ Ignasi Fosch Christopher Schirner Laura Barber John Cass +Ronald Zielaznicki diff --git a/AUTHORS b/AUTHORS index 08685991..52ea4e34 100644 --- a/AUTHORS +++ b/AUTHORS @@ -51,3 +51,5 @@ - Dirk Groenen - John Cass - Laura Barber +- Jakab Kristóf +- Ronald Zielaznicki From 88ddcc7d358e25bbf365bc32dbc11b94b0bf228b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:09:48 +0100 Subject: [PATCH 335/495] docs: Add section on ext installation on OS X --- docs/installation/osx.rst | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/installation/osx.rst b/docs/installation/osx.rst index 9c0e059e..dbc56137 100644 --- a/docs/installation/osx.rst +++ b/docs/installation/osx.rst @@ -57,16 +57,30 @@ If you are running OS X, you can install everything needed with Homebrew. brew install mopidy -#. Optional: If you want to use any Mopidy extensions, like Spotify support or - Last.fm scrobbling, the Homebrew tap has formulas for several Mopidy - extensions as well. - - To list all the extensions available from our tap, you can run:: - - brew search mopidy - - For a full list of available Mopidy extensions, including those not - installable from Homebrew, see :ref:`ext`. - #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, the Homebrew tap has formulas for several Mopidy extensions as +well. Extensions installed from Homebrew will come complete with all +dependencies, both Python and non-Python ones. + +To list all the extensions available from our tap, you can run:: + + brew search mopidy + +You can also install any Mopidy extension directly from PyPI with ``pip``, just +like on Linux. To list all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions, including those not installable +from Homebrew, see :ref:`ext`. From 3283beba697934fd98119b888dacc4ce327100b1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:20:09 +0100 Subject: [PATCH 336/495] docs: Add section on starting Mopidy at login Fixes #887 --- docs/installation/osx.rst | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/installation/osx.rst b/docs/installation/osx.rst index dbc56137..71beece3 100644 --- a/docs/installation/osx.rst +++ b/docs/installation/osx.rst @@ -84,3 +84,50 @@ about any other requirements needed for the extension to work properly. For a full list of available Mopidy extensions, including those not installable from Homebrew, see :ref:`ext`. + + +Running Mopidy automatically on login +===================================== + +On OS X, you can use launchd to start Mopidy automatically at login. + +If you installed Mopidy from Homebrew, simply run ``brew info mopidy`` and +follow the instructions in the "Caveats" section:: + + $ brew info mopidy + ... + ==> Caveats + To have launchd start mopidy at login: + ln -sfv /usr/local/opt/mopidy/*.plist ~/Library/LaunchAgents + Then to load mopidy now: + launchctl load ~/Library/LaunchAgents/homebrew.mopidy.mopidy.plist + Or, if you don't want/need launchctl, you can just run: + mopidy + +If you happen to be on OS X, but didn't install Mopidy with Homebrew, you can +get the same effect by adding the file +:file:`~/Library/LaunchAgents/mopidy.plist` with the following contents:: + + + + + + Label + mopidy + ProgramArguments + + /usr/local/bin/mopidy + + RunAtLoad + + KeepAlive + + + + +You might need to adjust the path to the ``mopidy`` executable, +``/usr/local/bin/mopidy``, to match your system. + +Then, to start Mopidy with launchd right away:: + + launchctl load ~/Library/LaunchAgents/mopidy.plist From c3f6016388571736bd58edcb058f45f00e1c330d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:28:44 +0100 Subject: [PATCH 337/495] docs: Add section on ext installation on Arch --- docs/installation/arch.rst | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/installation/arch.rst b/docs/installation/arch.rst index e58d6cf5..f8492fdf 100644 --- a/docs/installation/arch.rst +++ b/docs/installation/arch.rst @@ -16,12 +16,25 @@ If you are running Arch Linux, you can install Mopidy using the yaourt -Syua -#. Optional: If you want to use any Mopidy extensions, like Spotify support or - Last.fm scrobbling, AUR also has `packages for several Mopidy extensions - `_. - - For a full list of available Mopidy extensions, including those not - installable from AUR, see :ref:`ext`. - #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, AUR also has `packages for lots of Mopidy extensions +`_. + +You can also install any Mopidy extension directly from PyPI with ``pip``. To +list all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions, including those not installable +from AUR, see :ref:`ext`. From 6ff85aed2959485af305517c6b1a62ce3dbfb033 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:32:55 +0100 Subject: [PATCH 338/495] docs: Add section on ext installation on Debian --- docs/installation/debian.rst | 41 ++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index f34eb255..f39a4d3b 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -52,20 +52,6 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See sudo apt-get update sudo apt-get install mopidy -#. Optional: If you want to use any Mopidy extensions, like Spotify support or - Last.fm scrobbling, you need to install additional packages. - - To list all the extensions available from apt.mopidy.com, you can run:: - - apt-cache search mopidy - - To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: - - sudo apt-get install mopidy-spotify - - For a full list of available Mopidy extensions, including those not - installable from apt.mopidy.com, see :ref:`ext`. - #. Before continuing, make sure you've read the :ref:`debian` section to learn about the differences between running Mopidy as a system service and manually as your own system user. @@ -78,3 +64,30 @@ figure it out for itself, run the following to upgrade right away:: sudo apt-get update sudo apt-get dist-upgrade + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, you need to install additional packages. + +To list all the extensions available from apt.mopidy.com, you can run:: + + apt-cache search mopidy + +To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: + + sudo apt-get install mopidy-spotify + +You can also install any Mopidy extension directly from PyPI with ``pip``. To +list all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions, including those not +installable from apt.mopidy.com, see :ref:`ext`. From 1e41a1192f6884756127a3685d08920324856cf7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:36:30 +0100 Subject: [PATCH 339/495] docs: Add section on ext installation from source --- docs/installation/source.rst | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/installation/source.rst b/docs/installation/source.rst index 0b4fc5aa..c2018984 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -81,9 +81,23 @@ please follow the directions :ref:`here `. sudo pip install --allow-unverified=mopidy mopidy==dev -#. Optional: For Spotify support, Last.fm scrobbling, or many other extra - features, install the required Mopidy extensions. For a full list of - available Mopidy extensions, see :ref:`ext`. - #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, you need to install additional Mopidy extensions. + +You can install any Mopidy extension directly from PyPI with ``pip``. To list +all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions see :ref:`ext`. From 0f9fcc62cb38c1ab53aa07e2f9715be0068a8e84 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:53:36 +0100 Subject: [PATCH 340/495] docs: Add missing ext troubleshooting to Debian docs Fixes #879 --- docs/installation/debian.rst | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index f39a4d3b..72d5843e 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -91,3 +91,44 @@ about any other requirements needed for the extension to work properly. For a full list of available Mopidy extensions, including those not installable from apt.mopidy.com, see :ref:`ext`. + + +Missing extensions +================== + +If you've installed a Mopidy extension with pip, restarted Mopidy, and Mopidy +doesn't find the extension, there's probably a simple explanation and solution. + +Mopidy installed with APT can detect and use Mopidy extensions installed with +either APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`. + +Mopidy installed with pip can only detect Mopidy extensions installed from pip. +pip usually installs Mopidy as :file:`/usr/local/bin/mopidy`. + +If you have Mopidy installed from both APT and pip, then the pip-installed +Mopidy will probably shadow the APT-installed Mopidy because +:file:`/usr/local/bin` usually has precedence over :file:`/usr/bin` in the +``PATH`` environment variable. To check if this is the case on your system, you +can use ``which`` to see what installation of Mopidy you use when you run +``mopidy`` in your shell:: + + $ which mopidy + /usr/local/bin/mopidy + +If this is the case on your system, the recommended solution is to check that +you have Mopidy installed from APT too:: + + $ /usr/bin/mopidy --version + Mopidy 0.19.5 + +And then uninstall the pip-installed Mopidy:: + + sudo pip uninstall mopidy + +Depending on what shell you use, the shell may still try to use +:file:`/usr/local/bin/mopidy` even if it no longer exists. Check again with +``which mopidy`` what your shell believes is the right ``mopidy`` executable to +run. If the shell is still confused, you may need to restart it, or in the case +of zsh, run ``rehash`` to update the shell. + +For more details on why this works this way, see :ref:`debian`. From 6dddf34333774a78f1851cb5b56d3cb799c4ab40 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 15:28:30 +0100 Subject: [PATCH 341/495] docs: Fix review comments --- docs/installation/debian.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index 72d5843e..4def3fbb 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -100,9 +100,9 @@ If you've installed a Mopidy extension with pip, restarted Mopidy, and Mopidy doesn't find the extension, there's probably a simple explanation and solution. Mopidy installed with APT can detect and use Mopidy extensions installed with -either APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`. +both APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`. -Mopidy installed with pip can only detect Mopidy extensions installed from pip. +Mopidy installed with pip can only detect Mopidy extensions installed with pip. pip usually installs Mopidy as :file:`/usr/local/bin/mopidy`. If you have Mopidy installed from both APT and pip, then the pip-installed From a6f021cc4f0e18403b0b69d628593e6ecfae87d8 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Tue, 3 Mar 2015 15:03:13 +0000 Subject: [PATCH 342/495] docs: Troubleshooting link to discuss, not google group --- docs/troubleshooting.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 51cd8bc4..b7ff3c03 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -4,9 +4,9 @@ Troubleshooting *************** -If you run into problems with Mopidy, we usually hang around at ``#mopidy`` at -`irc.freenode.net `_ and also have a `mailing list at -Google Groups `_. +If you run into problems with Mopidy, we usually hang around at ``#mopidy`` on +`irc.freenode.net `_ and also have a `discussion forum +`_. If you stumble into a bug or have a feature request, please create an issue in the `issue tracker `_. From 9e967f7997219f9db204a330f1e91f0f712c1443 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 4 Mar 2015 23:16:26 +0100 Subject: [PATCH 343/495] docs: Add section on Deb package availability --- docs/debian.rst | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/debian.rst b/docs/debian.rst index f37c0673..f939d9af 100644 --- a/docs/debian.rst +++ b/docs/debian.rst @@ -1,13 +1,20 @@ .. _debian: -************** -Debian package -************** +*************** +Debian packages +*************** -The Mopidy Debian package is available from `apt.mopidy.com +The Mopidy Debian package, ``mopidy``, is available from `apt.mopidy.com `__ as well as from Debian, Ubuntu and other Debian-based Linux distributions. +Some extensions are also available from all of these sources, while others, +like Mopidy-Spotify and its dependencies, are only available from +apt.mopidy.com. This may either be temporary until the package is uploaded to +Debian and with time propagates to the other distributions. It may also be more +long term, like in the Mopidy-Spotify case where there is uncertainities around +licensing and distribution of non-free packages. + Installation ============ From 2280a533c05034791005f7d2de7f7e5bcedab034 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 00:35:20 +0100 Subject: [PATCH 344/495] Use py.test as test runner --- dev-requirements.txt | 7 +++---- docs/contributing.rst | 4 ++-- docs/extensiondev.rst | 5 ----- setup.py | 5 ----- tasks.py | 6 ++---- tests/__main__.py | 5 ----- tox.ini | 10 +++++----- 7 files changed, 12 insertions(+), 30 deletions(-) delete mode 100644 tests/__main__.py diff --git a/dev-requirements.txt b/dev-requirements.txt index 7b0e96c8..eba66348 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -12,12 +12,11 @@ flake8-import-order mock # Test runners -nose +pytest +pytest-cov +pytest-xdist tox -# Measure test's code coverage -coverage - # Check that MANIFEST.in matches Git repo contents before making a release check-manifest diff --git a/docs/contributing.rst b/docs/contributing.rst index 165fee49..f30e16bd 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -89,11 +89,11 @@ Mopidy to come with tests. #. To run all tests, go to the project directory and run:: - nosetests + py.test To run tests with test coverage statistics:: - nosetests --with-coverage + py.test --cov=mopidy --cov-report=term-missing Test coverage statistics can also be viewed online at `coveralls.io `_. diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index c6a88619..93f627dc 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -189,11 +189,6 @@ class that will connect the rest of the dots. 'Pykka >= 1.1', 'pysoundspot', ], - test_suite='nose.collector', - tests_require=[ - 'nose', - 'mock >= 1.0', - ], entry_points={ 'mopidy.ext': [ 'soundspot = mopidy_soundspot:Extension', diff --git a/setup.py b/setup.py index 384aaec5..0d29c041 100644 --- a/setup.py +++ b/setup.py @@ -29,11 +29,6 @@ setup( 'tornado >= 2.3', ], extras_require={'http': []}, - test_suite='nose.collector', - tests_require=[ - 'nose', - 'mock >= 1.0', - ], entry_points={ 'console_scripts': [ 'mopidy = mopidy.__main__:main', diff --git a/tasks.py b/tasks.py index 7b5692e3..9353eb8a 100644 --- a/tasks.py +++ b/tasks.py @@ -15,11 +15,9 @@ def test(path=None, coverage=False, watch=False, warn=False): if watch: return watcher(test, path=path, coverage=coverage) path = path or 'tests/' - cmd = 'nosetests' + cmd = 'py.test' if coverage: - cmd += ( - ' --with-coverage --cover-package=mopidy' - ' --cover-branches --cover-html') + cmd += ' --cov=mopidy --cov-report=term-missing' cmd += ' %s' % path run(cmd, pty=True, warn=warn) diff --git a/tests/__main__.py b/tests/__main__.py deleted file mode 100644 index ae7a18e6..00000000 --- a/tests/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import nose - -nose.main() diff --git a/tox.ini b/tox.ini index 277ae9d3..3d48e311 100644 --- a/tox.ini +++ b/tox.ini @@ -3,20 +3,20 @@ envlist = py27, py27-tornado23, py27-tornado31, docs, flake8 [testenv] sitepackages = true -commands = nosetests -v --with-xunit --xunit-file=xunit-{envname}.xml --with-coverage --cover-package=mopidy +commands = py.test --junit-xml=xunit-{envname}.xml --cov=mopidy deps = - coverage mock - nose + pytest + pytest-cov [testenv:py27-tornado23] -commands = nosetests -v tests/http +commands = py.test tests/http deps = {[testenv]deps} tornado==2.3 [testenv:py27-tornado31] -commands = nosetests -v tests/http +commands = py.test tests/http deps = {[testenv]deps} tornado==3.1.1 From c916fcb421ff07dede3efbb73528dd8673fd134d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 09:16:32 +0100 Subject: [PATCH 345/495] tox: Use env specific tmpdir for py.test --- tox.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 3d48e311..bffdb2df 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,10 @@ envlist = py27, py27-tornado23, py27-tornado31, docs, flake8 [testenv] sitepackages = true -commands = py.test --junit-xml=xunit-{envname}.xml --cov=mopidy +commands = + py.test \ + --basetemp={envtmpdir} \ + --junit-xml=xunit-{envname}.xml --cov=mopidy deps = mock pytest From d89041e1d3c34843decf79630436483662a5e670 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 09:16:46 +0100 Subject: [PATCH 346/495] tox: Pass args to py.test, include pytest-xdist --- tox.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index bffdb2df..ad43d9ec 100644 --- a/tox.ini +++ b/tox.ini @@ -6,11 +6,13 @@ sitepackages = true commands = py.test \ --basetemp={envtmpdir} \ - --junit-xml=xunit-{envname}.xml --cov=mopidy + --junit-xml=xunit-{envname}.xml --cov=mopidy \ + {posargs} deps = mock pytest pytest-cov + pytest-xdist [testenv:py27-tornado23] commands = py.test tests/http From 1119555809a03be7bc8029b375ae97ad5b2241fd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 12:27:18 +0100 Subject: [PATCH 347/495] core: Remove deprecated property warnings Their use of inspect (I think) made parallel test execution slower than serial test execution. --- mopidy/utils/deprecation.py | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index 1b744702..bf4756d7 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -1,34 +1,15 @@ from __future__ import unicode_literals -import inspect -import warnings - - -def _is_pykka_proxy_creation(): - stack = inspect.stack() - try: - calling_frame = stack[3] - except IndexError: - return False - else: - filename = calling_frame[1] - funcname = calling_frame[3] - return 'pykka' in filename and funcname == '_get_attributes' - def deprecated_property( getter=None, setter=None, message='Property is deprecated'): - def deprecated_getter(*args): - if not _is_pykka_proxy_creation(): - warnings.warn(message, DeprecationWarning, stacklevel=2) - return getter(*args) + # During development, this is a convenient place to add logging, emit + # warnings, or ``assert False`` to ensure you are not using any of the + # deprecated properties. + # + # Using inspect to find the call sites to emit proper warnings makes + # parallel execution of our test suite slower than serial execution. Thus, + # we don't want to add any extra overhead here by default. - def deprecated_setter(*args): - if not _is_pykka_proxy_creation(): - warnings.warn(message, DeprecationWarning, stacklevel=2) - return setter(*args) - - new_getter = getter and deprecated_getter - new_setter = setter and deprecated_setter - return property(new_getter, new_setter) + return property(getter, setter) From 67a41b980a7049690f9c7cc229c1c7da46d6a9e5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 12:28:31 +0100 Subject: [PATCH 348/495] tox: Run tests with 4 processes in parallel --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index ad43d9ec..e6470146 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ commands = py.test \ --basetemp={envtmpdir} \ --junit-xml=xunit-{envname}.xml --cov=mopidy \ + -n 4 \ {posargs} deps = mock From 51fb2e22422b25f589df745b7484d14d7db0b783 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 22:14:16 +0100 Subject: [PATCH 349/495] docs: Add PR #1024 to changelog --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 298a6305..9dd398e3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -185,6 +185,10 @@ This version has been released to npm as Mopidy.js v0.5.0. - Upgrade dependencies. +**Development** + +- Changed test runner from nose to py.test. (PR: :issue:`1024`) + v0.19.6 (UNRELEASED) ==================== From 733732405f3183da65ae5c224140e46fd10ab814 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 22:59:42 +0100 Subject: [PATCH 350/495] tox: Use better coverage report --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index e6470146..6dfab5ae 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,8 @@ sitepackages = true commands = py.test \ --basetemp={envtmpdir} \ - --junit-xml=xunit-{envname}.xml --cov=mopidy \ + --junit-xml=xunit-{envname}.xml \ + --cov=mopidy --cov-report=term-missing \ -n 4 \ {posargs} deps = From 9150c34053832f8e9aaa9e9dddf6ebac564e69df Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 6 Mar 2015 10:02:08 +0100 Subject: [PATCH 351/495] Fix #1023: Remove support for local album images from coverartarchive.org --- mopidy/local/commands.py | 1 - mopidy/local/translator.py | 9 --------- tests/local/test_translator.py | 15 +-------------- 3 files changed, 1 insertion(+), 24 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 79c1c505..798c10f8 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -141,7 +141,6 @@ class ScanCommand(commands.Command): mtime = file_mtimes.get(os.path.join(media_dir, relpath)) track = utils.convert_tags_to_track(tags).copy( uri=uri, length=duration, last_modified=mtime) - track = translator.add_musicbrainz_coverart_to_track(track) if library.add_supports_tags_and_duration: library.add(track, tags=tags, duration=duration) else: diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index d0c19c27..6800c478 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -13,19 +13,10 @@ from mopidy.utils.path import path_to_uri, uri_to_path M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') -COVERART_BASE = 'http://coverartarchive.org/release/%s/front' logger = logging.getLogger(__name__) -def add_musicbrainz_coverart_to_track(track): - if track.album and track.album.musicbrainz_id: - images = [COVERART_BASE % track.album.musicbrainz_id] - album = track.album.copy(images=images) - track = track.copy(album=album) - return track - - def local_track_uri_to_file_uri(uri, media_dir): return path_to_uri(local_track_uri_to_path(uri, media_dir)) diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py index b238c909..d3ba9e68 100644 --- a/tests/local/test_translator.py +++ b/tests/local/test_translator.py @@ -7,7 +7,7 @@ import tempfile import unittest from mopidy.local import translator -from mopidy.models import Album, Track +from mopidy.models import Track from mopidy.utils import path from tests import path_to_data_dir @@ -118,16 +118,3 @@ class M3UToUriTest(unittest.TestCase): class URItoM3UTest(unittest.TestCase): pass - - -class AddMusicbrainzCoverartTest(unittest.TestCase): - def test_add_cover_for_album(self): - album = Album(musicbrainz_id='someid') - track = Track(album=album) - - expected = album.copy( - images=['http://coverartarchive.org/release/someid/front']) - - self.assertEqual( - track.copy(album=expected), - translator.add_musicbrainz_coverart_to_track(track)) From 8d2cedcc6909326b6abbe7435aa2c28fff060880 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 6 Mar 2015 19:31:54 +0100 Subject: [PATCH 352/495] Remove changelog entry for #802. --- docs/changelog.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9dd398e3..3e209743 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,9 +66,6 @@ v0.20.0 (UNRELEASED) **Local backend** -- Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: - :issue:`697`, PR: :issue:`802`) - - Local library API: Implementors of :meth:`mopidy.local.Library.lookup` should now return a list of :class:`~mopidy.models.Track` instead of a single track, just like the other ``lookup()`` methods in Mopidy. For now, returning a From 94c418d5e60a5d75fa304608816ea024a7ece890 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sat, 7 Mar 2015 22:42:22 +0100 Subject: [PATCH 353/495] Fix #1026: Sort local playlists by name. --- mopidy/local/playlists.py | 8 ++++++-- tests/local/test_playlists.py | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py index 1a3afa6e..ba4dbf02 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/local/playlists.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, division, unicode_literals import glob import logging +import operator import os import sys @@ -29,6 +30,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self._playlists[index] = playlist else: self._playlists.append(playlist) + self._playlists.sort(key=operator.attrgetter('name')) logger.info('Created playlist %s', playlist.uri) return playlist @@ -45,7 +47,8 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self._playlists.remove(playlist) def lookup(self, uri): - # TODO: store as {uri: playlist}? + # TODO: store as {uri: playlist} when get_playlists() gets + # implemented for playlist in self._playlists: if playlist.uri == uri: return playlist @@ -66,7 +69,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): playlist = Playlist(uri=uri, name=name, tracks=tracks) playlists.append(playlist) - self.playlists = playlists + self.playlists = sorted(playlists, key=operator.attrgetter('name')) logger.info( 'Loaded %d local playlists from %s', @@ -95,6 +98,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self._playlists[index] = playlist else: self._playlists.append(playlist) + self._playlists.sort(key=operator.attrgetter('name')) return playlist def _write_m3u_extinf(self, file_handle, track): diff --git a/tests/local/test_playlists.py b/tests/local/test_playlists.py index d52fed82..5af0debe 100644 --- a/tests/local/test_playlists.py +++ b/tests/local/test_playlists.py @@ -267,3 +267,27 @@ class LocalPlaylistsProviderTest(unittest.TestCase): playlist.name, backend.playlists.playlists[0].name) self.assertEqual( track.uri, backend.playlists.playlists[0].tracks[0].uri) + + def test_playlist_sort_order(self): + def check_order(playlists, names): + self.assertEqual(names, [playlist.name for playlist in playlists]) + + self.core.playlists.create('c') + self.core.playlists.create('a') + self.core.playlists.create('b') + + check_order(self.core.playlists.playlists, ['a', 'b', 'c']) + + self.core.playlists.refresh() + + check_order(self.core.playlists.playlists, ['a', 'b', 'c']) + + playlist = self.core.playlists.lookup('local:playlist:a.m3u') + playlist = playlist.copy(name='d') + playlist = self.core.playlists.save(playlist) + + check_order(self.core.playlists.playlists, ['b', 'c', 'd']) + + self.core.playlists.delete('local:playlist:c.m3u') + + check_order(self.core.playlists.playlists, ['b', 'd']) From cf0b666a0afa2c5b34eca8f472e8cf621e86f250 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 1 Mar 2015 15:22:21 +0100 Subject: [PATCH 354/495] Add tests for PlaybackController get_current_(tl_)track Add some more test cases for PlaybackController --- tests/core/test_playback.py | 70 +++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 11d63e04..3b6435c8 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -43,9 +43,51 @@ class CorePlaybackTest(unittest.TestCase): self.tl_tracks = self.core.tracklist.tl_tracks self.unplayable_tl_track = self.tl_tracks[2] - # TODO Test get_current_tl_track + def test_get_current_tl_track_none(self): + self.core.playback.set_current_tl_track(None) - # TODO Test get_current_track + self.assertEqual( + self.core.playback.get_current_tl_track(), None) + + def test_get_current_tl_track_play(self): + self.core.playback.play(self.tl_tracks[0]) + + self.assertEqual( + self.core.playback.get_current_tl_track(), self.tl_tracks[0]) + + def test_get_current_tl_track_next(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.next() + + self.assertEqual( + self.core.playback.get_current_tl_track(), self.tl_tracks[1]) + + def test_get_current_tl_track_prev(self): + self.core.playback.play(self.tl_tracks[1]) + self.core.playback.previous() + + self.assertEqual( + self.core.playback.get_current_tl_track(), self.tl_tracks[0]) + + def test_get_current_track_play(self): + self.core.playback.play(self.tl_tracks[0]) + + self.assertEqual( + self.core.playback.get_current_track(), self.tracks[0]) + + def test_get_current_track_next(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.next() + + self.assertEqual( + self.core.playback.get_current_track(), self.tracks[1]) + + def test_get_current_track_prev(self): + self.core.playback.play(self.tl_tracks[1]) + self.core.playback.previous() + + self.assertEqual( + self.core.playback.get_current_track(), self.tracks[0]) # TODO Test state @@ -385,6 +427,30 @@ class CorePlaybackTest(unittest.TestCase): 'track_playback_started', tl_track=self.tl_tracks[1]), ]) + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_seek_past_end_of_track_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.seek(self.tracks[0].length * 5) + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[1]), + ]) + def test_seek_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.core.playback.seek(10000) From 0f52316d7791208d15d7111575369974db663cae Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sun, 8 Mar 2015 19:04:57 +0100 Subject: [PATCH 355/495] docs: Add PR #1028 to changelog --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9dd398e3..548a574f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -89,6 +89,7 @@ v0.20.0 (UNRELEASED) better and fixes bug in deletion of playlists. (Fixes: :issue:`937`, PR: :issue:`995` and rebased into :issue:`1000`) +- Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`) **File scanner** From 714ff0d64a15ad160ea1df64c7df0079a19384db Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 8 Mar 2015 21:16:31 +0100 Subject: [PATCH 356/495] docs: Fix real name of four contributors --- .mailmap | 4 ++++ AUTHORS | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.mailmap b/.mailmap index 7c0888ae..54e01b7d 100644 --- a/.mailmap +++ b/.mailmap @@ -5,6 +5,9 @@ Kristian Klette Johannes Knutsen Johannes Knutsen John Bäckstrand +David Caruso +Adam Rigg +Ernst Bammer Alli Witheford Alexandre Petitjean Alexandre Petitjean @@ -15,6 +18,7 @@ Janez Troha Janez Troha Luke Giuliani Colin Montgomerie +Nathan Harper Ignasi Fosch Christopher Schirner Laura Barber diff --git a/AUTHORS b/AUTHORS index 52ea4e34..91b71008 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,14 +8,14 @@ - John Bäckstrand - Fred Hatfull - Erling Børresen -- David C +- David Caruso - Christian Johansen - Matt Bray - Trygve Aaberge - Wouter van Wijk - Jeremy B. Merrill -- 0xadam -- herrernst +- Adam Rigg +- Ernst Bammer - Nick Steel - Zan Dobersek - Thomas Refis @@ -36,7 +36,7 @@ - Colin Montgomerie - Simon de Bakker - Arnaud Barisain-Monrose -- nathanharper +- Nathan Harper - Pierpaolo Frasa - Thomas Scholtes - Sam Willcocks From af87a17ff53210184f8d85549496d14b680d8cbe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 8 Mar 2015 21:41:54 +0100 Subject: [PATCH 357/495] docs: Fix all warnings --- docs/clients/upnp.rst | 4 ---- mopidy/core/library.py | 2 +- mopidy/local/__init__.py | 4 ++-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst index d0683df8..b5b18268 100644 --- a/docs/clients/upnp.rst +++ b/docs/clients/upnp.rst @@ -37,8 +37,6 @@ There are two ways Mopidy can be made available as an UPnP MediaRenderer: Using Mopidy-MPRIS and Rygel, or using Mopidy-MPD and upmpdcli. -.. _upmpdcli: - upmpdcli -------- @@ -68,8 +66,6 @@ MediaRenderer to the network, while using the MPD protocol to control Mopidy. 4. A UPnP renderer should be available now. -.. _rygel: - Rygel ----- diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 5937b2c0..49a4a796 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -83,7 +83,7 @@ class LibraryController(object): :param string field: One of ``artist``, ``albumartist``, ``album``, ``composer``, ``performer``, ``date``or ``genre``. :param dict query: Query to use for limiting results, see - :method:`search` for details about the query format. + :meth:`search` for details about the query format. :rtype: set of values corresponding to the requested field type. """ futures = [b.library.get_distinct(field, query) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 1587b63a..97ed4a09 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -96,7 +96,7 @@ class Library(object): :param string field: One of ``artist``, ``albumartist``, ``album``, ``composer``, ``performer``, ``date``or ``genre``. :param dict query: Query to use for limiting results, see - :method:`search` for details about the query format. + :meth:`search` for details about the query format. :rtype: set of values corresponding to the requested field type. """ return set() @@ -159,7 +159,7 @@ class Library(object): :param track: Track to add to the library :type track: :class:`~mopidy.models.Track` :param tags: All the tags the scanner found for the media. See - :module:`mopidy.audio.utils` for details about the tags. + :mod:`mopidy.audio.utils` for details about the tags. :type tags: dictionary of tag keys with a list of values. :param duration: Duration of media in milliseconds or :class:`None` if unknown From e639b2b18ba56051138efa13911aedcf92f7fcc4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 7 Mar 2015 01:44:35 +0100 Subject: [PATCH 358/495] tests: Add method for emitting fake tags changed in tests --- tests/dummy_audio.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py index 64639e91..b73946cb 100644 --- a/tests/dummy_audio.py +++ b/tests/dummy_audio.py @@ -109,6 +109,10 @@ class DummyAudio(pykka.ThreadingActor): def trigger_fake_playback_failure(self): self._state_change_result = False + def trigger_fake_tags_changed(self, tags): + self._tags = tags + audio.AudioListener.send('tags_changed', tags=self._tags.keys()) + def get_about_to_finish_callback(self): # This needs to be called from outside the actor or we lock up. def wrapper(): From cb19b2c48c1c59b0ecd7386f8e4d8ca6e0f43c65 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sat, 7 Feb 2015 22:54:02 +0100 Subject: [PATCH 359/495] Allow 'none' as audio.mixer value To disable mixing altogether, you can now set the configuration value audio/mixer to 'none'. --- docs/changelog.rst | 7 ++ docs/config.rst | 2 + mopidy/commands.py | 14 +++- mopidy/core/mixer.py | 23 +++--- mopidy/mpd/protocol/audio_output.py | 19 +++-- mopidy/mpd/protocol/playback.py | 15 ++-- tests/core/test_listener.py | 3 + tests/core/test_mixer.py | 55 +++++++++++++++ tests/dummy_mixer.py | 4 ++ tests/mpd/protocol/__init__.py | 7 +- tests/mpd/protocol/test_audio_output.py | 93 +++++++++++++++++++++++++ tests/mpd/protocol/test_idle.py | 30 ++++++++ tests/mpd/protocol/test_playback.py | 16 +++++ tests/mpd/test_exceptions.py | 12 +++- 14 files changed, 274 insertions(+), 26 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ca36454e..1d2f520d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,6 +57,9 @@ v0.20.0 (UNRELEASED) - Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) +- Add support for configuring :confval:`audio/mixer` to ``none``. (Fixes: + :issue:`936`) + **Logging** - Add custom log level ``TRACE`` (numerical level 5), which can be used by @@ -114,6 +117,10 @@ v0.20.0 (UNRELEASED) - Switch the ``list`` command over to using :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`913`) +- Add support for ``toggleoutput`` command. The ``mixrampdb`` and + ``mixrampdelay`` commands are now supported but throw a NotImplemented + exception. + **HTTP frontend** - Prevent race condition in webservice broadcast from breaking the server. diff --git a/docs/config.rst b/docs/config.rst index 69945ab8..46b15635 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -70,6 +70,8 @@ Audio configuration will affect the audio volume if you're streaming the audio from Mopidy through Shoutcast. + If you want to disable audio mixing set the value to ``none``. + If you want to use a hardware mixer, you need to install a Mopidy extension which integrates with your sound subsystem. E.g. for ALSA, install `Mopidy-ALSAMixer `_. diff --git a/mopidy/commands.py b/mopidy/commands.py index d9b4ce0e..5df8dd5a 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -276,7 +276,9 @@ class RootCommand(Command): exit_status_code = 0 try: - mixer = self.start_mixer(config, mixer_class) + mixer = None + if mixer_class is not None: + mixer = self.start_mixer(config, mixer_class) audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) core = self.start_core(mixer, backends, audio) @@ -297,7 +299,8 @@ class RootCommand(Command): self.stop_core() self.stop_backends(backend_classes) self.stop_audio() - self.stop_mixer(mixer_class) + if mixer_class is not None: + self.stop_mixer(mixer_class) process.stop_remaining_actors() return exit_status_code @@ -306,13 +309,18 @@ class RootCommand(Command): 'Available Mopidy mixers: %s', ', '.join(m.__name__ for m in mixer_classes) or 'none') + if config['audio']['mixer'] == 'none': + logger.debug('Mixer disabled') + return None + selected_mixers = [ m for m in mixer_classes if m.name == config['audio']['mixer']] if len(selected_mixers) != 1: logger.error( 'Did not find unique mixer "%s". Alternatives are: %s', config['audio']['mixer'], - ', '.join([m.name for m in mixer_classes])) + ', '.join([m.name for m in mixer_classes]) + ', none' or + 'none') process.exit_process() return selected_mixers[0] diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 4d77f8bc..1f5ada9e 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -11,8 +11,6 @@ class MixerController(object): def __init__(self, mixer): self._mixer = mixer - self._volume = None - self._mute = False def get_volume(self): """Get the volume. @@ -27,12 +25,15 @@ class MixerController(object): def set_volume(self, volume): """Set the volume. - The volume is defined as an integer in range [0..100]. + The volume is defined as an integer in range [0..100] or :class:`None` + if the mixer is disabled. The volume scale is linear. """ - if self._mixer is not None: - self._mixer.set_volume(volume) + if self._mixer is None: + return False + else: + return self._mixer.set_volume(volume).get() def get_mute(self): """Get mute state. @@ -40,13 +41,19 @@ class MixerController(object): :class:`True` if muted, :class:`False` unmuted, :class:`None` if unknown. """ - if self._mixer is not None: + if self._mixer is None: + return False + else: return self._mixer.get_mute().get() def set_mute(self, mute): """Set mute state. :class:`True` to mute, :class:`False` to unmute. + + Returns :class:`True` if call is successful, otherwise :class:`False`. """ - if self._mixer is not None: - self._mixer.set_mute(bool(mute)) + if self._mixer is None: + return False + else: + return self._mixer.set_mute(bool(mute)).get() diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 0152f852..6ffedcf1 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -13,7 +13,9 @@ def disableoutput(context, outputid): Turns an output off. """ if outputid == 0: - context.core.mixer.set_mute(False) + success = context.core.mixer.set_mute(False).get() + if success is False: + raise exceptions.MpdSystemError('problems disabling output') else: raise exceptions.MpdNoExistError('No such audio output') @@ -28,13 +30,14 @@ def enableoutput(context, outputid): Turns an output on. """ if outputid == 0: - context.core.mixer.set_mute(True) + success = context.core.mixer.set_mute(True).get() + if success is False: + raise exceptions.MpdSystemError('problems enabling output') else: raise exceptions.MpdNoExistError('No such audio output') -# TODO: implement and test -# @protocol.commands.add('toggleoutput', outputid=protocol.UINT) +@protocol.commands.add('toggleoutput', outputid=protocol.UINT) def toggleoutput(context, outputid): """ *musicpd.org, audio output section:* @@ -43,7 +46,13 @@ def toggleoutput(context, outputid): Turns an output on or off, depending on the current state. """ - pass + if outputid == 0: + mute_status = context.core.mixer.get_mute().get() + success = context.core.mixer.set_mute(not mute_status) + if success is False: + raise exceptions.MpdSystemError('problems toggling output') + else: + raise exceptions.MpdNoExistError('No such audio output') @protocol.commands.add('outputs') diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index f7856a03..4cf8b2e8 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -32,8 +32,7 @@ def crossfade(context, seconds): raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('mixrampdb') +@protocol.commands.add('mixrampdb') def mixrampdb(context, decibels): """ *musicpd.org, playback section:* @@ -46,11 +45,10 @@ def mixrampdb(context, decibels): volume so use negative values, I prefer -17dB. In the absence of mixramp tags crossfading will be used. See http://sourceforge.net/projects/mixramp """ - pass + raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('mixrampdelay', seconds=protocol.UINT) +@protocol.commands.add('mixrampdelay', seconds=protocol.UINT) def mixrampdelay(context, seconds): """ *musicpd.org, playback section:* @@ -61,7 +59,7 @@ def mixrampdelay(context, seconds): value of "nan" disables MixRamp overlapping and falls back to crossfading. """ - pass + raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('next') @@ -397,7 +395,10 @@ def setvol(context, volume): - issues ``setvol 50`` without quotes around the argument. """ # NOTE: we use INT as clients can pass in +N etc. - context.core.mixer.set_volume(min(max(0, volume), 100)) + value = min(max(0, volume), 100) + success = context.core.mixer.set_volume(value).get() + if success is False: + raise exceptions.MpdSystemError('problems setting volume') @protocol.commands.add('single', state=protocol.BOOL) diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index 64003769..1338ec5e 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -57,3 +57,6 @@ class CoreListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_seeked(self): self.listener.seeked(0) + + def test_listener_has_default_impl_for_current_metadata_changed(self): + self.listener.current_metadata_changed() diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 80e6f7ef..6485f3e8 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -4,7 +4,10 @@ import unittest import mock +import pykka + from mopidy import core, mixer +from tests import dummy_mixer class CoreMixerTest(unittest.TestCase): @@ -33,3 +36,55 @@ class CoreMixerTest(unittest.TestCase): self.core.mixer.set_mute(True) self.mixer.set_mute.assert_called_once_with(True) + + +class CoreNoneMixerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_get_volume_return_none(self): + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_set_volume_return_false(self): + self.assertEqual(self.core.mixer.set_volume(30), False) + + def test_get_set_mute_return_proper_state(self): + self.assertEqual(self.core.mixer.set_mute(False), False) + self.assertEqual(self.core.mixer.get_mute(), False) + self.assertEqual(self.core.mixer.set_mute(True), False) + self.assertEqual(self.core.mixer.get_mute(), False) + + +@mock.patch.object(mixer.MixerListener, 'send') +class CoreMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.mixer = dummy_mixer.create_proxy() + self.core = core.Core(mixer=self.mixer, backends=[]) + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.assertEqual(self.core.mixer.set_volume(volume=60), True) + self.assertEqual(send.call_args[0][0], 'volume_changed') + self.assertEqual(send.call_args[1]['volume'], 60) + + def test_forwards_mixer_mute_changed_event_to_frontends(self, send): + self.core.mixer.set_mute(mute=True) + + self.assertEqual(send.call_args[0][0], 'mute_changed') + self.assertEqual(send.call_args[1]['mute'], True) + + +@mock.patch.object(mixer.MixerListener, 'send') +class CoreNoneMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.assertEqual(self.core.mixer.set_volume(volume=60), False) + self.assertEqual(send.call_count, 0) + + def test_forwards_mixer_mute_changed_event_to_frontends(self, send): + self.core.mixer.set_mute(mute=True) + self.assertEqual(send.call_count, 0) diff --git a/tests/dummy_mixer.py b/tests/dummy_mixer.py index f7d90b17..6defddba 100644 --- a/tests/dummy_mixer.py +++ b/tests/dummy_mixer.py @@ -21,9 +21,13 @@ class DummyMixer(pykka.ThreadingActor, mixer.Mixer): def set_volume(self, volume): self._volume = volume + self.trigger_volume_changed(volume=volume) + return True def get_mute(self): return self._mute def set_mute(self, mute): self._mute = mute + self.trigger_mute_changed(mute=mute) + return True diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index b07a5ba3..88e3567b 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -25,6 +25,8 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): + enable_mixer = True + def get_config(self): return { 'mpd': { @@ -33,7 +35,10 @@ class BaseTestCase(unittest.TestCase): } def setUp(self): # noqa: N802 - self.mixer = dummy_mixer.create_proxy() + if self.enable_mixer: + self.mixer = dummy_mixer.create_proxy() + else: + self.mixer = None self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index a86f24f0..322bf181 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): + def test_enableoutput(self): self.core.mixer.set_mute(False) @@ -50,3 +51,95 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') + + def test_outputs_toggleoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 1') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 1') + self.assertInResponse('OK') + + def test_outputs_toggleoutput_unknown_outputid(self): + self.send_request('toggleoutput "7"') + + self.assertInResponse( + 'ACK [50@0] {toggleoutput} No such audio output') + + +class AudioOutputHandlerNoneMixerTest(protocol.BaseTestCase): + enable_mixer = False + + def test_enableoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('enableoutput "0"') + self.assertInResponse( + 'ACK [52@0] {enableoutput} problems enabling output') + self.assertEqual(self.core.mixer.get_mute().get(), False) + + def test_disableoutput(self): + self.core.mixer.set_mute(True) + + self.send_request('disableoutput "0"') + self.assertInResponse( + 'ACK [52@0] {disableoutput} problems disabling output') + self.assertEqual(self.core.mixer.get_mute().get(), False) + + def test_outputs_when_unmuted(self): + self.core.mixer.set_mute(False) + + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + def test_outputs_when_muted(self): + self.core.mixer.set_mute(True) + + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + def test_outputs_toggleoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') diff --git a/tests/mpd/protocol/test_idle.py b/tests/mpd/protocol/test_idle.py index 0bd16992..e3c6ad38 100644 --- a/tests/mpd/protocol/test_idle.py +++ b/tests/mpd/protocol/test_idle.py @@ -50,6 +50,12 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertNoEvents() self.assertNoResponse() + def test_idle_output(self): + self.send_request('idle output') + self.assertEqualSubscriptions(['output']) + self.assertNoEvents() + self.assertNoResponse() + def test_idle_player_playlist(self): self.send_request('idle player playlist') self.assertEqualSubscriptions(['player', 'playlist']) @@ -102,6 +108,22 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') + def test_idle_then_output(self): + self.send_request('idle') + self.idle_event('output') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') + + def test_idle_output_then_event_output(self): + self.send_request('idle output') + self.idle_event('output') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') + def test_idle_player_then_noidle(self): self.send_request('idle player') self.send_request('noidle') @@ -206,3 +228,11 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertNotInResponse('changed: player') self.assertOnceInResponse('changed: playlist') self.assertOnceInResponse('OK') + + def test_output_then_idle_toggleoutput(self): + self.idle_event('output') + self.send_request('idle output') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index ea9c59ce..4f3d6d7a 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -150,6 +150,14 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') self.assertInResponse('off') + def test_mixrampdb(self): + self.send_request('mixrampdb "10"') + self.assertInResponse('ACK [0@0] {mixrampdb} Not implemented') + + def test_mixrampdelay(self): + self.send_request('mixrampdelay "10"') + self.assertInResponse('ACK [0@0] {mixrampdelay} Not implemented') + @unittest.SkipTest def test_replay_gain_status_off(self): pass @@ -463,3 +471,11 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.send_request('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('OK') + + +class PlaybackOptionsHandlerNoneMixerTest(protocol.BaseTestCase): + enable_mixer = False + + def test_setvol_max_error(self): + self.send_request('setvol "100"') + self.assertInResponse('ACK [52@0] {setvol} problems setting volume') diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index d055ef7e..7bb64096 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -3,8 +3,8 @@ from __future__ import absolute_import, unicode_literals import unittest from mopidy.mpd.exceptions import ( - MpdAckError, MpdNoCommand, MpdNotImplemented, MpdPermissionError, - MpdSystemError, MpdUnknownCommand) + MpdAckError, MpdNoCommand, MpdNoExistError, MpdNotImplemented, + MpdPermissionError, MpdSystemError, MpdUnknownCommand) class MpdExceptionsTest(unittest.TestCase): @@ -61,3 +61,11 @@ class MpdExceptionsTest(unittest.TestCase): self.assertEqual( e.get_mpd_ack(), 'ACK [4@0] {foo} you don\'t have permission for "foo"') + + def test_mpd_noexist_error(self): + try: + raise MpdNoExistError(command='foo') + except MpdNoExistError as e: + self.assertEqual( + e.get_mpd_ack(), + 'ACK [50@0] {foo} ') From 20e95eac077688f54bd700ffee6f2a80d81068c0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 10 Mar 2015 18:34:49 +0100 Subject: [PATCH 360/495] docs: Fix rST syntax error --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ca36454e..9e3fb9d2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -74,7 +74,7 @@ v0.20.0 (UNRELEASED) - Add support for giving local libraries direct access to tags and duration. (Fixes: :issue:`967`) -- Add "--force" option for local scan (Fixes: :issue:'910', PR: :issue:'1010') +- Add "--force" option for local scan (Fixes: :issue:`910`, PR: :issue:`1010`) - Stop ignoring ``offset`` and ``limit`` in searches. (Fixes: :issue:`917`, PR: :issue:`949`) From 6fcd43891e7a2b97f3b8681cafd001e61248c96a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 10 Mar 2015 21:55:51 +0100 Subject: [PATCH 361/495] core: Switch to reference based stream info. - Adds tests for new behaviors in core. - Adds stream name to MPD format (fixes #944) - Adds 'stream_changed' core event (needs a new name/event) - Adds 'get_stream_reference' (which I'm also unsure about) The bits I'm unsure about are mostly with respect to #270, but I'm going ahead with this commit so we can discuss the details in PR with this code as an example. --- mopidy/core/actor.py | 30 +++------ mopidy/core/listener.py | 4 +- mopidy/core/playback.py | 30 +++++---- mopidy/mpd/actor.py | 2 +- mopidy/mpd/protocol/current_playlist.py | 25 +++---- mopidy/mpd/protocol/status.py | 8 +-- mopidy/mpd/translator.py | 6 +- tests/core/test_playback.py | 90 ++++++++++++++++++++++++- 8 files changed, 137 insertions(+), 58 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 19e49838..251f6e2c 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -5,9 +5,8 @@ import itertools import pykka -from mopidy import audio, backend, mixer +from mopidy import audio, backend, mixer, models from mopidy.audio import PlaybackState -from mopidy.audio.utils import convert_tags_to_track from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener @@ -15,7 +14,6 @@ from mopidy.core.mixer import MixerController from mopidy.core.playback import PlaybackController from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController -from mopidy.models import TlTrack, Track from mopidy.utils import versioning from mopidy.utils.deprecation import deprecated_property @@ -88,6 +86,9 @@ class Core( def reached_end_of_stream(self): self.playback.on_end_of_track() + def stream_changed(self, uri): + self.playback.on_stream_changed(uri) + def state_changed(self, old_state, new_state, target_state): # XXX: This is a temporary fix for issue #232 while we wait for a more # permanent solution with the implementation of issue #234. When the @@ -116,30 +117,15 @@ class Core( CoreListener.send('mute_changed', mute=mute) def tags_changed(self, tags): - if not self.audio: - return - - current_tl_track = self.playback.get_current_tl_track() - if current_tl_track is None: + if not self.audio or 'title' not in tags: return tags = self.audio.get_current_tags().get() - if not tags: + if not tags or 'title' not in tags or not tags['title']: return - current_track = current_tl_track.track - tags_track = convert_tags_to_track(tags) - - track_kwargs = {k: v for k, v in current_track.__dict__.items() if v} - track_kwargs.update( - {k: v for k, v in tags_track.__dict__.items() if v}) - - self.playback._current_metadata_track = TlTrack(**{ - 'tlid': current_tl_track.tlid, - 'track': Track(**track_kwargs)}) - - # TODO Move this into playback.current_metadata_track setter? - CoreListener.send('current_metadata_changed') + self.playback._stream_ref = models.Ref.track(name=tags['title'][0]) + CoreListener.send('stream_changed') class Backends(list): diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 9d952473..f013fa18 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -164,9 +164,9 @@ class CoreListener(listener.Listener): """ pass - def current_metadata_changed(self): + def stream_changed(self): """ - Called whenever current track's metadata changed + Called whenever the currently playing stream changes. *MAY* be implemented by actor. """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 0d604d61..6314442b 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -20,7 +20,7 @@ class PlaybackController(object): self.core = core self._current_tl_track = None - self._current_metadata_track = None + self._stream_ref = None self._state = PlaybackState.STOPPED def _get_backend(self): @@ -73,20 +73,23 @@ class PlaybackController(object): Use :meth:`get_current_track` instead. """ - def get_current_metadata_track(self): + def get_stream_reference(self): """ - Get a :class:`mopidy.models.TlTrack` with updated metadata for the - currently playing track. + Get additional information about the current stream. - Returns :class:`None` if no track is currently playing. + For most cases this value won't be set, but for radio streams it will + contain a reference with the name of the currently playing track or + program. Clients should show this when available. + + The :class:`mopidy.models.Ref` instance may or may not have an URI set. + If present you can call ``lookup`` on it to get the full metadata for + the URI. + + Returns a :class:`mopidy.models.Ref` instance representing the current + stream. If nothing is playing, or no stream info is available this will + return :class:`None`. """ - return self._current_metadata_track - - current_metadata_track = deprecated_property(get_current_metadata_track) - """ - .. deprecated:: 0.20 - Use :meth:`get_current_metadata_track` instead. - """ + return self._stream_ref def get_state(self): """Get The playback state.""" @@ -244,6 +247,9 @@ class PlaybackController(object): self.stop() self.set_current_tl_track(None) + def on_stream_changed(self, uri): + self._stream_ref = None + def next(self): """ Change to the next track. diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index b56e507d..2c63bcb2 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -74,5 +74,5 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def mute_changed(self, mute): self.send_idle('output') - def current_metadata_changed(self): + def stream_changed(self): self.send_idle('playlist') diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index e083ea7c..fdd65bde 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -276,25 +276,20 @@ def plchanges(context, version): """ # XXX Naive implementation that returns all tracks as changed tracklist_version = context.core.tracklist.version.get() - iversion = int(version) - if iversion < tracklist_version: + if version < tracklist_version: return translator.tracks_to_mpd_format( context.core.tracklist.tl_tracks.get()) - elif iversion == tracklist_version: - # If version are equals, it is just a metadata update - # So we replace the updated track in playlist - current_md_track = context.core.playback.current_metadata_track.get() - if current_md_track is None: + elif version == tracklist_version: + # A version match could indicate this is just a metadata update, so + # check for a stream ref and let the client know about the change. + stream_ref = context.core.playback.get_stream_reference().get() + if stream_ref is None: return None - ntl_tracks = [] - tl_tracks = context.core.tracklist.tl_tracks.get() - for tl_track in tl_tracks: - if tl_track.tlid == current_md_track.tlid: - ntl_tracks.append(current_md_track) - else: - ntl_tracks.append(tl_track) - return translator.tracks_to_mpd_format(ntl_tracks) + tl_track = context.core.playback.current_tl_track.get() + position = context.core.tracklist.index(tl_track).get() + return translator.track_to_mpd_format( + tl_track, position=position, stream=stream_ref) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index d33e0afa..e2e73e6f 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -34,12 +34,12 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - tl_track = context.core.playback.current_metadata_track.get() - if tl_track is None: - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.current_tl_track.get() + stream = context.core.playback.get_stream_reference().get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() - return translator.track_to_mpd_format(tl_track, position=position) + return translator.track_to_mpd_format( + tl_track, position=position, stream=stream) @protocol.commands.add('idle', list_command=False) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 23fb2874..37c1493b 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -15,7 +15,7 @@ def normalize_path(path, relative=False): return '/'.join(parts) -def track_to_mpd_format(track, position=None): +def track_to_mpd_format(track, position=None, stream=None): """ Format track for output to MPD client. @@ -33,6 +33,7 @@ def track_to_mpd_format(track, position=None): (tlid, track) = track else: (tlid, track) = (None, track) + result = [ ('file', track.uri or ''), ('Time', track.length and (track.length // 1000) or 0), @@ -41,6 +42,9 @@ def track_to_mpd_format(track, position=None): ('Album', track.album and track.album.name or ''), ] + if stream and stream.name != track.name: + result.append(('Name', stream.name)) + if track.date: result.append(('Date', track.date)) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 3b6435c8..15d2d5f8 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -4,8 +4,12 @@ import unittest import mock +import pykka + from mopidy import backend, core -from mopidy.models import Track +from mopidy.models import Ref, Track + +from tests import dummy_audio as audio class CorePlaybackTest(unittest.TestCase): @@ -525,3 +529,87 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.get_time_position.called) # TODO Test on_tracklist_change + + +# Since we rely on our DummyAudio to actually emit events we need a "real" +# backend and not a mock so the right calls make it through to audio. +class TestBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['dummy'] + + def __init__(self, config, audio): + super(TestBackend, self).__init__() + self.playback = backend.PlaybackProvider(audio=audio, backend=self) + + +class TestStream(unittest.TestCase): + def setUp(self): # noqa: N802 + self.audio = audio.DummyAudio.start().proxy() + self.backend = TestBackend.start(config={}, audio=self.audio).proxy() + self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.playback = self.core.playback + + self.tracks = [Track(uri='dummy:a', length=1234), + Track(uri='dummy:b', length=1234)] + + self.core.tracklist.add(self.tracks) + + self.events = [] + self.patcher = mock.patch('mopidy.audio.listener.AudioListener.send') + self.send_mock = self.patcher.start() + + def send(event, **kwargs): + self.events.append((event, kwargs)) + + self.send_mock.side_effect = send + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + self.patcher.stop() + + def replay_audio_events(self): + while self.events: + event, kwargs = self.events.pop(0) + self.core.on_event(event, **kwargs) + + def test_get_stream_reference_before_playback(self): + self.assertEqual(self.playback.get_stream_reference(), None) + + def test_get_stream_reference_during_playback(self): + self.core.playback.play() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_reference(), None) + + def test_get_stream_reference_during_playback_with_tags_change(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + + self.replay_audio_events() + expected = Ref.track(name='foobar') + self.assertEqual(self.playback.get_stream_reference(), expected) + + def test_get_stream_reference_after_next(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + self.core.playback.next() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_reference(), None) + + def test_get_stream_reference_after_next_with_tags_change(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foo']}).get() + self.core.playback.next() + self.audio.trigger_fake_tags_changed({'title': ['bar']}).get() + + self.replay_audio_events() + expected = Ref.track(name='bar') + self.assertEqual(self.playback.get_stream_reference(), expected) + + def test_get_stream_reference_after_stop(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + self.core.playback.stop() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_reference(), None) From 9a507e17df0c5f3487fac6cff356fbf8697982bf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 8 Mar 2015 21:51:28 +0100 Subject: [PATCH 362/495] docs: Improve pointer to contribution page --- docs/authors.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/authors.rst b/docs/authors.rst index 1a0f21ed..90ec6f23 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -14,7 +14,7 @@ our Git repository. .. include:: ../AUTHORS -If you already enjoy Mopidy, or don't enjoy it and want to help us making -Mopidy better, the best way to do so is to contribute back to the community. -You can contribute code, documentation, tests, bug reports, or help other -users, spreading the word, etc. See :ref:`contributing` for a head start. +If want to help us making Mopidy better, the best way to do so is to contribute +back to the community, either through code, documentation, tests, bug reports, +or by helping other users, spreading the word, etc. See :ref:`contributing` for +a head start. From e655d3938455d02bd7559e310493f97d0b4db5ce Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Thu, 12 Mar 2015 11:43:27 +0100 Subject: [PATCH 363/495] Fix #1031: Add get_images() to local library. --- docs/changelog.rst | 3 +++ mopidy/local/__init__.py | 23 ++++++++++++++++++++++- mopidy/local/library.py | 5 +++++ tests/local/test_library.py | 29 ++++++++++++++++++++++++++++- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9e3fb9d2..64cc2ed0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -88,6 +88,9 @@ v0.20.0 (UNRELEASED) - Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`) +- Add :meth:`mopidy.local.Library.get_images` for looking up images + for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032`) + **File scanner** - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 97ed4a09..eecaa4a2 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -4,7 +4,7 @@ import logging import os import mopidy -from mopidy import config, ext +from mopidy import config, ext, models logger = logging.getLogger(__name__) @@ -101,6 +101,27 @@ class Library(object): """ return set() + def get_images(self, uris): + """ + Lookup the images for the given URIs. + + The default implementation will simply call :meth:`lookup` and + try and use the album art for any tracks returned. Most local + libraries should replace this with something smarter or simply + return an empty dictionary. + + :param list uris: list of URIs to find images for + :rtype: {uri: tuple of :class:`mopidy.models.Image`} + """ + result = {} + for uri in uris: + image_uris = set() + for track in self.lookup(uri): + if track.album and track.album.images: + image_uris.update(track.album.images) + result[uri] = [models.Image(uri=u) for u in image_uris] + return result + def load(self): """ (Re)load any tracks stored in memory, if any, otherwise just return diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 90a54770..77c122bd 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -28,6 +28,11 @@ class LocalLibraryProvider(backend.LibraryProvider): return set() return self._library.get_distinct(field, query) + def get_images(self, uris): + if not self._library: + return {} + return self._library.get_images(uris) + def refresh(self, uri=None): if not self._library: return 0 diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 6cc1992e..13ad9405 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -11,7 +11,7 @@ import pykka from mopidy import core from mopidy.local import actor, json -from mopidy.models import Album, Artist, Track +from mopidy.models import Album, Artist, Image, Track from tests import path_to_data_dir @@ -580,3 +580,30 @@ class LocalLibraryProviderTest(unittest.TestCase): with self.assertRaises(LookupError): self.library.search(any=['']) + + def test_default_get_images_impl_no_images(self): + result = self.library.get_images([track.uri for track in self.tracks]) + self.assertEqual(result, {track.uri: tuple() for track in self.tracks}) + + @mock.patch.object(json.JsonLibrary, 'lookup') + def test_default_get_images_impl_album_images(self, mock_lookup): + library = actor.LocalBackend(config=self.config, audio=None).library + + image = Image(uri='imageuri') + album = Album(images=[image.uri]) + track = Track(uri='trackuri', album=album) + mock_lookup.return_value = [track] + + result = library.get_images([track.uri]) + self.assertEqual(result, {track.uri: [image]}) + + @mock.patch.object(json.JsonLibrary, 'get_images') + def test_local_library_get_images(self, mock_get_images): + library = actor.LocalBackend(config=self.config, audio=None).library + + image = Image(uri='imageuri') + track = Track(uri='trackuri') + mock_get_images.return_value = {track.uri: [image]} + + result = library.get_images([track.uri]) + self.assertEqual(result, {track.uri: [image]}) From 40c7225cb75c940d0e0774c51f1c5e2bec4e88e2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Mar 2015 22:11:33 +0100 Subject: [PATCH 364/495] local: Fix remainder display in local scan --- mopidy/local/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 798c10f8..279fda13 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -177,6 +177,6 @@ class _Progress(object): logger.info('Scanned %d of %d files in %ds.', self.count, self.total, duration) else: - remainder = duration // self.count * (self.total - self.count) + remainder = duration / self.count * (self.total - self.count) logger.info('Scanned %d of %d files in %ds, ~%ds left.', self.count, self.total, duration, remainder) From f4e6956bb749045b35179f99357c551f44d0dfda Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 11 Mar 2015 22:58:41 +0100 Subject: [PATCH 365/495] audio: Catch missing plugins in scanner for better error messages --- mopidy/audio/scan.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 38b86437..c3eec941 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -5,11 +5,14 @@ import time import pygst pygst.require('0.10') import gst # noqa +import gst.pbutils from mopidy import exceptions from mopidy.audio import utils from mopidy.utils import encoding +_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description + class Scanner(object): """ @@ -86,7 +89,11 @@ class Scanner(object): continue message = self._bus.pop() - if message.type == gst.MESSAGE_ERROR: + if message.type == gst.MESSAGE_ELEMENT: + if gst.pbutils.is_missing_plugin_message(message): + description = _missing_plugin_desc(message) + raise exceptions.ScannerError(description) + elif message.type == gst.MESSAGE_ERROR: raise exceptions.ScannerError( encoding.locale_decode(message.parse_error()[0])) elif message.type == gst.MESSAGE_EOS: From cee73b5501b7956ebc6cc5a5712fc31c3ff30523 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 11 Mar 2015 23:09:14 +0100 Subject: [PATCH 366/495] audio: Add support for checking seekable state in scanner Return type of scanner changed to a named tuple with (uri, tags, duration, seekable). This should help with #872 and the related "live" issues. Tests, local scan and stream metadata lookup have been updated to account for the changes. --- mopidy/audio/scan.py | 22 +++++++++++++++++----- mopidy/local/commands.py | 3 ++- mopidy/stream/actor.py | 6 +++--- tests/audio/test_scan.py | 6 +++--- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index c3eec941..d443b8bd 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, division, unicode_literals +import collections import time import pygst @@ -13,6 +14,9 @@ from mopidy.utils import encoding _missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description +Result = collections.namedtuple( + 'Result', ('uri', 'tags', 'duration', 'seekable')) + class Scanner(object): """ @@ -54,19 +58,22 @@ class Scanner(object): :param uri: URI of the resource to scan. :type event: string - :return: (tags, duration) pair. tags is a dictionary of lists for all - the tags we found and duration is the length of the URI in - milliseconds, or :class:`None` if the URI has no duration. + :return: A named tuple containing ``(uri, tags, duration, seekable)``. + ``tags`` is a dictionary of lists for all the tags we found. + ``duration`` is the length of the URI in milliseconds, or + :class:`None` if the URI has no duration. ``seekable`` is boolean + indicating if a seek would succeed. """ - tags, duration = None, None + tags, duration, seekable = None, None, None try: self._setup(uri) tags = self._collect() duration = self._query_duration() + seekable = self._query_seekable() finally: self._reset() - return tags, duration + return Result(uri, tags, duration, seekable) def _setup(self, uri): """Primes the pipeline for collection.""" @@ -123,3 +130,8 @@ class Scanner(object): return None else: return duration // gst.MSECOND + + def _query_seekable(self): + query = gst.query_new_seeking(gst.FORMAT_TIME) + self._pipe.query(query) + return query.parse_seeking()[1] diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 279fda13..af8b0025 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -133,7 +133,8 @@ class ScanCommand(commands.Command): try: relpath = translator.local_track_uri_to_path(uri, media_dir) file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) - tags, duration = scanner.scan(file_uri) + result = scanner.scan(file_uri) + tags, duration = result.tags, result.duration if duration < MIN_DURATION_MS: logger.warning('Failed %s: Track shorter than %dms', uri, MIN_DURATION_MS) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 58fd966a..47bfd58f 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -45,9 +45,9 @@ class StreamLibraryProvider(backend.LibraryProvider): return [Track(uri=uri)] try: - tags, duration = self._scanner.scan(uri) - track = utils.convert_tags_to_track(tags).copy( - uri=uri, length=duration) + result = self._scanner.scan(uri) + track = utils.convert_tags_to_track(result.tags).copy( + uri=uri, length=result.duration) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) track = Track(uri=uri) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 50ec8352..b2937a3f 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -31,9 +31,9 @@ class ScannerTest(unittest.TestCase): uri = path_lib.path_to_uri(path) key = uri[len('file://'):] try: - tags, duration = scanner.scan(uri) - self.tags[key] = tags - self.durations[key] = duration + result = scanner.scan(uri) + self.tags[key] = result.tags + self.durations[key] = result.duration except exceptions.ScannerError as error: self.errors[key] = error From ccd3753b30514a02245f923c39d77a008bb8c847 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 11 Mar 2015 23:14:24 +0100 Subject: [PATCH 367/495] audio: Switch to decodebin2 in scanner and handle our own sources This is needed to be able to put in our own typefind and catch playlists before they make it to the decoder. --- mopidy/audio/scan.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index d443b8bd..c4fca6ad 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -29,24 +29,21 @@ class Scanner(object): def __init__(self, timeout=1000, proxy_config=None): self._timeout_ms = timeout + self._proxy_config = proxy_config or {} sink = gst.element_factory_make('fakesink') - - audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') + self._src = None def pad_added(src, pad): return pad.link(sink.get_pad('sink')) - def source_setup(element, source): - utils.setup_proxy(source, proxy_config or {}) - - self._uribin = gst.element_factory_make('uridecodebin') - self._uribin.set_property('caps', audio_caps) - self._uribin.connect('pad-added', pad_added) - self._uribin.connect('source-setup', source_setup) + audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') + self._decodebin = gst.element_factory_make('decodebin2') + self._decodebin.set_property('caps', audio_caps) + self._decodebin.connect('pad-added', pad_added) self._pipe = gst.element_factory_make('pipeline') - self._pipe.add(self._uribin) + self._pipe.add(self._decodebin) self._pipe.add(sink) self._bus = self._pipe.get_bus() @@ -78,8 +75,16 @@ class Scanner(object): def _setup(self, uri): """Primes the pipeline for collection.""" self._pipe.set_state(gst.STATE_READY) - self._uribin.set_property(b'uri', uri) + + self._src = gst.element_make_from_uri(gst.URI_SRC, uri) + utils.setup_proxy(self._src, self._proxy_config) + + self._pipe.add(self._src) + self._src.sync_state_with_parent() + self._src.link(self._decodebin) + self._bus.set_flushing(False) + result = self._pipe.set_state(gst.STATE_PAUSED) if result == gst.STATE_CHANGE_NO_PREROLL: # Live sources don't pre-roll, so set to playing to get data. @@ -119,6 +124,9 @@ class Scanner(object): """Ensures we cleanup child elements and flush the bus.""" self._bus.set_flushing(True) self._pipe.set_state(gst.STATE_NULL) + self._src.unlink(self._decodebin) + self._pipe.remove(self._src) + self._src = None def _query_duration(self): try: From cd579ff7bbd36b8a69fad05080d6a661b043cc95 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 11 Mar 2015 23:20:30 +0100 Subject: [PATCH 368/495] audio: Going to NULL already handles the flushing for us --- mopidy/audio/scan.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index c4fca6ad..84477def 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -47,7 +47,6 @@ class Scanner(object): self._pipe.add(sink) self._bus = self._pipe.get_bus() - self._bus.set_flushing(True) def scan(self, uri): """ @@ -83,8 +82,6 @@ class Scanner(object): self._src.sync_state_with_parent() self._src.link(self._decodebin) - self._bus.set_flushing(False) - result = self._pipe.set_state(gst.STATE_PAUSED) if result == gst.STATE_CHANGE_NO_PREROLL: # Live sources don't pre-roll, so set to playing to get data. @@ -121,8 +118,7 @@ class Scanner(object): raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) def _reset(self): - """Ensures we cleanup child elements and flush the bus.""" - self._bus.set_flushing(True) + """Ensures we cleanup child elements.""" self._pipe.set_state(gst.STATE_NULL) self._src.unlink(self._decodebin) self._pipe.remove(self._src) From 24cceb69ebf2453b7f8cbd1f6c3126c39f2fd885 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 11 Mar 2015 23:21:41 +0100 Subject: [PATCH 369/495] audio: Going to ready is pointless in this code. --- mopidy/audio/scan.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 84477def..359f31cf 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -73,11 +73,8 @@ class Scanner(object): def _setup(self, uri): """Primes the pipeline for collection.""" - self._pipe.set_state(gst.STATE_READY) - self._src = gst.element_make_from_uri(gst.URI_SRC, uri) utils.setup_proxy(self._src, self._proxy_config) - self._pipe.add(self._src) self._src.sync_state_with_parent() self._src.link(self._decodebin) From c93eaad7ed53e9175a5b88636c1009ca10ae7804 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Mar 2015 00:16:02 +0100 Subject: [PATCH 370/495] audio: Try and reuse source when we can --- mopidy/audio/scan.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 359f31cf..87e60076 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -73,11 +73,21 @@ class Scanner(object): def _setup(self, uri): """Primes the pipeline for collection.""" - self._src = gst.element_make_from_uri(gst.URI_SRC, uri) - utils.setup_proxy(self._src, self._proxy_config) - self._pipe.add(self._src) - self._src.sync_state_with_parent() - self._src.link(self._decodebin) + protocol = gst.uri_get_protocol(uri) + if self._src and protocol not in self._src.get_protocols(): + self._src.unlink(self._decodebin) + self._pipe.remove(self._src) + self._src = None + + if not self._src: + self._src = gst.element_make_from_uri(gst.URI_SRC, uri) + utils.setup_proxy(self._src, self._proxy_config) + self._pipe.add(self._src) + self._src.sync_state_with_parent() + self._src.link(self._decodebin) + + self._pipe.set_state(gst.STATE_READY) + self._src.set_uri(uri) result = self._pipe.set_state(gst.STATE_PAUSED) if result == gst.STATE_CHANGE_NO_PREROLL: @@ -115,11 +125,7 @@ class Scanner(object): raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) def _reset(self): - """Ensures we cleanup child elements.""" self._pipe.set_state(gst.STATE_NULL) - self._src.unlink(self._decodebin) - self._pipe.remove(self._src) - self._src = None def _query_duration(self): try: From 837f2de62985232d9b7140e334ac70816d54933b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Mar 2015 01:06:36 +0100 Subject: [PATCH 371/495] audio: Add typefinder to scanner and add mime to result This should allow us to move playlist handling out of GStreamer as we will short circuit for text/* and application/xml now. --- mopidy/audio/scan.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 87e60076..39cf172e 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -15,7 +15,7 @@ from mopidy.utils import encoding _missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description Result = collections.namedtuple( - 'Result', ('uri', 'tags', 'duration', 'seekable')) + 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime')) class Scanner(object): @@ -37,15 +37,25 @@ class Scanner(object): def pad_added(src, pad): return pad.link(sink.get_pad('sink')) + def have_type(finder, probability, caps): + msg = gst.message_new_application(finder, caps.get_structure(0)) + finder.get_bus().post(msg) + + self._typefinder = gst.element_factory_make('typefind') + self._typefinder.connect('have-type', have_type) + audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') self._decodebin = gst.element_factory_make('decodebin2') self._decodebin.set_property('caps', audio_caps) self._decodebin.connect('pad-added', pad_added) self._pipe = gst.element_factory_make('pipeline') + self._pipe.add(self._typefinder) self._pipe.add(self._decodebin) self._pipe.add(sink) + self._typefinder.link(self._decodebin) + self._bus = self._pipe.get_bus() def scan(self, uri): @@ -54,28 +64,29 @@ class Scanner(object): :param uri: URI of the resource to scan. :type event: string - :return: A named tuple containing ``(uri, tags, duration, seekable)``. + :return: A named tuple containing + ``(uri, tags, duration, seekable, mime)``. ``tags`` is a dictionary of lists for all the tags we found. ``duration`` is the length of the URI in milliseconds, or - :class:`None` if the URI has no duration. ``seekable`` is boolean + :class:`None` if the URI has no duration. ``seekable`` is boolean. indicating if a seek would succeed. """ - tags, duration, seekable = None, None, None + tags, duration, seekable, mime = None, None, None, None try: self._setup(uri) - tags = self._collect() + tags, mime = self._collect() duration = self._query_duration() seekable = self._query_seekable() finally: self._reset() - return Result(uri, tags, duration, seekable) + return Result(uri, tags, duration, seekable, mime) def _setup(self, uri): """Primes the pipeline for collection.""" protocol = gst.uri_get_protocol(uri) if self._src and protocol not in self._src.get_protocols(): - self._src.unlink(self._decodebin) + self._src.unlink(self._typefinder) self._pipe.remove(self._src) self._src = None @@ -83,8 +94,7 @@ class Scanner(object): self._src = gst.element_make_from_uri(gst.URI_SRC, uri) utils.setup_proxy(self._src, self._proxy_config) self._pipe.add(self._src) - self._src.sync_state_with_parent() - self._src.link(self._decodebin) + self._src.link(self._typefinder) self._pipe.set_state(gst.STATE_READY) self._src.set_uri(uri) @@ -98,7 +108,7 @@ class Scanner(object): """Polls for messages to collect data.""" start = time.time() timeout_s = self._timeout_ms / 1000.0 - tags = {} + tags, mime = {}, None while time.time() - start < timeout_s: if not self._bus.have_pending(): @@ -109,14 +119,18 @@ class Scanner(object): if gst.pbutils.is_missing_plugin_message(message): description = _missing_plugin_desc(message) raise exceptions.ScannerError(description) + elif message.type == gst.MESSAGE_APPLICATION: + mime = message.structure.get_name() + if mime.startswith('text/') or mime == 'application/xml': + return tags, mime elif message.type == gst.MESSAGE_ERROR: raise exceptions.ScannerError( encoding.locale_decode(message.parse_error()[0])) elif message.type == gst.MESSAGE_EOS: - return tags + return tags, mime elif message.type == gst.MESSAGE_ASYNC_DONE: if message.src == self._pipe: - return tags + return tags, mime elif message.type == gst.MESSAGE_TAG: taglist = message.parse_tag() # Note that this will only keep the last tag. From 9c9d05be36616f528c9d79d7bc54e48d2e938283 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Mar 2015 21:55:17 +0100 Subject: [PATCH 372/495] audio: Only warn about missing plugin on errors --- mopidy/audio/scan.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 39cf172e..ed8e9eb9 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -108,7 +108,7 @@ class Scanner(object): """Polls for messages to collect data.""" start = time.time() timeout_s = self._timeout_ms / 1000.0 - tags, mime = {}, None + tags, mime, missing_description = {}, None, None while time.time() - start < timeout_s: if not self._bus.have_pending(): @@ -117,15 +117,17 @@ class Scanner(object): if message.type == gst.MESSAGE_ELEMENT: if gst.pbutils.is_missing_plugin_message(message): - description = _missing_plugin_desc(message) - raise exceptions.ScannerError(description) + missing_description = encoding.locale_decode( + _missing_plugin_desc(message)) elif message.type == gst.MESSAGE_APPLICATION: mime = message.structure.get_name() if mime.startswith('text/') or mime == 'application/xml': return tags, mime elif message.type == gst.MESSAGE_ERROR: - raise exceptions.ScannerError( - encoding.locale_decode(message.parse_error()[0])) + error = encoding.locale_decode(message.parse_error()[0]) + if missing_description: + error = '%s (%s)' % (missing_description, error) + raise exceptions.ScannerError(error) elif message.type == gst.MESSAGE_EOS: return tags, mime elif message.type == gst.MESSAGE_ASYNC_DONE: From 411bae5a56aaeb5e59e57bdb5939aa76f398511d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Mar 2015 21:58:27 +0100 Subject: [PATCH 373/495] audio: Raise error for unknown protocol types --- mopidy/audio/scan.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index ed8e9eb9..c4516531 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -92,6 +92,9 @@ class Scanner(object): if not self._src: self._src = gst.element_make_from_uri(gst.URI_SRC, uri) + if not self._src: + raise exceptions.ScannerError('Could not find any elements to ' + 'handle %s URI.' % protocol) utils.setup_proxy(self._src, self._proxy_config) self._pipe.add(self._src) self._src.link(self._typefinder) From 628c8280877e85ba6f027ac3a691a5830e0d2243 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Mar 2015 00:18:50 +0100 Subject: [PATCH 374/495] audio: Recreate scan pipeline for each scan Turns out this code runs a lot faster when we fully destroy the decodebins between scans. And since going to NULL isn't enough I opted to just go for redoing the whole pipeline instead of adding and removing decodebins all the time. As part of this almost all the logic has been ripped out of the scan class and into internal functions. The external interface has been kept the same for now. But we could easily switch to `scan(uri, timeout=1000, proxy=None)` --- mopidy/audio/scan.py | 203 +++++++++++++++++++++---------------------- 1 file changed, 98 insertions(+), 105 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index c4516531..50fb8700 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -14,10 +14,13 @@ from mopidy.utils import encoding _missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description -Result = collections.namedtuple( +_Result = collections.namedtuple( 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime')) +_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') + +# TODO: replace with a scan(uri, timeout=1000, proxy_config=None)? class Scanner(object): """ Helper to get tags and other relevant info from URIs. @@ -31,33 +34,6 @@ class Scanner(object): self._timeout_ms = timeout self._proxy_config = proxy_config or {} - sink = gst.element_factory_make('fakesink') - self._src = None - - def pad_added(src, pad): - return pad.link(sink.get_pad('sink')) - - def have_type(finder, probability, caps): - msg = gst.message_new_application(finder, caps.get_structure(0)) - finder.get_bus().post(msg) - - self._typefinder = gst.element_factory_make('typefind') - self._typefinder.connect('have-type', have_type) - - audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') - self._decodebin = gst.element_factory_make('decodebin2') - self._decodebin.set_property('caps', audio_caps) - self._decodebin.connect('pad-added', pad_added) - - self._pipe = gst.element_factory_make('pipeline') - self._pipe.add(self._typefinder) - self._pipe.add(self._decodebin) - self._pipe.add(sink) - - self._typefinder.link(self._decodebin) - - self._bus = self._pipe.get_bus() - def scan(self, uri): """ Scan the given uri collecting relevant metadata. @@ -72,92 +48,109 @@ class Scanner(object): indicating if a seek would succeed. """ tags, duration, seekable, mime = None, None, None, None + pipeline = _setup_pipeline(uri, self._proxy_config) + try: - self._setup(uri) - tags, mime = self._collect() - duration = self._query_duration() - seekable = self._query_seekable() + _start_pipeline(pipeline) + tags, mime = _process(pipeline, self._timeout_ms / 1000.0) + duration = _query_duration(pipeline) + seekable = _query_seekable(pipeline) finally: - self._reset() + pipeline.set_state(gst.STATE_NULL) + del pipeline - return Result(uri, tags, duration, seekable, mime) + return _Result(uri, tags, duration, seekable, mime) - def _setup(self, uri): - """Primes the pipeline for collection.""" - protocol = gst.uri_get_protocol(uri) - if self._src and protocol not in self._src.get_protocols(): - self._src.unlink(self._typefinder) - self._pipe.remove(self._src) - self._src = None - if not self._src: - self._src = gst.element_make_from_uri(gst.URI_SRC, uri) - if not self._src: - raise exceptions.ScannerError('Could not find any elements to ' - 'handle %s URI.' % protocol) - utils.setup_proxy(self._src, self._proxy_config) - self._pipe.add(self._src) - self._src.link(self._typefinder) +# Turns out it's _much_ faster to just create a new pipeline for every as +# decodebins and other elements don't seem to take well to being reused. +def _setup_pipeline(uri, proxy_config=None): + src = gst.element_make_from_uri(gst.URI_SRC, uri) + if not src: + raise exceptions.ScannerError('GStreamer can not open: %s' % uri) - self._pipe.set_state(gst.STATE_READY) - self._src.set_uri(uri) + typefind = gst.element_factory_make('typefind') + decodebin = gst.element_factory_make('decodebin2') + sink = gst.element_factory_make('fakesink') - result = self._pipe.set_state(gst.STATE_PAUSED) - if result == gst.STATE_CHANGE_NO_PREROLL: - # Live sources don't pre-roll, so set to playing to get data. - self._pipe.set_state(gst.STATE_PLAYING) + pipeline = gst.element_factory_make('pipeline') + pipeline.add_many(src, typefind, decodebin, sink) + gst.element_link_many(src, typefind, decodebin) - def _collect(self): - """Polls for messages to collect data.""" - start = time.time() - timeout_s = self._timeout_ms / 1000.0 - tags, mime, missing_description = {}, None, None + if proxy_config: + utils.setup_proxy(src, proxy_config) - while time.time() - start < timeout_s: - if not self._bus.have_pending(): - continue - message = self._bus.pop() + decodebin.set_property('caps', _RAW_AUDIO) + decodebin.connect('pad-added', _pad_added, sink) + typefind.connect('have-type', _have_type, decodebin) - if message.type == gst.MESSAGE_ELEMENT: - if gst.pbutils.is_missing_plugin_message(message): - missing_description = encoding.locale_decode( - _missing_plugin_desc(message)) - elif message.type == gst.MESSAGE_APPLICATION: - mime = message.structure.get_name() - if mime.startswith('text/') or mime == 'application/xml': - return tags, mime - elif message.type == gst.MESSAGE_ERROR: - error = encoding.locale_decode(message.parse_error()[0]) - if missing_description: - error = '%s (%s)' % (missing_description, error) - raise exceptions.ScannerError(error) - elif message.type == gst.MESSAGE_EOS: + return pipeline + + +def _have_type(element, probability, caps, decodebin): + decodebin.set_property('sink-caps', caps) + msg = gst.message_new_application(element, caps.get_structure(0)) + element.get_bus().post(msg) + + +def _pad_added(element, pad, sink): + return pad.link(sink.get_pad('sink')) + + +def _start_pipeline(pipeline): + if pipeline.set_state(gst.STATE_PAUSED) == gst.STATE_CHANGE_NO_PREROLL: + pipeline.set_state(gst.STATE_PLAYING) + + +def _query_duration(pipeline): + try: + duration = pipeline.query_duration(gst.FORMAT_TIME, None)[0] + except gst.QueryError: + return None + + if duration < 0: + return None + else: + return duration // gst.MSECOND + + +def _query_seekable(pipeline): + query = gst.query_new_seeking(gst.FORMAT_TIME) + pipeline.query(query) + return query.parse_seeking()[1] + + +def _process(pipeline, timeout): + start = time.time() + tags, mime, missing_description = {}, None, None + bus = pipeline.get_bus() + + while time.time() - start < timeout: + if not bus.have_pending(): + continue + message = bus.pop() + + if message.type == gst.MESSAGE_ELEMENT: + if gst.pbutils.is_missing_plugin_message(message): + missing_description = encoding.locale_decode( + _missing_plugin_desc(message)) + elif message.type == gst.MESSAGE_APPLICATION: + mime = message.structure.get_name() + if mime.startswith('text/') or mime == 'application/xml': return tags, mime - elif message.type == gst.MESSAGE_ASYNC_DONE: - if message.src == self._pipe: - return tags, mime - elif message.type == gst.MESSAGE_TAG: - taglist = message.parse_tag() - # Note that this will only keep the last tag. - tags.update(utils.convert_taglist(taglist)) + elif message.type == gst.MESSAGE_ERROR: + error = encoding.locale_decode(message.parse_error()[0]) + if missing_description: + error = '%s (%s)' % (missing_description, error) + raise exceptions.ScannerError(error) + elif message.type == gst.MESSAGE_EOS: + return tags, mime + elif message.type == gst.MESSAGE_ASYNC_DONE: + if message.src == pipeline: + return tags, mime + elif message.type == gst.MESSAGE_TAG: + taglist = message.parse_tag() + # Note that this will only keep the last tag. + tags.update(utils.convert_taglist(taglist)) - raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) - - def _reset(self): - self._pipe.set_state(gst.STATE_NULL) - - def _query_duration(self): - try: - duration = self._pipe.query_duration(gst.FORMAT_TIME, None)[0] - except gst.QueryError: - return None - - if duration < 0: - return None - else: - return duration // gst.MSECOND - - def _query_seekable(self): - query = gst.query_new_seeking(gst.FORMAT_TIME) - self._pipe.query(query) - return query.parse_seeking()[1] + raise exceptions.ScannerError('Timeout after %dms' % (timeout * 1000)) From 73bb6c2a8a18c6e6749878ec4ccd6d36389090e4 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 13 Mar 2015 19:10:57 +0100 Subject: [PATCH 375/495] Replace Mopidy-HTTP-Kuechenradio with Mopidy-Mobile. --- docs/ext/mobile.png | Bin 0 -> 88350 bytes docs/ext/web.rst | 14 +++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 docs/ext/mobile.png diff --git a/docs/ext/mobile.png b/docs/ext/mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..983aa27cf394c7de26c0203b80c29cdeb02e7a5c GIT binary patch literal 88350 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

092SqyyM@YNg7?mh?3+EeJHV^*Ro+% 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 literal 0 HcmV?d00001 diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 5abf5b15..425ecf78 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -30,17 +30,21 @@ To install, run:: pip install Mopidy-API-Explorer -Mopidy-HTTP-Kuechenradio +Mopidy-Mobile ========================= -https://github.com/tkem/mopidy-http-kuechenradio +https://github.com/tkem/mopidy-mobile -A deliberately simple Mopidy Web client for mobile devices. Made with jQuery -Mobile by Thomas Kemmer. +A Mopidy Web client extension and hybrid mobile app, made with Ionic, +AngularJS and Apache Cordova by Thomas Kemmer. + +.. image:: /ext/mobile.png + :width: 1024 + :height: 606 To install, run:: - pip install Mopidy-HTTP-Kuechenradio + pip install Mopidy-Mobile Mopidy-Moped From e4ef6d13caa70f91c51c2cb30462754f117e8ddf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Mar 2015 21:04:37 +0100 Subject: [PATCH 376/495] core: Correct mixer.set_volume() docstring --- mopidy/core/mixer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 1f5ada9e..224c09df 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -25,10 +25,11 @@ class MixerController(object): def set_volume(self, volume): """Set the volume. - The volume is defined as an integer in range [0..100] or :class:`None` - if the mixer is disabled. + The volume is defined as an integer in range [0..100]. The volume scale is linear. + + Returns :class:`True` if call is successful, otherwise :class:`False`. """ if self._mixer is None: return False From b29f9e10c4ada28a07a9c977e9032d834795aa76 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Mar 2015 21:18:28 +0100 Subject: [PATCH 377/495] core: get_mute() with no mixer returns None ...and not False, because the mute state is unknown (None) and not unmuted (False) when there is no mixer. Note that this change does not affect the MPD responses. --- mopidy/core/mixer.py | 4 +--- tests/core/test_mixer.py | 12 ++++++------ tests/mpd/protocol/test_audio_output.py | 10 ++++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 224c09df..3388d706 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -42,9 +42,7 @@ class MixerController(object): :class:`True` if muted, :class:`False` unmuted, :class:`None` if unknown. """ - if self._mixer is None: - return False - else: + if self._mixer is not None: return self._mixer.get_mute().get() def set_mute(self, mute): diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 6485f3e8..c4126eaa 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -42,17 +42,17 @@ class CoreNoneMixerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.core = core.Core(mixer=None, backends=[]) - def test_get_volume_return_none(self): + def test_get_volume_return_none_because_it_is_unknown(self): self.assertEqual(self.core.mixer.get_volume(), None) - def test_set_volume_return_false(self): + def test_set_volume_return_false_because_it_failed(self): self.assertEqual(self.core.mixer.set_volume(30), False) - def test_get_set_mute_return_proper_state(self): - self.assertEqual(self.core.mixer.set_mute(False), False) - self.assertEqual(self.core.mixer.get_mute(), False) + def test_get_mute_return_none_because_it_is_unknown(self): + self.assertEqual(self.core.mixer.get_mute(), None) + + def test_set_mute_return_false_because_it_failed(self): self.assertEqual(self.core.mixer.set_mute(True), False) - self.assertEqual(self.core.mixer.get_mute(), False) @mock.patch.object(mixer.MixerListener, 'send') diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index 322bf181..b42b4c56 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -90,20 +90,22 @@ class AudioOutputHandlerNoneMixerTest(protocol.BaseTestCase): enable_mixer = False def test_enableoutput(self): - self.core.mixer.set_mute(False) + self.assertEqual(self.core.mixer.get_mute().get(), None) self.send_request('enableoutput "0"') self.assertInResponse( 'ACK [52@0] {enableoutput} problems enabling output') - self.assertEqual(self.core.mixer.get_mute().get(), False) + + self.assertEqual(self.core.mixer.get_mute().get(), None) def test_disableoutput(self): - self.core.mixer.set_mute(True) + self.assertEqual(self.core.mixer.get_mute().get(), None) self.send_request('disableoutput "0"') self.assertInResponse( 'ACK [52@0] {disableoutput} problems disabling output') - self.assertEqual(self.core.mixer.get_mute().get(), False) + + self.assertEqual(self.core.mixer.get_mute().get(), None) def test_outputs_when_unmuted(self): self.core.mixer.set_mute(False) From 9adb2c86a9ee2df65c280a0e042d045c6437b38e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Mar 2015 21:20:25 +0100 Subject: [PATCH 378/495] mpd: Make code read better The result of set_mute() and set_volume() is always True or False, never another falsy value like None. --- mopidy/mpd/protocol/audio_output.py | 6 +++--- mopidy/mpd/protocol/playback.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 6ffedcf1..565ea3d0 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -14,7 +14,7 @@ def disableoutput(context, outputid): """ if outputid == 0: success = context.core.mixer.set_mute(False).get() - if success is False: + if not success: raise exceptions.MpdSystemError('problems disabling output') else: raise exceptions.MpdNoExistError('No such audio output') @@ -31,7 +31,7 @@ def enableoutput(context, outputid): """ if outputid == 0: success = context.core.mixer.set_mute(True).get() - if success is False: + if not success: raise exceptions.MpdSystemError('problems enabling output') else: raise exceptions.MpdNoExistError('No such audio output') @@ -49,7 +49,7 @@ def toggleoutput(context, outputid): if outputid == 0: mute_status = context.core.mixer.get_mute().get() success = context.core.mixer.set_mute(not mute_status) - if success is False: + if not success: raise exceptions.MpdSystemError('problems toggling output') else: raise exceptions.MpdNoExistError('No such audio output') diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 4cf8b2e8..86f2e36b 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -397,7 +397,7 @@ def setvol(context, volume): # NOTE: we use INT as clients can pass in +N etc. value = min(max(0, volume), 100) success = context.core.mixer.set_volume(value).get() - if success is False: + if not success: raise exceptions.MpdSystemError('problems setting volume') From 4ce16ce6385ca02e940ee7dc9293799d35a20061 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Mar 2015 21:31:33 +0100 Subject: [PATCH 379/495] docs: Fix header marker --- docs/ext/web.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 425ecf78..b4a9660f 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -31,7 +31,7 @@ To install, run:: Mopidy-Mobile -========================= +============= https://github.com/tkem/mopidy-mobile From 9e8b3263abf5fa8529dedeb46392f8f19eb723f4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Mar 2015 22:36:35 +0100 Subject: [PATCH 380/495] audio: Use timed pop for message loop and gst clocks --- mopidy/audio/scan.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 50fb8700..cbf4c170 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,7 +1,6 @@ from __future__ import absolute_import, division, unicode_literals import collections -import time import pygst pygst.require('0.10') @@ -31,7 +30,7 @@ class Scanner(object): """ def __init__(self, timeout=1000, proxy_config=None): - self._timeout_ms = timeout + self._timeout_ms = int(timeout) self._proxy_config = proxy_config or {} def scan(self, uri): @@ -52,7 +51,7 @@ class Scanner(object): try: _start_pipeline(pipeline) - tags, mime = _process(pipeline, self._timeout_ms / 1000.0) + tags, mime = _process(pipeline, self._timeout_ms) duration = _query_duration(pipeline) seekable = _query_seekable(pipeline) finally: @@ -120,17 +119,19 @@ def _query_seekable(pipeline): return query.parse_seeking()[1] -def _process(pipeline, timeout): - start = time.time() - tags, mime, missing_description = {}, None, None +def _process(pipeline, timeout_ms): + clock = pipeline.get_clock() bus = pipeline.get_bus() + timeout = timeout_ms * gst.MSECOND + tags, mime, missing_description = {}, None, None - while time.time() - start < timeout: - if not bus.have_pending(): - continue - message = bus.pop() + start = clock.get_time() + while timeout > 0: + message = bus.timed_pop(timeout) - if message.type == gst.MESSAGE_ELEMENT: + if message is None: + break + elif message.type == gst.MESSAGE_ELEMENT: if gst.pbutils.is_missing_plugin_message(message): missing_description = encoding.locale_decode( _missing_plugin_desc(message)) @@ -153,4 +154,6 @@ def _process(pipeline, timeout): # Note that this will only keep the last tag. tags.update(utils.convert_taglist(taglist)) - raise exceptions.ScannerError('Timeout after %dms' % (timeout * 1000)) + timeout -= clock.get_time() - start + + raise exceptions.ScannerError('Timeout after %dms' % timeout_ms) From faab0b755af9ceb92b2f80b6a9654a670cf38f19 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Mar 2015 22:39:52 +0100 Subject: [PATCH 381/495] audio: Filter for messages we care about, rest will be dropped --- mopidy/audio/scan.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index cbf4c170..3880d91a 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -125,9 +125,12 @@ def _process(pipeline, timeout_ms): timeout = timeout_ms * gst.MSECOND tags, mime, missing_description = {}, None, None + types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR + | gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG) + start = clock.get_time() while timeout > 0: - message = bus.timed_pop(timeout) + message = bus.timed_pop_filtered(timeout, types) if message is None: break From 6b7f9b4899555c8b2badadc4e2016e0f5ec3ee4d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Mar 2015 22:45:57 +0100 Subject: [PATCH 382/495] docs: Add changelog for the scanner improvements --- docs/changelog.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9e3fb9d2..c5808833 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -144,6 +144,13 @@ v0.20.0 (UNRELEASED) - Update scanner to operate with milliseconds for duration. + - Update scanner to use a custom src, typefind and decodebin. This allows us + to catch playlists before we try to decode them. + + - Refactored scanner to create a new pipeline per song, this is needed as + reseting decodebin is much slower than tearing it down and making a fresh + one. + - Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new tags are found. @@ -163,6 +170,12 @@ v0.20.0 (UNRELEASED) - Add workaround for volume not persisting across tracks on OS X. (Issue: :issue:`886`, PR: :issue:`958`) +- Improved missing plugin error reporting in scanner. + +- Introduced a new return type for the scanner, a named tuple with ``uri``, + ``tags``, ``duration``, ``seekable`` and ``mime``. Also added support for + checking seekable, and the initial MIME type guess. + **Stream backend** - Add basic tests for the stream library provider. From 4db4b4d63b80510ec25805d3ecb39838c1d76f3b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Mar 2015 23:56:51 +0100 Subject: [PATCH 383/495] core: Reduce stream metadata to just the title The API I really want for this to support regular tracks, stream updates and dynamic playlists is still unclear to me. As such I'm taking the KISS approach and reducing this to just the stream title and nothing else. If all goes as planed this will be replaced by playback_track_changed(tlid, ref) style events and other improvements in a later version. --- mopidy/core/actor.py | 7 ++++--- mopidy/core/listener.py | 4 ++-- mopidy/core/playback.py | 24 +++++------------------ mopidy/mpd/actor.py | 2 +- mopidy/mpd/protocol/current_playlist.py | 6 +++--- mopidy/mpd/protocol/status.py | 4 ++-- mopidy/mpd/translator.py | 12 +++++------- tests/core/test_playback.py | 26 ++++++++++++------------- 8 files changed, 34 insertions(+), 51 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 251f6e2c..ed1c33ab 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -5,7 +5,7 @@ import itertools import pykka -from mopidy import audio, backend, mixer, models +from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController @@ -124,8 +124,9 @@ class Core( if not tags or 'title' not in tags or not tags['title']: return - self.playback._stream_ref = models.Ref.track(name=tags['title'][0]) - CoreListener.send('stream_changed') + title = tags['title'][0] + self.playback._stream_title = title + CoreListener.send('stream_title_changed', title=title) class Backends(list): diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index f013fa18..3ae03925 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -164,9 +164,9 @@ class CoreListener(listener.Listener): """ pass - def stream_changed(self): + def stream_title_changed(self, title): """ - Called whenever the currently playing stream changes. + Called whenever the currently playing stream title changes. *MAY* be implemented by actor. """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 6314442b..e92563dd 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -20,7 +20,7 @@ class PlaybackController(object): self.core = core self._current_tl_track = None - self._stream_ref = None + self._stream_title = None self._state = PlaybackState.STOPPED def _get_backend(self): @@ -73,23 +73,9 @@ class PlaybackController(object): Use :meth:`get_current_track` instead. """ - def get_stream_reference(self): - """ - Get additional information about the current stream. - - For most cases this value won't be set, but for radio streams it will - contain a reference with the name of the currently playing track or - program. Clients should show this when available. - - The :class:`mopidy.models.Ref` instance may or may not have an URI set. - If present you can call ``lookup`` on it to get the full metadata for - the URI. - - Returns a :class:`mopidy.models.Ref` instance representing the current - stream. If nothing is playing, or no stream info is available this will - return :class:`None`. - """ - return self._stream_ref + def get_stream_title(self): + """Get the current stream title or :class:`None`.""" + return self._stream_title def get_state(self): """Get The playback state.""" @@ -248,7 +234,7 @@ class PlaybackController(object): self.set_current_tl_track(None) def on_stream_changed(self, uri): - self._stream_ref = None + self._stream_title = None def next(self): """ diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 2c63bcb2..2aecb6d1 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -74,5 +74,5 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def mute_changed(self, mute): self.send_idle('output') - def stream_changed(self): + def stream_title_changed(self, title): self.send_idle('playlist') diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index fdd65bde..d8e1a9d8 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -282,14 +282,14 @@ def plchanges(context, version): elif version == tracklist_version: # A version match could indicate this is just a metadata update, so # check for a stream ref and let the client know about the change. - stream_ref = context.core.playback.get_stream_reference().get() - if stream_ref is None: + stream_title = context.core.playback.get_stream_title().get() + if stream_title is None: return None tl_track = context.core.playback.current_tl_track.get() position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format( - tl_track, position=position, stream=stream_ref) + tl_track, position=position, stream_title=stream_title) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index e2e73e6f..aa78b387 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -35,11 +35,11 @@ def currentsong(context): identified in status). """ tl_track = context.core.playback.current_tl_track.get() - stream = context.core.playback.get_stream_reference().get() + stream_title = context.core.playback.get_stream_title().get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format( - tl_track, position=position, stream=stream) + tl_track, position=position, stream_title=stream_title) @protocol.commands.add('idle', list_command=False) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 37c1493b..10207a69 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -15,7 +15,7 @@ def normalize_path(path, relative=False): return '/'.join(parts) -def track_to_mpd_format(track, position=None, stream=None): +def track_to_mpd_format(track, position=None, stream_title=None): """ Format track for output to MPD client. @@ -23,10 +23,8 @@ def track_to_mpd_format(track, position=None, stream=None): :type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack` :param position: track's position in playlist :type position: integer - :param key: if we should set key - :type key: boolean - :param mtime: if we should set mtime - :type mtime: boolean + :param stream_title: The current streams title. + :type position: string :rtype: list of two-tuples """ if isinstance(track, TlTrack): @@ -42,8 +40,8 @@ def track_to_mpd_format(track, position=None, stream=None): ('Album', track.album and track.album.name or ''), ] - if stream and stream.name != track.name: - result.append(('Name', stream.name)) + if stream_title: + result.append(('Name', stream_title)) if track.date: result.append(('Date', track.date)) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 15d2d5f8..80efc38a 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -571,45 +571,43 @@ class TestStream(unittest.TestCase): event, kwargs = self.events.pop(0) self.core.on_event(event, **kwargs) - def test_get_stream_reference_before_playback(self): - self.assertEqual(self.playback.get_stream_reference(), None) + def test_get_stream_title_before_playback(self): + self.assertEqual(self.playback.get_stream_title(), None) - def test_get_stream_reference_during_playback(self): + def test_get_stream_title_during_playback(self): self.core.playback.play() self.replay_audio_events() - self.assertEqual(self.playback.get_stream_reference(), None) + self.assertEqual(self.playback.get_stream_title(), None) - def test_get_stream_reference_during_playback_with_tags_change(self): + def test_get_stream_title_during_playback_with_tags_change(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.replay_audio_events() - expected = Ref.track(name='foobar') - self.assertEqual(self.playback.get_stream_reference(), expected) + self.assertEqual(self.playback.get_stream_title(), 'foobar') - def test_get_stream_reference_after_next(self): + def test_get_stream_title_after_next(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.core.playback.next() self.replay_audio_events() - self.assertEqual(self.playback.get_stream_reference(), None) + self.assertEqual(self.playback.get_stream_title(), None) - def test_get_stream_reference_after_next_with_tags_change(self): + def test_get_stream_title_after_next_with_tags_change(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'title': ['foo']}).get() self.core.playback.next() self.audio.trigger_fake_tags_changed({'title': ['bar']}).get() self.replay_audio_events() - expected = Ref.track(name='bar') - self.assertEqual(self.playback.get_stream_reference(), expected) + self.assertEqual(self.playback.get_stream_title(), 'bar') - def test_get_stream_reference_after_stop(self): + def test_get_stream_title_after_stop(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.core.playback.stop() self.replay_audio_events() - self.assertEqual(self.playback.get_stream_reference(), None) + self.assertEqual(self.playback.get_stream_title(), None) From 3a61445519473a00904feaed9af10f1e8bdca508 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 00:05:10 +0100 Subject: [PATCH 384/495] models: Change Track.last_modified from seconds to ms --- docs/changelog.rst | 6 ++++++ mopidy/models.py | 7 ++++--- mopidy/utils/path.py | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f06f291d..6c111ff2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,12 @@ v0.20.0 (UNRELEASED) - Add :class:`mopidy.models.Image` model to be returned by :meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`) +- Change the semantics of :attr:`mopidy.models.Track.last_modified` to be + milliseconds instead of seconds since Unix epoch, or a simple counter, + depending on the source of the track. This makes it match the semantics of + :attr:`mopidy.models.Playlist.last_modified`. (Fixes: :issue:`678`, PR: + :issue:`1036`) + **Core API** - Deprecate all properties in the core API. The previously undocumented getter diff --git a/mopidy/models.py b/mopidy/models.py index 4d6ed27d..c0931855 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -378,9 +378,10 @@ class Track(ImmutableObject): #: The MusicBrainz ID of the track. Read-only. musicbrainz_id = None - #: Integer representing when the track was last modified, exact meaning - #: depends on source of track. For local files this is the mtime, for other - #: backends it could be a timestamp or simply a version counter. + #: Integer representing when the track was last modified. Exact meaning + #: depends on source of track. For local files this is the modification + #: time in milliseconds since Unix epoch. For other backends it could be an + #: equivalent timestamp or simply a version counter. last_modified = None def __init__(self, *args, **kwargs): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index c72d3b18..0c0d6676 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -200,7 +200,7 @@ def _find(root, thread_count=10, relative=False, follow=False): def find_mtimes(root, follow=False): results, errors = _find(root, relative=False, follow=follow) - mtimes = dict((f, int(st.st_mtime)) for f, st in results.items()) + mtimes = dict((f, int(st.st_mtime * 1000)) for f, st in results.items()) return mtimes, errors From ea97047607b31303c01cd87ca22ea43ac53182dd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Mar 2015 00:10:21 +0100 Subject: [PATCH 385/495] flake8: Fix bad import --- tests/core/test_playback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 80efc38a..8911978a 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -7,7 +7,7 @@ import mock import pykka from mopidy import backend, core -from mopidy.models import Ref, Track +from mopidy.models import Track from tests import dummy_audio as audio From 6d50f835a4a2374a2c8c9635ccd5ff56e35980c5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Mar 2015 00:22:22 +0100 Subject: [PATCH 386/495] review: docstring update for mpd translator --- mopidy/mpd/translator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 10207a69..77adecd0 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -23,7 +23,7 @@ def track_to_mpd_format(track, position=None, stream_title=None): :type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack` :param position: track's position in playlist :type position: integer - :param stream_title: The current streams title. + :param stream_title: the current streams title :type position: string :rtype: list of two-tuples """ From 36fe8321b112542661ef20cf5753350df8415f6a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Mar 2015 00:25:20 +0100 Subject: [PATCH 387/495] docs: Add changelog entry for stream title stuff --- docs/changelog.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ca36454e..c354f2b9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -40,6 +40,10 @@ v0.20.0 (UNRELEASED) - Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique values for a given field. (Fixes: :issue:`913`, PR: :issue:`1022`) +- Add :meth:`mopidy.core.Listener.stream_title_changed` and + :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients + know about the current song in streams. + **Commands** - Make the ``mopidy`` command print a friendly error message if the @@ -114,6 +118,9 @@ v0.20.0 (UNRELEASED) - Switch the ``list`` command over to using :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`913`) +- Start setting the ``Name`` field which is used for radio streams. + (Fixes: :issue:`944`) + **HTTP frontend** - Prevent race condition in webservice broadcast from breaking the server. From 6260ba00bec4bce4e607ce93ef1fde9aaf2d47f3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Mar 2015 00:30:46 +0100 Subject: [PATCH 388/495] core: Test stream_title_changed listener --- tests/core/test_listener.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index 1338ec5e..8ec3a843 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -58,5 +58,5 @@ class CoreListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_seeked(self): self.listener.seeked(0) - def test_listener_has_default_impl_for_current_metadata_changed(self): - self.listener.current_metadata_changed() + def test_listener_has_default_impl_for_stream_title_changed(self): + self.listener.stream_title_changed('foobar') From abe9b7aea76ebdbfa4eb0883a3417bdcd566e73b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 01:17:52 +0100 Subject: [PATCH 389/495] docs: Initial cleanup of v0.20 changelog --- docs/changelog.rst | 101 +++++++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 49 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1828b108..74c37366 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -30,7 +30,7 @@ v0.20.0 (UNRELEASED) - Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. The old methods on :class:`mopidy.core.PlaybackController` for volume - and mute management has been deprecated. (Fixes: :issue:`962`) + and mute management have been deprecated. (Fixes: :issue:`962`) - Remove ``clear_current_track`` keyword argument to :meth:`mopidy.core.PlaybackController.stop`. It was a leaky internal @@ -48,7 +48,7 @@ v0.20.0 (UNRELEASED) - Add :meth:`mopidy.core.Listener.stream_title_changed` and :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients - know about the current song in streams. + know about the current song in streams. (PR: :issue:`938`, :issue:`1030`) **Commands** @@ -67,50 +67,49 @@ v0.20.0 (UNRELEASED) - Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) -- Add support for configuring :confval:`audio/mixer` to ``none``. (Fixes: - :issue:`936`) - **Logging** - Add custom log level ``TRACE`` (numerical level 5), which can be used by Mopidy and extensions to log at an even more detailed level than ``DEBUG``. -- Add support for per logger color overrides. (Fixes: :issue:`808`) +- Add support for per logger color overrides. (Fixes: :issue:`808`, PR: + :issue:`1005`) **Local backend** -- Local library API: Implementors of :meth:`mopidy.local.Library.lookup` should - now return a list of :class:`~mopidy.models.Track` instead of a single track, - just like the other ``lookup()`` methods in Mopidy. For now, returning a - single track will continue to work. (PR: :issue:`840`) +- Improve error logging for scanner. (Fixes: :issue:`856`, PR: :issue:`874`) -- Add support for giving local libraries direct access to tags and duration. - (Fixes: :issue:`967`) +- Add symlink support with loop protection to file finder. (Fixes: + :issue:`858`, PR: :issue:`874`) -- Add "--force" option for local scan (Fixes: :issue:`910`, PR: :issue:`1010`) +- Add ``--force`` option for ``mopidy local scan`` for forcing a full rescan of + the library. (Fixes: :issue:`910`, PR: :issue:`1010`) -- Stop ignoring ``offset`` and ``limit`` in searches. (Fixes: :issue:`917`, - PR: :issue:`949`) +- Stop ignoring ``offset`` and ``limit`` in searches when using the default + JSON backed local library. (Fixes: :issue:`917`, PR: :issue:`949`) - Removed double triggering of ``playlists_loaded`` event. (Fixes: :issue:`998`, PR: :issue:`999`) - Cleanup and refactoring of local playlist code. Preserves playlist names - better and fixes bug in deletion of playlists. (Fixes: :issue:`937`, + better and fixes bug in deletion of playlists. (Fixes: :issue:`937`, PR: :issue:`995` and rebased into :issue:`1000`) - Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`) +**Local library API** + +- Implementors of :meth:`mopidy.local.Library.lookup` should now return a list + of :class:`~mopidy.models.Track` instead of a single track, just like the + other ``lookup()`` methods in Mopidy. For now, returning a single track will + continue to work. (PR: :issue:`840`) + +- Add support for giving local libraries direct access to tags and duration. + (Fixes: :issue:`967`) + - Add :meth:`mopidy.local.Library.get_images` for looking up images for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032`) -**File scanner** - -- Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) - -- Add symlink support with loop protection to file finder (Fixes: :issue:`858`, - PR: :issue:`874`) - **MPD frontend** - In stored playlist names, replace "/", which are illegal, with "|" instead of @@ -128,26 +127,40 @@ v0.20.0 (UNRELEASED) :confval:`mpd/command_blacklist`. - Switch the ``list`` command over to using - :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`913`) + :meth:`mopidy.core.LibraryController.get_distinct` for increased performance. + (Fixes: :issue:`913`) -- Add support for ``toggleoutput`` command. The ``mixrampdb`` and - ``mixrampdelay`` commands are now supported but throw a NotImplemented - exception. +- Add support for ``toggleoutput`` command. (PR: :issue:`1015`) -- Start setting the ``Name`` field which is used for radio streams. - (Fixes: :issue:`944`) +- The ``mixrampdb`` and ``mixrampdelay`` commands are now known to Mopidy, but + are not implemented. (PR: :issue:`1015`) + +- Start setting the ``Name`` field with the stream title when listening to + radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`) **HTTP frontend** -- Prevent race condition in webservice broadcast from breaking the server. +- Prevent race condition in WebSocket broadcast from breaking the web server. (PR: :issue:`1020`) +**Mixer** + +- Add support for disabling volume control in Mopidy entirely by setting the + configuration :confval:`audio/mixer` to ``none``. (Fixes: :issue:`936`, PR: + :issue:`1015`, :issue:`1035`) + **Audio** - Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end the stream. +- Kill support for visualizers. Feature was originally added as a workaround for + all the people asking for ncmpcpp visualizer support. And since we could get + it almost for free thanks to GStreamer. But this feature didn't really ever + make sense for a server such as Mopidy. Currently the only way to find out if + it is in use and will be missed is to go ahead and remove it. + - Internal code cleanup within audio subsystem: - Started splitting audio code into smaller better defined pieces. @@ -182,22 +195,20 @@ v0.20.0 (UNRELEASED) - Move and rename helper for converting tags to tracks. - - Helper now ignores albums without a name. +- Ignore albums without a name when converting tags to tracks. -- Kill support for visualizers. Feature was originally added as a workaround for - all the people asking for ncmpcpp visualizer support. And since we could get - it almost for free thanks to GStreamer. But this feature didn't really ever - make sense for a server such as Mopidy. Currently the only way to find out if - it is in use and will be missed is to go ahead and remove it. +- Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) - Add workaround for volume not persisting across tracks on OS X. (Issue: :issue:`886`, PR: :issue:`958`) -- Improved missing plugin error reporting in scanner. +- Improved missing plugin error reporting in scanner. (PR: :issue:`1033`) - Introduced a new return type for the scanner, a named tuple with ``uri``, - ``tags``, ``duration``, ``seekable`` and ``mime``. Also added support for - checking seekable, and the initial MIME type guess. + ``tags``, ``duration``, ``seekable`` and ``mime``. (PR: :issue:`1033`) + +- Added support for checking if the media is seekable, and getting the initial + MIME type guess. (PR: :issue:`1033`) **Stream backend** @@ -221,19 +232,11 @@ This version has been released to npm as Mopidy.js v0.5.0. **Development** +- Speed up event emitting. + - Changed test runner from nose to py.test. (PR: :issue:`1024`) -v0.19.6 (UNRELEASED) -==================== - -Bug fix release. - -- Audio: Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) - -- Events: Speed up event emitting. - - v0.19.5 (2014-12-23) ==================== From 29b4a2075aef4ad6375a35bca3f245ad7a3ef128 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sat, 14 Mar 2015 16:12:46 +0100 Subject: [PATCH 390/495] local: Fix get_images() for local libraries returning single track from lookup(). --- mopidy/local/__init__.py | 6 +++++- tests/local/test_library.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index eecaa4a2..542d99f3 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -116,7 +116,11 @@ class Library(object): result = {} for uri in uris: image_uris = set() - for track in self.lookup(uri): + tracks = self.lookup(uri) + # local libraries may return single track + if isinstance(tracks, models.Track): + tracks = [tracks] + for track in tracks: if track.album and track.album.images: image_uris.update(track.album.images) result[uri] = [models.Image(uri=u) for u in image_uris] diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 13ad9405..39f0e53e 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -597,6 +597,18 @@ class LocalLibraryProviderTest(unittest.TestCase): result = library.get_images([track.uri]) self.assertEqual(result, {track.uri: [image]}) + @mock.patch.object(json.JsonLibrary, 'lookup') + def test_default_get_images_impl_single_track(self, mock_lookup): + library = actor.LocalBackend(config=self.config, audio=None).library + + image = Image(uri='imageuri') + album = Album(images=[image.uri]) + track = Track(uri='trackuri', album=album) + mock_lookup.return_value = track + + result = library.get_images([track.uri]) + self.assertEqual(result, {track.uri: [image]}) + @mock.patch.object(json.JsonLibrary, 'get_images') def test_local_library_get_images(self, mock_get_images): library = actor.LocalBackend(config=self.config, audio=None).library From 9a1833a6986f20248cf76f43d5983b8e377ba506 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sat, 14 Mar 2015 16:43:02 +0100 Subject: [PATCH 391/495] Update change log for PR #1037. --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 74c37366..4689c29d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -108,7 +108,7 @@ v0.20.0 (UNRELEASED) (Fixes: :issue:`967`) - Add :meth:`mopidy.local.Library.get_images` for looking up images - for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032`) + for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`) **MPD frontend** From 003454535188d386ac95ec71388b3c20941a89e2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 20:43:34 +0100 Subject: [PATCH 392/495] http: Deprecate http/static_dir config --- docs/changelog.rst | 4 ++++ docs/ext/http.rst | 18 +++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4689c29d..7e619024 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -140,6 +140,10 @@ v0.20.0 (UNRELEASED) **HTTP frontend** +- **Deprecated:** Deprecated the :confval:`http/static_dir` config. Please make + your web clients pip-installable Mopidy extensions to make it easier to + install for end users. + - Prevent race condition in WebSocket broadcast from breaking the web server. (PR: :issue:`1020`) diff --git a/docs/ext/http.rst b/docs/ext/http.rst index 54d44ce0..8745130f 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -73,17 +73,21 @@ See :ref:`config` for general help on configuring Mopidy. .. confval:: http/static_dir + **Deprecated:** This config is deprecated and will be removed in a future + version of Mopidy. + Which directory the HTTP server should serve at "/" Change this to have Mopidy serve e.g. files for your JavaScript client. - "/mopidy" will continue to work as usual even if you change this setting. + "/mopidy" will continue to work as usual even if you change this setting, + but any other Mopidy webclient installed with pip to be served at + "/ext_name" will stop working if you set this config. - This config value isn't deprecated yet, but you're strongly encouraged to - make Mopidy extensions which use the the :ref:`http-server-api` to host - static files on Mopidy's web server instead of using - :confval:`http/static_dir`. That way, installation of your web client will - be a lot easier for your end users, and multiple web clients can easily - share the same web server. + You're strongly encouraged to make Mopidy extensions which use the the + :ref:`http-server-api` to host static files on Mopidy's web server instead + of using :confval:`http/static_dir`. That way, installation of your web + client will be a lot easier for your end users, and multiple web clients + can easily share the same web server. .. confval:: http/zeroconf From 0eb2e1675af6fba7ef7a6f8e3788aac97d7ac6b4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 20:48:12 +0100 Subject: [PATCH 393/495] docs: Highlight deprecations in the changelog --- docs/changelog.rst | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7e619024..aa1aad43 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,16 +21,18 @@ v0.20.0 (UNRELEASED) **Core API** -- Deprecate all properties in the core API. The previously undocumented getter - and setter methods are now the official API. This aligns the Python API with - the WebSocket/JavaScript API. (Fixes: :issue:`952`) +- **Deprecated:** Deprecate all properties in the core API. The previously + undocumented getter and setter methods are now the official API. This aligns + the Python API with the WebSocket/JavaScript API. (Fixes: :issue:`952`) - Add :class:`mopidy.core.HistoryController` which keeps track of what tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`) - Add :class:`mopidy.core.MixerController` which keeps track of volume and - mute. The old methods on :class:`mopidy.core.PlaybackController` for volume - and mute management have been deprecated. (Fixes: :issue:`962`) + mute. (Fixes: :issue:`962`) + +- **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for + volume and mute management have been deprecated. (Fixes: :issue:`962`) - Remove ``clear_current_track`` keyword argument to :meth:`mopidy.core.PlaybackController.stop`. It was a leaky internal @@ -155,9 +157,9 @@ v0.20.0 (UNRELEASED) **Audio** -- Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a - :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end the - stream. +- **Deprecated:** Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. + Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end + the stream. - Kill support for visualizers. Feature was originally added as a workaround for all the people asking for ncmpcpp visualizer support. And since we could get From 3dc19014367a08427a2454eaf3b2ce1f8a1c92e2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 20:59:49 +0100 Subject: [PATCH 394/495] docs: Remove GStreamer elements from extdev guide Mixers are no longer custom GStreamer elements, and while still possible to do, custom GStreamer elements will probably not be as well supported when we port to GStreamer 1.x. --- docs/extensiondev.rst | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 93f627dc..a2a5f463 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -307,12 +307,6 @@ This is ``mopidy_soundspot/__init__.py``:: from .backend import SoundspotBackend registry.add('backend', SoundspotBackend) - # Register a custom GStreamer element - from .mixer import SoundspotMixer - gobject.type_register(SoundspotMixer) - gst.element_register( - SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) - # Or nothing to register e.g. command extension pass @@ -416,17 +410,6 @@ examples, see the :ref:`http-server-api` docs or explore with :ref:`http-explore-extension` extension. -Example GStreamer element -========================= - -If you want to extend Mopidy's GStreamer pipeline with new custom GStreamer -elements, you'll need to register them in GStreamer before they can be used. - -Basically, you just implement your GStreamer element in Python and then make -your :meth:`~mopidy.ext.Extension.setup` method register all your custom -GStreamer elements. - - Running an extension ==================== From 19e120582d1e3ecf26af456960b7120892b1d2c5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 21:06:28 +0100 Subject: [PATCH 395/495] docs: Update API concepts --- docs/api/concepts.rst | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index d127561b..9c542777 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -22,15 +22,16 @@ Frontends ========= Frontends expose Mopidy to the external world. They can implement servers for -protocols like MPD and MPRIS, and they can be used to update other services -when something happens in Mopidy, like the Last.fm scrobbler frontend does. See -:ref:`frontend-api` for more details. +protocols like HTTP, MPD and MPRIS, and they can be used to update other +services when something happens in Mopidy, like the Last.fm scrobbler frontend +does. See :ref:`frontend-api` for more details. .. digraph:: frontend_architecture + "HTTP\nfrontend" -> Core "MPD\nfrontend" -> Core "MPRIS\nfrontend" -> Core - "Last.fm\nfrontend" -> Core + "Scrobbler\nfrontend" -> Core Core @@ -55,6 +56,7 @@ See :ref:`core-api` for more details. Core -> "Library\ncontroller" Core -> "Playback\ncontroller" Core -> "Playlists\ncontroller" + Core -> "History\ncontroller" "Library\ncontroller" -> "Local backend" "Library\ncontroller" -> "Spotify backend" @@ -95,7 +97,8 @@ Audio The audio actor is a thin wrapper around the parts of the GStreamer library we use. If you implement an advanced backend, you may need to implement your own -playback provider using the :ref:`audio-api`. +playback provider using the :ref:`audio-api`, but most backends can use the +default playback provider without any changes. Mixer From 5be2849547266bb5b8d5a7f34a2da72491635919 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 21:51:37 +0100 Subject: [PATCH 396/495] docs: Add missing models to graph, reorder model docs --- docs/api/models.rst | 51 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/api/models.rst b/docs/api/models.rst index 270f3896..23a08002 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -28,21 +28,54 @@ Data model relations .. digraph:: model_relations - Playlist -> Track [ label="has 0..n" ] - Track -> Album [ label="has 0..1" ] - Track -> Artist [ label="has 0..n" ] - Album -> Artist [ label="has 0..n" ] + Ref -> Album [ style="dotted", weight=1 ] + Ref -> Artist [ style="dotted", weight=1 ] + Ref -> Directory [ style="dotted", weight=1 ] + Ref -> Playlist [ style="dotted", weight=1 ] + Ref -> Track [ style="dotted", weight=1 ] - SearchResult -> Artist [ label="has 0..n" ] - SearchResult -> Album [ label="has 0..n" ] - SearchResult -> Track [ label="has 0..n" ] + Playlist -> Track [ label="has 0..n", weight=2 ] + Track -> Album [ label="has 0..1", weight=10 ] + Track -> Artist [ label="has 0..n", weight=10 ] + Album -> Artist [ label="has 0..n", weight=10 ] Image + SearchResult -> Artist [ label="has 0..n", weight=1 ] + SearchResult -> Album [ label="has 0..n", weight=1 ] + SearchResult -> Track [ label="has 0..n", weight=1 ] + + TlTrack -> Track [ label="has 1", weight=20 ] + Data model API ============== -.. automodule:: mopidy.models +.. module:: mopidy.models :synopsis: Data model API - :members: + +.. autoclass:: mopidy.models.Ref + +.. autoclass:: mopidy.models.Track + +.. autoclass:: mopidy.models.Album + +.. autoclass:: mopidy.models.Artist + +.. autoclass:: mopidy.models.Playlist + +.. autoclass:: mopidy.models.Image + +.. autoclass:: mopidy.models.TlTrack + +.. autoclass:: mopidy.models.SearchResult + + +Data model helpers +================== + +.. autoclass:: mopidy.models.ImmutableObject + +.. autoclass:: mopidy.models.ModelJSONEncoder + +.. autofunction:: mopidy.models.model_json_decoder From 6f5ae2f9c4578e0fc910ec64e4376b0471598337 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 22:02:19 +0100 Subject: [PATCH 397/495] docs: Fix syntax error in deprecation notices --- mopidy/core/playback.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index e92563dd..3a492371 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -126,7 +126,7 @@ class PlaybackController(object): def get_volume(self): """ - ... deprecated:: 0.20 + .. deprecated:: 0.20 Use :meth:`core.mixer.get_volume() ` instead. """ @@ -136,7 +136,7 @@ class PlaybackController(object): def set_volume(self, volume): """ - ... deprecated:: 0.20 + .. deprecated:: 0.20 Use :meth:`core.mixer.set_volume() ` instead. """ @@ -155,7 +155,7 @@ class PlaybackController(object): def get_mute(self): """ - ... deprecated:: 0.20 + .. deprecated:: 0.20 Use :meth:`core.mixer.get_mute() ` instead. """ @@ -164,7 +164,7 @@ class PlaybackController(object): def set_mute(self, mute): """ - ... deprecated:: 0.20 + .. deprecated:: 0.20 Use :meth:`core.mixer.set_mute() ` instead. """ From b36083bae6b7602810d9980eb7047d01b116b8bb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 22:02:27 +0100 Subject: [PATCH 398/495] docs: Fix mock of gst.Caps --- docs/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index fbfb11aa..a47901d4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,8 @@ class Mock(object): # glib.get_user_config_dir() return str elif (name[0] == name[0].upper() and + # gst.Caps + not name.startswith('Caps') and # gst.PadTemplate not name.startswith('PadTemplate') and # dbus.String() From a1e866e46e88e1822f918615e4e671a9129cd3fb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 22:06:38 +0100 Subject: [PATCH 399/495] docs: Use sphinx_rtd_theme bundled with Sphinx 1.3 --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index a47901d4..88ea49f0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -114,7 +114,7 @@ modindex_common_prefix = ['mopidy.'] # -- Options for HTML output -------------------------------------------------- -html_theme = 'default' +html_theme = 'sphinx_rtd_theme' html_theme_path = ['_themes'] html_static_path = ['_static'] From 336ef4534ab1d04cda477c7872ebc15957b98a3e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 23:00:09 +0100 Subject: [PATCH 400/495] tests: Use assertEqual instead of assertEquals --- tests/local/test_tracklist.py | 2 +- tests/mpd/protocol/test_command_list.py | 4 ++-- tests/mpd/protocol/test_playback.py | 12 ++++++------ tests/mpd/protocol/test_regression.py | 10 +++++----- tests/test_models.py | 22 +++++++++++----------- tests/utils/test_deps.py | 24 ++++++++++++------------ tests/utils/test_jsonrpc.py | 14 +++++++------- 7 files changed, 44 insertions(+), 44 deletions(-) diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index 5c85ac19..db5de58b 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -310,7 +310,7 @@ class LocalTracklistProviderTest(unittest.TestCase): def test_version_does_not_change_when_adding_nothing(self): version = self.controller.version self.controller.add([]) - self.assertEquals(version, self.controller.version) + self.assertEqual(version, self.controller.version) def test_version_increases_when_adding_something(self): version = self.controller.version diff --git a/tests/mpd/protocol/test_command_list.py b/tests/mpd/protocol/test_command_list.py index 28642b47..bd9a9e6c 100644 --- a/tests/mpd/protocol/test_command_list.py +++ b/tests/mpd/protocol/test_command_list.py @@ -6,7 +6,7 @@ from tests.mpd import protocol class CommandListsTest(protocol.BaseTestCase): def test_command_list_begin(self): response = self.send_request('command_list_begin') - self.assertEquals([], response) + self.assertEqual([], response) def test_command_list_end(self): self.send_request('command_list_begin') @@ -42,7 +42,7 @@ class CommandListsTest(protocol.BaseTestCase): def test_command_list_ok_begin(self): response = self.send_request('command_list_ok_begin') - self.assertEquals([], response) + self.assertEqual([], response) def test_command_list_ok_with_ping(self): self.send_request('command_list_ok_begin') diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 4f3d6d7a..22527e1e 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -273,7 +273,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -286,9 +286,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.core.playback.pause() - self.assertEquals(PAUSED, self.core.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -347,7 +347,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -360,9 +360,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.core.playback.pause() - self.assertEquals(PAUSED, self.core.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index 09ec8a46..6fb59afd 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -29,21 +29,21 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): random.seed(1) # Playlist order: abcfde self.send_request('play') - self.assertEquals( + self.assertEqual( 'dummy:a', self.core.playback.current_track.get().uri) self.send_request('random "1"') self.send_request('next') - self.assertEquals( + self.assertEqual( 'dummy:b', self.core.playback.current_track.get().uri) self.send_request('next') # Should now be at track 'c', but playback fails and it skips ahead - self.assertEquals( + self.assertEqual( 'dummy:f', self.core.playback.current_track.get().uri) self.send_request('next') - self.assertEquals( + self.assertEqual( 'dummy:d', self.core.playback.current_track.get().uri) self.send_request('next') - self.assertEquals( + self.assertEqual( 'dummy:e', self.core.playback.current_track.get().uri) diff --git a/tests/test_models.py b/tests/test_models.py index e7aec877..7711f00d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -54,7 +54,7 @@ class GenericCopyTest(unittest.TestCase): def test_copying_track_to_remove(self): track = Track(name='foo').copy(name=None) - self.assertEquals(track.__dict__, Track().__dict__) + self.assertEqual(track.__dict__, Track().__dict__) class RefTest(unittest.TestCase): @@ -77,7 +77,7 @@ class RefTest(unittest.TestCase): Ref(foo='baz') def test_repr_without_results(self): - self.assertEquals( + self.assertEqual( "Ref(name=u'foo', type=u'artist', uri=u'uri')", repr(Ref(uri='uri', name='foo', type='artist'))) @@ -189,7 +189,7 @@ class ArtistTest(unittest.TestCase): Artist(serialize='baz') def test_repr(self): - self.assertEquals( + self.assertEqual( "Artist(name=u'name', uri=u'uri')", repr(Artist(uri='uri', name='name'))) @@ -353,12 +353,12 @@ class AlbumTest(unittest.TestCase): Album(foo='baz') def test_repr_without_artists(self): - self.assertEquals( + self.assertEqual( "Album(name=u'name', uri=u'uri')", repr(Album(uri='uri', name='name'))) def test_repr_with_artists(self): - self.assertEquals( + self.assertEqual( "Album(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", repr(Album(uri='uri', name='name', artists=[Artist(name='foo')]))) @@ -596,12 +596,12 @@ class TrackTest(unittest.TestCase): Track(foo='baz') def test_repr_without_artists(self): - self.assertEquals( + self.assertEqual( "Track(name=u'name', uri=u'uri')", repr(Track(uri='uri', name='name'))) def test_repr_with_artists(self): - self.assertEquals( + self.assertEqual( "Track(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", repr(Track(uri='uri', name='name', artists=[Artist(name='foo')]))) @@ -830,7 +830,7 @@ class TlTrackTest(unittest.TestCase): self.assertEqual(track2, track) def test_repr(self): - self.assertEquals( + self.assertEqual( "TlTrack(tlid=123, track=Track(uri=u'uri'))", repr(TlTrack(tlid=123, track=Track(uri='uri')))) @@ -962,12 +962,12 @@ class PlaylistTest(unittest.TestCase): Playlist(foo='baz') def test_repr_without_tracks(self): - self.assertEquals( + self.assertEqual( "Playlist(name=u'name', uri=u'uri')", repr(Playlist(uri='uri', name='name'))) def test_repr_with_tracks(self): - self.assertEquals( + self.assertEqual( "Playlist(name=u'name', tracks=[Track(name=u'foo')], uri=u'uri')", repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')]))) @@ -1098,7 +1098,7 @@ class SearchResultTest(unittest.TestCase): SearchResult(foo='baz') def test_repr_without_results(self): - self.assertEquals( + self.assertEqual( "SearchResult(uri=u'uri')", repr(SearchResult(uri='uri'))) diff --git a/tests/utils/test_deps.py b/tests/utils/test_deps.py index 3144fe30..2281765e 100644 --- a/tests/utils/test_deps.py +++ b/tests/utils/test_deps.py @@ -49,13 +49,13 @@ class DepsTest(unittest.TestCase): def test_platform_info(self): result = deps.platform_info() - self.assertEquals('Platform', result['name']) + self.assertEqual('Platform', result['name']) self.assertIn(platform.platform(), result['version']) def test_python_info(self): result = deps.python_info() - self.assertEquals('Python', result['name']) + self.assertEqual('Python', result['name']) self.assertIn(platform.python_implementation(), result['version']) self.assertIn(platform.python_version(), result['version']) self.assertIn('python', result['path']) @@ -64,8 +64,8 @@ class DepsTest(unittest.TestCase): def test_gstreamer_info(self): result = deps.gstreamer_info() - self.assertEquals('GStreamer', result['name']) - self.assertEquals( + self.assertEqual('GStreamer', result['name']) + self.assertEqual( '.'.join(map(str, gst.get_gst_version())), result['version']) self.assertIn('gst', result['path']) self.assertNotIn('__init__.py', result['path']) @@ -99,17 +99,17 @@ class DepsTest(unittest.TestCase): result = deps.pkg_info() - self.assertEquals('Mopidy', result['name']) - self.assertEquals('0.13', result['version']) + self.assertEqual('Mopidy', result['name']) + self.assertEqual('0.13', result['version']) self.assertIn('mopidy', result['path']) dep_info_pykka = result['dependencies'][0] - self.assertEquals('Pykka', dep_info_pykka['name']) - self.assertEquals('1.1', dep_info_pykka['version']) + self.assertEqual('Pykka', dep_info_pykka['name']) + self.assertEqual('1.1', dep_info_pykka['version']) dep_info_setuptools = dep_info_pykka['dependencies'][0] - self.assertEquals('setuptools', dep_info_setuptools['name']) - self.assertEquals('0.6', dep_info_setuptools['version']) + self.assertEqual('setuptools', dep_info_setuptools['name']) + self.assertEqual('0.6', dep_info_setuptools['version']) @mock.patch('pkg_resources.get_distribution') def test_pkg_info_for_missing_dist(self, get_distribution_mock): @@ -117,7 +117,7 @@ class DepsTest(unittest.TestCase): result = deps.pkg_info() - self.assertEquals('Mopidy', result['name']) + self.assertEqual('Mopidy', result['name']) self.assertNotIn('version', result) self.assertNotIn('path', result) @@ -127,6 +127,6 @@ class DepsTest(unittest.TestCase): result = deps.pkg_info() - self.assertEquals('Mopidy', result['name']) + self.assertEqual('Mopidy', result['name']) self.assertNotIn('version', result) self.assertNotIn('path', result) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index 4471a4a0..fb59d06b 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -629,23 +629,23 @@ class JsonRpcInspectorTest(JsonRpcTestBase): methods = inspector.describe() self.assertIn('core.get_uri_schemes', methods) - self.assertEquals(len(methods['core.get_uri_schemes']['params']), 0) + self.assertEqual(len(methods['core.get_uri_schemes']['params']), 0) self.assertIn('core.library.lookup', methods.keys()) - self.assertEquals( + self.assertEqual( methods['core.library.lookup']['params'][0]['name'], 'uri') self.assertIn('core.playback.next', methods) - self.assertEquals(len(methods['core.playback.next']['params']), 0) + self.assertEqual(len(methods['core.playback.next']['params']), 0) self.assertIn('core.playlists.get_playlists', methods) - self.assertEquals( + self.assertEqual( len(methods['core.playlists.get_playlists']['params']), 1) self.assertIn('core.tracklist.filter', methods.keys()) - self.assertEquals( + self.assertEqual( methods['core.tracklist.filter']['params'][0]['name'], 'criteria') - self.assertEquals( + self.assertEqual( methods['core.tracklist.filter']['params'][1]['name'], 'kwargs') - self.assertEquals( + self.assertEqual( methods['core.tracklist.filter']['params'][1]['kwargs'], True) From aed91008a39a9138fbd719c78f1995a328ea2131 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 23:07:59 +0100 Subject: [PATCH 401/495] deps: Add executable path to 'mopidy deps' output --- docs/changelog.rst | 4 ++++ mopidy/utils/deps.py | 9 +++++++++ tests/utils/test_deps.py | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index aa1aad43..81d8a2f1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -61,6 +61,10 @@ v0.20.0 (UNRELEASED) to set the log level for all loggers to the lowest possible value, including log records at levels lover than ``DEBUG`` too. +- Add path to the current ``mopidy`` executable to the output of ``mopidy + deps``. This make it easier to see that a user is using pip-installed Mopidy + instead of APT-installed Mopidy without asking for ``which mopidy`` output. + **Configuration** - Add support for the log level value ``all`` to the loglevels configurations. diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 886b8818..bc9f7c2f 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals import functools import os import platform +import sys import pygst pygst.require('0.10') @@ -24,6 +25,7 @@ def format_dependency_list(adapters=None): for dist_name in dist_names] adapters = [ + executable_info, platform_info, python_info, functools.partial(pkg_info, 'Mopidy', True) @@ -63,6 +65,13 @@ def _format_dependency(dep_info): return '\n'.join(lines) +def executable_info(): + return { + 'name': 'Executable', + 'version': sys.argv[0], + } + + def platform_info(): return { 'name': 'Platform', diff --git a/tests/utils/test_deps.py b/tests/utils/test_deps.py index 2281765e..95f5b982 100644 --- a/tests/utils/test_deps.py +++ b/tests/utils/test_deps.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import platform +import sys import unittest import mock @@ -46,6 +47,12 @@ class DepsTest(unittest.TestCase): self.assertIn(' pylast: 0.5', result) self.assertIn(' setuptools: 0.6', result) + def test_executable_info(self): + result = deps.executable_info() + + self.assertEqual('Executable', result['name']) + self.assertIn(sys.argv[0], result['version']) + def test_platform_info(self): result = deps.platform_info() From 6adeea6009343402507f7d2c7d6c5d80c27d3cd1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 15 Mar 2015 11:29:07 +0100 Subject: [PATCH 402/495] core: Correctly handle missing duration in seek. Seeks will now fail when the duration is None, this is an approximation to if the track is seekable or not. This check is need as otherwise seeking a radio stream will trigger the next track. If the track truly isn't seekable despite having a duration we should still fail as GStreamer will reject the seek. --- mopidy/core/playback.py | 3 +++ mopidy/models.py | 2 +- mopidy/mpd/translator.py | 2 ++ tests/core/test_playback.py | 11 +++++++++++ tests/mpd/test_translator.py | 2 ++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index e92563dd..84ffecb4 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -361,6 +361,9 @@ class PlaybackController(object): if not self.core.tracklist.tracks: return False + if self.current_track and self.current_track.length is None: + return False + if self.get_state() == PlaybackState.STOPPED: self.play() diff --git a/mopidy/models.py b/mopidy/models.py index 4d6ed27d..47f17b6b 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -325,7 +325,7 @@ class Track(ImmutableObject): :param date: track release date (YYYY or YYYY-MM-DD) :type date: string :param length: track length in milliseconds - :type length: integer + :type length: integer or :class:`None` if there is no duration :param bitrate: bitrate in kbit/s :type bitrate: integer :param comment: track comment diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 77adecd0..8359f86b 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -34,6 +34,8 @@ def track_to_mpd_format(track, position=None, stream_title=None): result = [ ('file', track.uri or ''), + # TODO: only show length if not none, see: + # https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110 ('Time', track.length and (track.length // 1000) or 0), ('Artist', artists_to_mpd_format(track.artists)), ('Title', track.name or ''), diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 8911978a..2a28be4d 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -38,6 +38,7 @@ class CorePlaybackTest(unittest.TestCase): Track(uri='dummy2:a', length=40000), Track(uri='dummy3:a', length=40000), # Unplayable Track(uri='dummy1:b', length=40000), + Track(uri='dummy1:c', length=None), # No duration ] self.core = core.Core(mixer=None, backends=[ @@ -46,6 +47,7 @@ class CorePlaybackTest(unittest.TestCase): self.tl_tracks = self.core.tracklist.tl_tracks self.unplayable_tl_track = self.tl_tracks[2] + self.duration_less_tl_track = self.tl_tracks[4] def test_get_current_tl_track_none(self): self.core.playback.set_current_tl_track(None) @@ -478,6 +480,15 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.seek.called) self.assertFalse(self.playback2.seek.called) + def test_seek_fails_for_track_without_duration(self): + self.core.playback.current_tl_track = self.duration_less_tl_track + self.core.playback.state = core.PlaybackState.PLAYING + success = self.core.playback.seek(1000) + + self.assertFalse(success) + self.assertFalse(self.playback1.seek.called) + self.assertFalse(self.playback2.seek.called) + def test_seek_play_stay_playing(self): self.core.playback.play(self.tl_tracks[0]) self.core.playback.state = core.PlaybackState.PLAYING diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 027ce28f..527cfef8 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -34,6 +34,8 @@ class TrackMpdFormatTest(unittest.TestCase): mtime.undo_fake() def test_track_to_mpd_format_for_empty_track(self): + # TODO: this is likely wrong, see: + # https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110 result = translator.track_to_mpd_format(Track()) self.assertIn(('file', ''), result) self.assertIn(('Time', 0), result) From 28d047e1d22e48e15507d5636c8ea3d8a17d604f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 15 Mar 2015 11:42:01 +0100 Subject: [PATCH 403/495] core: Only emit stream title changed for streams This is done by checking for the presence of the organization tag typically set by web streams. This might be a bit to strict and a bad heuristic, but it's currently better than wrongly emitting stream titles for non streams IMO. --- mopidy/core/actor.py | 12 ++++++++---- tests/core/test_playback.py | 5 +++++ tests/dummy_audio.py | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index ed1c33ab..671517ca 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -121,12 +121,16 @@ class Core( return tags = self.audio.get_current_tags().get() - if not tags or 'title' not in tags or not tags['title']: + if not tags: return - title = tags['title'][0] - self.playback._stream_title = title - CoreListener.send('stream_title_changed', title=title) + # TODO: this limits us to only streams that set organization, this is + # a hack to make sure we don't emit stream title changes for plain + # tracks. We need a better way to decide if something is a stream. + if 'title' in tags and tags['title'] and 'organization' in tags: + title = tags['title'][0] + self.playback._stream_title = title + CoreListener.send('stream_title_changed', title=title) class Backends(list): diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 8911978a..809c1385 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -582,6 +582,7 @@ class TestStream(unittest.TestCase): def test_get_stream_title_during_playback_with_tags_change(self): self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.replay_audio_events() @@ -589,6 +590,7 @@ class TestStream(unittest.TestCase): def test_get_stream_title_after_next(self): self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.core.playback.next() @@ -597,8 +599,10 @@ class TestStream(unittest.TestCase): def test_get_stream_title_after_next_with_tags_change(self): self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foo']}).get() self.core.playback.next() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['bar']}).get() self.replay_audio_events() @@ -606,6 +610,7 @@ class TestStream(unittest.TestCase): def test_get_stream_title_after_stop(self): self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.core.playback.stop() diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py index b73946cb..dcf90ffa 100644 --- a/tests/dummy_audio.py +++ b/tests/dummy_audio.py @@ -110,7 +110,7 @@ class DummyAudio(pykka.ThreadingActor): self._state_change_result = False def trigger_fake_tags_changed(self, tags): - self._tags = tags + self._tags.update(tags) audio.AudioListener.send('tags_changed', tags=self._tags.keys()) def get_about_to_finish_callback(self): From 7aee5059431372b74472889409d9f2cc2ab8856c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 16 Mar 2015 23:53:42 +0100 Subject: [PATCH 404/495] docs: Add development environment guide Fixes #994 --- docs/devenv.rst | 593 ++++++++++++++++++++++++++++++++++++ docs/devtools.rst | 43 --- docs/index.rst | 1 + docs/installation/index.rst | 5 +- 4 files changed, 597 insertions(+), 45 deletions(-) create mode 100644 docs/devenv.rst diff --git a/docs/devenv.rst b/docs/devenv.rst new file mode 100644 index 00000000..48a7bc30 --- /dev/null +++ b/docs/devenv.rst @@ -0,0 +1,593 @@ +.. _devenv: + +*********************** +Development environment +*********************** + +This page describes a common development setup for working with Mopidy and +Mopidy extensions. Of course, there may be other ways that work better for you +and the tools you use, but here's one recommended way to do it. + +.. contents:: + :local: + + +Initial setup +============= + +The following steps help you get a good initial setup. They build on each other +to some degree, so if you're not very familiar with Python development it might +be wise to proceed in the order laid out here. + +.. contents:: + :local: + + +Install Mopidy the regular way +------------------------------ + +Install Mopidy the regular way. Mopidy has some non-Python dependencies which +may be tricky to install. Thus we recommend to always start with a full regular +Mopidy install, as described in :ref:`installation`. That is, if you're running +e.g. Debian, start with installing Mopidy from Debian packages. + + +Make a development workspace +---------------------------- + +Make a directory to be used as a workspace for all your Mopidy development:: + + mkdir ~/mopidy-dev + +It will contain all the Git repositories you'll check out when working on +Mopidy and extensions. + + +Make a virtualenv +----------------- + +Make a Python `virtualenv `_ for Mopidy +development. The virtualenv will wall of Mopidy and its dependencies from the +rest of your system. All development and installation of Python dependencies +versions of Mopidy and extensions are done inside the virtualenv. This way your +regular Mopidy install, which you set up in the first step, is unaffected by +your hacking and will always be working. + +Most of us use the `virtualenvwrapper +`_ to ease working with +virtualenvs, so that's what we'll be using for the examples here. First, +install and setup virtualenvwrapper as described in their docs. + +To create a virtualenv named ``mopidy`` which use Python 2.7, allows access to +system-wide packages like GStreamer, and use the Mopidy workspace directory as +the "project path", run:: + + mkvirtualenv -a ~/mopidy-dev --python `which python2.7` \ + --system-site-packages mopidy + +Now, each time you open a terminal and want to activate the ``mopidy`` +virtualenv, run:: + + workon mopidy + +This will both activate the ``mopidy`` virtualenv, and change the current +working directory to ``~/mopidy-dev``. + + +Clone the repo from GitHub +-------------------------- + +Once inside the virtualenv, it's time to clone the ``mopidy/mopidy`` Git repo +from GitHub:: + + git clone https://github.com/mopidy/mopidy.git + +When you've cloned the ``mopidy`` Git repo, ``cd`` into it:: + + cd ~/mopidy-dev/mopidy/ + +With a fresh clone of the Git repo, you should start out on the ``develop`` +branch. This is where all features for the next feature release lands. To +confirm that you're on the right branch, run:: + + git branch + + +Install development tools +------------------------- + +We use a number of Python development tools. The :file:`dev-requirements.txt` +file has comments describing what we use each dependency for, so we might just +as well show include the file verbatim here: + +.. literalinclude:: ../dev-requirements.txt + +You probably won't use all of these development tools, but at least a +majority of them. Install them all into the active virtualenv by running `pip +`_:: + + pip install --upgrade -r dev-requirements.txt + +To upgrade the tools in the future, just rerun the exact same command. + + +Install Mopidy from the Git repo +-------------------------------- + +Next up, we'll want to run Mopidy from the Git repo. There's two reasons for +this: First of all, it lets you easily change the source code, restart Mopidy, +and see the change take effect. Second, it's a convenient way to keep at the +bleeding edge, testing the latest developments in Mopidy itself or test some +extension against the latest Mopidy changes. + +Assuming you're still inside the Git repo, use pip to install Mopidy from the +Git repo in an "editable" form:: + + pip install --editable . + +This will not copy the source code into the virtualenv's ``site-packages`` +directory, but instead create a link there pointing to the Git repo. Using +``cdsitepackages`` from virtualenvwrapper, we can quickly show that the +installed :file:`Mopidy.egg-link` file points back to the Git repo:: + + $ cdsitepackages + $ cat Mopidy.egg-link + /home/user/mopidy-dev/mopidy + .% + $ + +It will also create a ``mopidy`` executable inside the virtualenv that will +always run the latest code from the Git repo. Using another +virtualenvwrapper command, ``cdvirtualenv``, we can show that too:: + + $ cdvirtualenv + $ cat bin/mopidy + ... + +The executable should contain something like this, using :mod:`pkg_resources` +to look up Mopidy's "console script" entry point:: + + #!/home/user/virtualenvs/mopidy/bin/python2 + # EASY-INSTALL-ENTRY-SCRIPT: 'Mopidy==0.19.5','console_scripts','mopidy' + __requires__ = 'Mopidy==0.19.5' + import sys + from pkg_resources import load_entry_point + + if __name__ == '__main__': + sys.exit( + load_entry_point('Mopidy==0.19.5', 'console_scripts', 'mopidy')() + ) + +.. note:: + + It still works to run ``python mopidy`` directly on the + :file:`~/mopidy-dev/mopidy/mopidy/` Python package directory, but if + you don't run the ``pip install`` command above, the extensions bundled + with Mopidy will not be registered with :mod:`pkg_resources`, making Mopidy + quite useless. + +Third, the ``pip install`` command will register the bundled Mopidy +extensions so that Mopidy may find them through :mod:`pkg_resources`. The +result of this can be seen in the Git repo, in a new directory called +:file:`Mopidy.egg-info`, which is ignored by Git. The +:file:`Mopidy.egg-info/entry_points.txt` file is of special interest as it +shows both how the above executable and the bundled extensions are connected to +the Mopidy source code: + +.. code-block:: ini + + [console_scripts] + mopidy = mopidy.__main__:main + + [mopidy.ext] + http = mopidy.http:Extension + local = mopidy.local:Extension + mpd = mopidy.mpd:Extension + softwaremixer = mopidy.softwaremixer:Extension + stream = mopidy.stream:Extension + +.. warning:: + + It's not uncommon to clean up in the Git repo now and then, e.g. by running + ``git clean``. + + If you do this, then the :file:`Mopidy.egg-info` directory will be removed, + and :mod:`pkg_resources` will no longer know how to locate the "console + script" entry point or the bundled Mopidy extensions. + + The fix is simply to run the install command again:: + + pip install --editable . + +Finally, we can go back to the workspace, again using a virtualenvwrapper +tool:: + + cdproject + + +.. _running-from-git: + +Running Mopidy from Git +======================= + +As long as the virtualenv is activated, you can start Mopidy from any +directory. Simply run:: + + mopidy + +To stop it again, press :kbd:`Ctrl+C`. + +Every time you change code in Mopidy or an extension and want to see it +live, you must restart Mopidy. + +If you wan't to iterate quickly while developing, it may sound a bit tedious to +restart Mopidy for every minor change. Then it's useful to have tests to +exercise your code... + + +.. _running-tests: + +Running tests +============= + +Mopidy has quite good test coverage, and we would like all new code going into +Mopidy to come with tests. + +.. contents:: + :local: + + +Test it all +----------- + +You need to know at least one command; the one that runs all the tests:: + + tox + +This will run exactly the same tests as `Travis CI +`_ runs for all our branches and pull +requests. If this command turns green, you can be quite confident that your +pull request will get the green flag from Travis as well, which is a +requirement for it to be merged. + +As this is the ultimate test command, it's also the one taking the most time to +run; up to a minute, depending on your system. But, if you have patience, this +is all you need to know. Always run this command before pushing your changes to +GitHub. + +If you take a look at the tox config file, :file:`tox.ini`, you'll see that tox +runs tests in multiple environments, including a ``flake8`` environment that +lints the source code for issues and a ``docs`` environment that tests that the +documentation can be built. You can also limit tox to just test specific +environments using the ``-e`` option, e.g. to run just unit tests:: + + tox -e py27 + +To learn more, see the `tox documentation `_ . + + +Running unit tests +------------------ + +Under the hood, ``tox -e py27`` will use `pytest `_ as the +test runner. We can also use it directly to run all tests:: + + py.test + +py.test has lots of possibilities, so you'll have to dive into their docs and +plugins to get full benefit from it. To get you interested, here are some +examples. + +We can limit to just tests in a single directory to save time:: + + py.test tests/http/ + +With the help of the pytest-xdist plugin, we can run tests with four processes +in parallel, which usually cuts the test time in half or more:: + + py.test -n 4 + +Another useful feature from pytest-xdist, is the possiblity to stop on the +first test failure, watch the file system for changes, and then rerun the +tests. This makes for a very quick code-test cycle:: + + py.test -f # or --looponfail + +With the help of the pytest-cov plugin, we can get a report on what parts of +the given module, ``mopidy`` in this example, is covered by the test suite:: + + py.test --cov=mopidy --cov-report=term-missing + +.. note:: + + Up to date test coverage statistics can also be viewed online at + `coveralls.io `_. + +If we want to speed up the test suite, we can even get a list of the ten +slowest tests:: + + py.test --durations=10 + +By now, you should be convinced that running py.test directly during +development can be very useful. + + +Continuous integration +---------------------- + +Mopidy uses the free service `Travis CI `_ +for automatically running the test suite when code is pushed to GitHub. This +works both for the main Mopidy repo, but also for any forks. This way, any +contributions to Mopidy through GitHub will automatically be tested by Travis +CI, and the build status will be visible in the GitHub pull request interface, +making it easier to evaluate the quality of pull requests. + +For each success build, Travis submits code coverage data to `coveralls.io +`_. If you're out of work, coveralls might +help you find areas in the code which could need better test coverage. + +In addition, we run a Jenkins CI server at https://ci.mopidy.com/ that runs all +test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to +the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't +tested by Jenkins before it is merged into the ``develop`` branch, which is a +bit late, but good enough to get broad testing before new code is released. + + +.. _code-linting: + +Style checking and linting +-------------------------- + +We're quite pedantic about :ref:`codestyle` and try hard to keep the Mopidy +code base a very clean and nice place to work in. + +Luckily, you can get very far by using the `flake8 +`_ linter to check your code for issues before +submitting a pull request. Mopidy passes all of flake8's checks, with only a +very few exceptions configured in :file:`setup.cfg`. You can either run the +``flake8`` tox environment, like Travis CI will do on your pull request:: + + tox -e flake8 + +Or you can run flake8 directly:: + + flake8 + +If successful, the command will not print anything at all. + +.. note:: + + In some rare cases it doesn't make sense to listen to flake8's warnings. In + those cases, ignore the check by appending ``# noqa: `` to + the source line that triggers the warning. The ``# noqa`` part will make + flake8 skip all checks on the line, while the warning code will help other + developers lookup what you are ignoring. + + +.. _writing-docs: + +Writing documentation +===================== + +To write documentation, we use `Sphinx `_. See their +site for lots of documentation on how to use Sphinx. + +.. note:: + + To generate a few graphs which are part of the documentation, you need some + additional dependencies. You can install them from APT with:: + + sudo apt-get install python-pygraphviz graphviz + +To build the documentation, go into the :file:`docs/` directory:: + + cd ~/mopidy-dev/mopidy/docs/ + +Then, to see all available build targets, run:: + + make + +To generate an HTML version of the documentation, run:: + + make html + +The generated HTML will be available at :file:`_build/html/index.html`. To open +it in a browser you can run either of the following commands, depending on your +OS:: + + xdg-open _build/html/index.html # Linux + open _build/html/index.html # OS X + +The documentation at https://docs.mopidy.com/ is hosted by `Read the Docs +`_, which automatically updates the documentation +when a change is pushed to the ``mopidy/mopidy`` repo at GitHub. + + +Working on extensions +===================== + +Much of the above also applies to Mopidy extensions, though they're often a bit +simpler. They don't have documentation sites and their test suites are either +small and fast, or sadly missing entirely. Most of them use tox and flake8, and +py.test can be used to run their test suites. + +.. contents:: + :local: + + +Installing extensions +--------------------- + +As always, the ``mopidy`` virtualenv should be active when working on +extensions:: + + workon mopidy + +Just like with non-development Mopidy installations, you can install extensions +using pip:: + + pip install Mopidy-Scrobbler + +Installing an extension from its Git repo works the same way as with Mopidy +itself. First, go to the Mopidy workspace:: + + cdproject # or cd ~/mopidy-dev/ + +Clone the desired Mopidy extension:: + + git clone https://github.com/mopidy/mopidy-spotify.git + +Change to the newly created extension directory:: + + cd mopidy-spotify/ + +Then, install the extension in "editable" mode, so that it can be imported from +anywhere inside the virtualenv and the extension is registered and discoverable +through :mod:`pkg_resources`:: + + pip install --editable . + +Every extension will have a ``README.rst`` file. It may contain information +about extra dependencies required, development process, etc. Extensions usually +have a changelog in the readme file. + + +Upgrading extensions +-------------------- + +Extensions often have a much quicker life cycle than Mopidy itself, often with +daily releases in periods of active development. To find outdated extensions in +your virtualenv, you can run:: + + pip search mopidy + +This will list all available Mopidy extensions and compare the installed +versions with the latest available ones. + +To upgrade an extension installed with pip, simply use pip:: + + pip install --upgrade Mopidy-Scrobbler + +To upgrade an extension installed from a Git repo, it's usually enough to pull +the new changes in:: + + cd ~/mopidy-dev/mopidy-spotify/ + git pull + +Of course, if you have local modifications, you'll need to stash these away on +a branch or similar first. + +Depending on the changes to the extension, it may be necessary to update the +metadata about the extension package by installing it in "editable" mode +again:: + + pip install --editable . + + +Contribution workflow +===================== + +Before you being, make sure you've read the :ref:`contributing` page and the +guidelines there. This section will focus more on the practical workflow. + +For the examples, we're making a change to Mopidy. Approximately the same +workflow should work for most Mopidy extensions too. + +.. contents:: + :local: + + +Setting up Git remotes +---------------------- + +Assuming we already have a local Git clone of the upstream Git repo in +:file:`~/mopidy-dev/mopidy/`, we can run ``git remote -v`` to list the +configured remotes of the repo:: + + $ git remote -v + origin https://github.com/mopidy/mopidy.git (fetch) + origin https://github.com/mopidy/mopidy.git (push) + +For clarity, we can rename the ``origin`` remote to ``upstream``:: + + $ git remote rename origin upstream + $ git remote -v + upstream https://github.com/mopidy/mopidy.git (fetch) + upstream https://github.com/mopidy/mopidy.git (push) + +If you haven't already, `fork the repository +`_ to your own GitHub account. + +Then, add the new fork as a remote to your local clone:: + + git remote add myuser git@github.com:myuser/mopidy.git + +The end result is that you have both the upstream repo and your own fork as +remotes:: + + $ git remote -v + myuser git@github.com:myuser/mopidy.git (fetch) + myuser git@github.com:myuser/mopidy.git (push) + upstream https://github.com/mopidy/mopidy.git (fetch) + upstream https://github.com/mopidy/mopidy.git (push) + + +Creating a branch +----------------- + +Fetch the latest data from all remotes without affecting your working +directory:: + + git remote update + +Now, we are ready to create and checkout a new branch off of the upstream +``develop`` branch for our work:: + + git checkout -b fix/666-crash-on-foo upstream/develop + +Do the work, while remembering to adhere to code style, test the changes, make +necessary updates to the documentation, and making small commits with good +commit messages. All as described in :ref:`contributing` and elsewhere in +the :ref:`devenv` guide. + + +Creating a pull request +----------------------- + +When everything is done and committed, push the branch to your fork on GitHub:: + + git push myuser fix/666-crash-on-foo + +Go to the repository on GitHub where you want the change merged, in this case +https://github.com/mopidy/mopidy, and `create a pull request +`_. + + +Updating a pull request +----------------------- + +When the pull request is created, `Travis CI +`__ will run all tests on it. If something +fails, you'll get notified by email. You might as well just fix the issues +right away, as we won't merge a pull request without a green Travis build. See +:ref:`running-tests` on how to run the same tests locally as Travis CI runs on +your pull request. + +When you've fixed the issues, you can update the pull request simply by pushing +more commits to the same branch in your fork:: + + git push myuser fix/666-crash-on-foo + +Likewise, when you get review comments from other developers on your pull +request, you're expected to create additional commits which addresses the +comments. Push them to your branch so that the pull request is updated. + +.. note:: + + Setup the remote as the default push target for your branch:: + + git branch --set-upstream-to myuser/fix/666-crash-on-foo + + Then you can push more commits without specifying the remote:: + + git push diff --git a/docs/devtools.rst b/docs/devtools.rst index 93798071..ec80c543 100644 --- a/docs/devtools.rst +++ b/docs/devtools.rst @@ -5,49 +5,6 @@ Development tools Here you'll find description of the development tools we use. -Continuous integration -====================== - -Mopidy uses the free service `Travis CI `_ -for automatically running the test suite when code is pushed to GitHub. This -works both for the main Mopidy repo, but also for any forks. This way, any -contributions to Mopidy through GitHub will automatically be tested by Travis -CI, and the build status will be visible in the GitHub pull request interface, -making it easier to evaluate the quality of pull requests. - -In addition, we run a Jenkins CI server at http://ci.mopidy.com/ that runs all -test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to -the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't -tested by Jenkins before it is merged into the ``develop`` branch, which is a -bit late, but good enough to get broad testing before new code is released. - -In addition to running tests, the Jenkins CI server also gathers coverage -statistics and uses flake8 to check for errors and possible improvements in our -code. So, if you're out of work, the code coverage and flake8 data at the CI -server should give you a place to start. - - -Documentation writing -===================== - -To write documentation, we use `Sphinx `_. See their -site for lots of documentation on how to use Sphinx. To generate HTML from the -documentation files, you need some additional dependencies. - -You can install them through Debian/Ubuntu package management:: - - sudo apt-get install python-sphinx python-pygraphviz graphviz - -Then, to generate docs:: - - cd docs/ - make # For help on available targets - make html # To generate HTML docs - -The documentation at http://docs.mopidy.com/ is automatically updated when a -documentation update is pushed to ``mopidy/mopidy`` at GitHub. - - Creating releases ================= diff --git a/docs/index.rst b/docs/index.rst index 395e683e..bb16239c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -135,6 +135,7 @@ Development :maxdepth: 1 contributing + devenv devtools codestyle extensiondev diff --git a/docs/installation/index.rst b/docs/installation/index.rst index c8deae59..dba1fb3a 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -7,8 +7,9 @@ Installation There are several ways to install Mopidy. What way is best depends upon your OS and/or distribution. -If you want to contribute to the development of Mopidy, you should first read -the general installation instructions, then have a look at :ref:`run-from-git`. +If you want to contribute to the development of Mopidy, you should first follow +the instructions here to install a regular install of Mopidy, then continue +with reading :ref:`contributing` and :ref:`devenv`. .. toctree:: From e981caf5e9e067ba61cedb0b4a8c35e8e88f5909 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 00:00:35 +0100 Subject: [PATCH 405/495] docs: New contribution guidelines Fixes #830 --- docs/contributing.rst | 198 +++++++++++++++++------------------------- 1 file changed, 79 insertions(+), 119 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index f30e16bd..64f8d74b 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -4,44 +4,81 @@ Contributing ************ -If you are thinking about making Mopidy better, or you just want to hack on it, -that’s great. Here are some tips to get you started. +If you want to contribute to Mopidy, here are some tips to get you started. -Getting started -=============== +.. _asking-questions: -#. Make sure you have a `GitHub account `_. +Asking questions +================ -#. If a ticket does not already exist `submit a ticket - `_ for your issue. - Make sure to clearly describe the issue, and if it is a bug: include steps - to reproduce. +Please use one of these channels for requesting help with Mopidy and its +extensions: -#. Fork the repository on GitHub. +- Our discussion forum: `discuss.mopidy.com `_. + Just sign in and fire away. + +- Our IRC channel: ``#mopidy`` on `irc.freenode.net `_, + with public `searchable logs `_. Be + prepared to hang around for a while, as we're not always around to answer + straight away. + +Before asking for help, it might be worth your time to read the +:ref:`troubleshooting` page, both so you might find a solution to your problem +but also to be able to provide useful details when asking for help. -Making changes -============== +Helping users +============= -#. Clone your fork on GitHub to your computer. +If you want to contribute to Mopidy, a great place to start is by helping other +users on IRC and in the discussion forum. This is a contribution we value +highly. As more people help with user support, new users get faster and better +help. For your own benefit, you'll quickly learn what users find confusing, +difficult or lacking, giving you some ideas for where you may contribute +improvements, either to code or documentation. Lastly, this may also free up +time for other contributors to spend more time on fixing bugs or implementing +new features. -#. Consider making a Python `virtualenv `_ for - Mopidy development to wall of Mopidy and it's dependencies from the rest of - your system. If you do so, create the virtualenv with the - ``--system-site-packages`` flag so that Mopidy can use globally installed - dependencies like GStreamer. If you don't use a virtualenv, you may need to - run the following ``pip`` and ``python setup.py`` commands with ``sudo`` to - install stuff globally on your computer. -#. Install dependencies as described in the :ref:`installation` section. +.. _issue-guidelines: -#. Install additional development dependencies:: +Issue guidelines +================ - pip install -r dev-requirements.txt +#. If you need help, see :ref:`asking-questions` above. The GitHub issue + tracker is not a support forum. -#. Checkout a new branch (usually based on ``develop``) and name it accordingly - to what you intend to do. +#. If you are not sure if is what you're experiencing is a bug or not, post in + the `discussion forum `__ first to verify that + its a bug. + +#. If you are sure that you've found a bug or have a feature request, check if + there's already an issue in the `issue tracker + `_. If there is, see if there is + anything you can add to help reproduce or fix the issue. + +#. If there is no exising issue matching your bug or feature request, create a + `new issue `_. Please include + as much relevant information as possible. If its a bug, including how to + reproduce the bug and any relevant logs or error messages. + + +Pull request guidelines +======================= + +#. Before spending any time on making a pull request: + + - If its a bug, :ref:`file an issue `. + + - If its an enhancement, discuss it with other Mopidy developers first, + either in a GitHub issue, on the discussion forum, or on IRC. Making sure + your ideas and solutions are aligned with other contributors greatly + increase the odds of your pull request being quickly accepted. + +#. Create a new branch, based on the ``develop`` branch, for every feature or + bug fix. Keep branches small and on topic, as that makes them far easier to + review. We often use the following naming convention for branches: - Features get the prefix ``feature/`` @@ -49,105 +86,28 @@ Making changes - Improvements to the documentation get the prefix ``docs/`` +#. Follow the :ref:`code style `, especially make sure the + ``flake8`` linter does not complain about anything. Travis CI will check + that your pull request is "flake8 clean". See :ref:`code-linting`. -.. _run-from-git: +#. Include tests for any new feature or substantial bug fix. See + :ref:`running-tests`. -Running Mopidy from Git -======================= +#. Include documentation for any new feature. See :ref:`writing-docs`. -If you want to hack on Mopidy, you should run Mopidy directly from the Git -repo. +#. Feel free to include a changelog entry in your pull request. The changelog + is in :file:`docs/changelog.rst`. -#. Go to the Git repo root:: +#. Write good commit messages. Here's three blog posts on how to do it right: - cd mopidy/ + - `Writing Git commit messages + `_ -#. To get a ``mopidy`` executable and register all bundled extensions with - setuptools, run:: + - `A Note About Git Commit Messages + `_ - python setup.py develop + - `On commit messages + `_ - It still works to run ``python mopidy`` directly on the ``mopidy`` Python - package directory, but if you have never run ``python setup.py develop`` the - extensions bundled with Mopidy isn't registered with setuptools, so Mopidy - will start without any frontends or backends, making it quite useless. - -#. Now you can run the Mopidy command, and it will run using the code - in the Git repo:: - - mopidy - - If you do any changes to the code, you'll just need to restart ``mopidy`` - to see the changes take effect. - - -Testing -======= - -Mopidy has quite good test coverage, and we would like all new code going into -Mopidy to come with tests. - -#. To run all tests, go to the project directory and run:: - - py.test - - To run tests with test coverage statistics:: - - py.test --cov=mopidy --cov-report=term-missing - - Test coverage statistics can also be viewed online at - `coveralls.io `_. - -#. Always check the code for errors and style issues using flake8:: - - flake8 - - If successful, the command will not print anything at all. Ignore the rare - cases you need to ignore a check use `# noqa: ` so we can lookup what - you are ignoring. - -#. Finally, there is the ultimate but a bit slower command. To run both tests, - docs build, and flake8 linting, run:: - - tox - - This will run exactly the same tests as `Travis CI - `_ runs for all our branches and pull - requests. If this command turns green, you can be quite confident that your - pull request will get the green flag from Travis as well, which is a - requirement for it to be merged. - - -Submitting changes -================== - -- One branch per feature or fix. Keep branches small and on topic. - -- Follow the :ref:`code style `, especially make sure ``flake8`` - does not complain about anything. - -- Write good commit messages. Here's three blog posts on how to do it right: - - - `Writing Git commit messages - `_ - - - `A Note About Git Commit Messages - `_ - - - `On commit messages - `_ - -- Send a pull request to the ``develop`` branch. See the `GitHub pull request - docs `_ for help. - - -Additional resources -==================== - -- IRC channel: ``#mopidy`` at `irc.freenode.net `_ - -- `Issue tracker `_ - -- `Mailing List `_ - -- `GitHub documentation `_ +#. Send a pull request to the ``develop`` branch. See the `GitHub pull request + docs `_ for help. From b2b7b8708198a87d307266aa2a19f1cbe0757320 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 01:01:15 +0100 Subject: [PATCH 406/495] docs: Make devtools a pure release procedure page --- docs/index.rst | 2 +- docs/{devtools.rst => releasing.rst} | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) rename docs/{devtools.rst => releasing.rst} (93%) diff --git a/docs/index.rst b/docs/index.rst index bb16239c..06af9dcd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -136,7 +136,7 @@ Development contributing devenv - devtools + releasing codestyle extensiondev diff --git a/docs/devtools.rst b/docs/releasing.rst similarity index 93% rename from docs/devtools.rst rename to docs/releasing.rst index ec80c543..8a12cf7d 100644 --- a/docs/devtools.rst +++ b/docs/releasing.rst @@ -1,8 +1,10 @@ -***************** -Development tools -***************** +****************** +Release procedures +****************** -Here you'll find description of the development tools we use. +Here we try to keep an up to date record of how Mopidy releases are made. This +documentation serves both as a checklist, to reduce the project's dependency on +key individuals, and as a stepping stone to more automation. Creating releases From c90f08d8ea66301d55a3e249a406b2c31daf4571 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 01:13:25 +0100 Subject: [PATCH 407/495] docs: Show another level of the about section in the ToC --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 06af9dcd..e91c491c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -132,7 +132,7 @@ Development =========== .. toctree:: - :maxdepth: 1 + :maxdepth: 2 contributing devenv From b6b872b9c2dde4049ef0d8c936b38329fd7361ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 12:20:35 +0100 Subject: [PATCH 408/495] docs: Fix typos from review Co-Authored-By: Nick Steel --- docs/contributing.rst | 14 +++++++------- docs/devenv.rst | 42 +++++++++++++++++++++--------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 64f8d74b..1b3b1330 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -49,9 +49,9 @@ Issue guidelines #. If you need help, see :ref:`asking-questions` above. The GitHub issue tracker is not a support forum. -#. If you are not sure if is what you're experiencing is a bug or not, post in - the `discussion forum `__ first to verify that - its a bug. +#. If you are not sure if what you're experiencing is a bug or not, post in the + `discussion forum `__ first to verify that it's + a bug. #. If you are sure that you've found a bug or have a feature request, check if there's already an issue in the `issue tracker @@ -60,7 +60,7 @@ Issue guidelines #. If there is no exising issue matching your bug or feature request, create a `new issue `_. Please include - as much relevant information as possible. If its a bug, including how to + as much relevant information as possible. If it's a bug, including how to reproduce the bug and any relevant logs or error messages. @@ -69,12 +69,12 @@ Pull request guidelines #. Before spending any time on making a pull request: - - If its a bug, :ref:`file an issue `. + - If it's a bug, :ref:`file an issue `. - - If its an enhancement, discuss it with other Mopidy developers first, + - If it's an enhancement, discuss it with other Mopidy developers first, either in a GitHub issue, on the discussion forum, or on IRC. Making sure your ideas and solutions are aligned with other contributors greatly - increase the odds of your pull request being quickly accepted. + increases the odds of your pull request being quickly accepted. #. Create a new branch, based on the ``develop`` branch, for every feature or bug fix. Keep branches small and on topic, as that makes them far easier to diff --git a/docs/devenv.rst b/docs/devenv.rst index 48a7bc30..c67426f7 100644 --- a/docs/devenv.rst +++ b/docs/devenv.rst @@ -47,19 +47,19 @@ Make a virtualenv ----------------- Make a Python `virtualenv `_ for Mopidy -development. The virtualenv will wall of Mopidy and its dependencies from the -rest of your system. All development and installation of Python dependencies -versions of Mopidy and extensions are done inside the virtualenv. This way your -regular Mopidy install, which you set up in the first step, is unaffected by -your hacking and will always be working. +development. The virtualenv will wall off Mopidy and its dependencies from the +rest of your system. All development and installation of Python dependencies, +versions of Mopidy, and extensions are done inside the virtualenv. This way +your regular Mopidy install, which you set up in the first step, is unaffected +by your hacking and will always be working. Most of us use the `virtualenvwrapper `_ to ease working with virtualenvs, so that's what we'll be using for the examples here. First, install and setup virtualenvwrapper as described in their docs. -To create a virtualenv named ``mopidy`` which use Python 2.7, allows access to -system-wide packages like GStreamer, and use the Mopidy workspace directory as +To create a virtualenv named ``mopidy`` which uses Python 2.7, allows access to +system-wide packages like GStreamer, and uses the Mopidy workspace directory as the "project path", run:: mkvirtualenv -a ~/mopidy-dev --python `which python2.7` \ @@ -87,7 +87,7 @@ When you've cloned the ``mopidy`` Git repo, ``cd`` into it:: cd ~/mopidy-dev/mopidy/ With a fresh clone of the Git repo, you should start out on the ``develop`` -branch. This is where all features for the next feature release lands. To +branch. This is where all features for the next feature release land. To confirm that you're on the right branch, run:: git branch @@ -98,12 +98,11 @@ Install development tools We use a number of Python development tools. The :file:`dev-requirements.txt` file has comments describing what we use each dependency for, so we might just -as well show include the file verbatim here: +as well include the file verbatim here: .. literalinclude:: ../dev-requirements.txt -You probably won't use all of these development tools, but at least a -majority of them. Install them all into the active virtualenv by running `pip +Install them all into the active virtualenv by running `pip `_:: pip install --upgrade -r dev-requirements.txt @@ -115,7 +114,7 @@ Install Mopidy from the Git repo -------------------------------- Next up, we'll want to run Mopidy from the Git repo. There's two reasons for -this: First of all, it lets you easily change the source code, restart Mopidy, +this: first of all, it lets you easily change the source code, restart Mopidy, and see the change take effect. Second, it's a convenient way to keep at the bleeding edge, testing the latest developments in Mopidy itself or test some extension against the latest Mopidy changes. @@ -220,7 +219,7 @@ To stop it again, press :kbd:`Ctrl+C`. Every time you change code in Mopidy or an extension and want to see it live, you must restart Mopidy. -If you wan't to iterate quickly while developing, it may sound a bit tedious to +If you want to iterate quickly while developing, it may sound a bit tedious to restart Mopidy for every minor change. Then it's useful to have tests to exercise your code... @@ -282,8 +281,8 @@ We can limit to just tests in a single directory to save time:: py.test tests/http/ -With the help of the pytest-xdist plugin, we can run tests with four processes -in parallel, which usually cuts the test time in half or more:: +With the help of the pytest-xdist plugin, we can run tests with four Python +processes in parallel, which usually cuts the test time in half or more:: py.test -n 4 @@ -294,7 +293,7 @@ tests. This makes for a very quick code-test cycle:: py.test -f # or --looponfail With the help of the pytest-cov plugin, we can get a report on what parts of -the given module, ``mopidy`` in this example, is covered by the test suite:: +the given module, ``mopidy`` in this example, are covered by the test suite:: py.test --cov=mopidy --cov-report=term-missing @@ -322,15 +321,16 @@ contributions to Mopidy through GitHub will automatically be tested by Travis CI, and the build status will be visible in the GitHub pull request interface, making it easier to evaluate the quality of pull requests. -For each success build, Travis submits code coverage data to `coveralls.io +For each successful build, Travis submits code coverage data to `coveralls.io `_. If you're out of work, coveralls might help you find areas in the code which could need better test coverage. In addition, we run a Jenkins CI server at https://ci.mopidy.com/ that runs all -test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to -the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't -tested by Jenkins before it is merged into the ``develop`` branch, which is a -bit late, but good enough to get broad testing before new code is released. +tests on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push +to the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code +isn't tested by Jenkins before it is merged into the ``develop`` branch, which +is a bit late, but good enough to get broad testing before new code is +released. .. _code-linting: From 8a0bf3c25f2a07f012443337c3793a099306c742 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 13:16:39 +0100 Subject: [PATCH 409/495] docs: Include commit message tips in the guidelines --- docs/contributing.rst | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 1b3b1330..ecfaea90 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -98,7 +98,19 @@ Pull request guidelines #. Feel free to include a changelog entry in your pull request. The changelog is in :file:`docs/changelog.rst`. -#. Write good commit messages. Here's three blog posts on how to do it right: +#. Write good commit messages. + + - Follow the template "topic: description" for the first line of the commit + message, e.g. "mpd: Switch list command to using list_distinct". See the + commit history for inspiration. + + - Use the rest of the commit message to explain anything you feel isn't + obvious. It's better to have the details here than in the pull request + description, since the commit message will live forever. + + - Write in the imperative, present tense: "add" not "added". + + For more inspiration, read these blog posts: - `Writing Git commit messages `_ From b90d18c8ac4be31d404aabc74b171f0a89dfcfe9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 Mar 2015 20:56:16 +0100 Subject: [PATCH 410/495] audio: Reduce most buffering message to trace level --- mopidy/audio/actor.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 63b0eebe..e137b944 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -343,15 +343,18 @@ class _Handler(object): self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy') def on_buffering(self, percent): - gst_logger.debug('Got buffering message: percent=%d%%', percent) - + level = logging.getLevelName('TRACE') if percent < 10 and not self._audio._buffering: self._audio._playbin.set_state(gst.STATE_PAUSED) self._audio._buffering = True + level = logging.DEBUG if percent == 100: self._audio._buffering = False if self._audio._target_state == gst.STATE_PLAYING: self._audio._playbin.set_state(gst.STATE_PLAYING) + level = logging.DEBUG + + gst_logger.log(level, 'Got buffering message: percent=%d%%', percent) def on_end_of_stream(self): gst_logger.debug('Got end-of-stream message.') From 8983608992c6c36d10e8da4f6902f8b9a19213c1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 Mar 2015 20:56:58 +0100 Subject: [PATCH 411/495] audio: Never buffer live sources as they would stall --- mopidy/audio/actor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index e137b944..4805e617 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -279,7 +279,7 @@ class _Handler(object): if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element: self.on_playbin_state_changed(*msg.parse_state_changed()) elif msg.type == gst.MESSAGE_BUFFERING: - self.on_buffering(msg.parse_buffering()) + self.on_buffering(msg.parse_buffering(), msg.structure) elif msg.type == gst.MESSAGE_EOS: self.on_end_of_stream() elif msg.type == gst.MESSAGE_ERROR: @@ -342,7 +342,11 @@ class _Handler(object): gst.DEBUG_BIN_TO_DOT_FILE( self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy') - def on_buffering(self, percent): + def on_buffering(self, percent, structure=None): + if structure and structure.has_field('buffering-mode'): + if structure['buffering-mode'] == gst.BUFFERING_LIVE: + return # Live sources stall in paused. + level = logging.getLevelName('TRACE') if percent < 10 and not self._audio._buffering: self._audio._playbin.set_state(gst.STATE_PAUSED) From b1448f584f0d90a697a1978e458729a7175c1449 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 Mar 2015 21:08:20 +0100 Subject: [PATCH 412/495] audio: Remove download flag from audio (fixes #1041) This should resolve the issue where Mopidy tries and download way to much of a remote track before playing it. --- mopidy/audio/actor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 4805e617..788fbab4 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -48,8 +48,10 @@ MB = 1 << 20 # GST_PLAY_FLAG_DEINTERLACE (1<<9) # GST_PLAY_FLAG_SOFT_COLORBALANCE (1<<10) -# Default flags to use for playbin: AUDIO, SOFT_VOLUME, DOWNLOAD -PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7) +# Default flags to use for playbin: AUDIO, SOFT_VOLUME +# TODO: consider removing soft volume when we do multi outputs and handling it +# ourselves. +PLAYBIN_FLAGS = (1 << 1) | (1 << 4) class _Signals(object): From bdee9478893936e4e51b12d2258ec6f585f8aa23 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 21:24:32 +0100 Subject: [PATCH 413/495] docs: Fix review comments --- docs/conf.py | 2 +- docs/contributing.rst | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 88ea49f0..22ecb6fa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -84,7 +84,7 @@ def setup(app): # -- General configuration ---------------------------------------------------- -needs_sphinx = '1.0' +needs_sphinx = '1.3' extensions = [ 'sphinx.ext.autodoc', diff --git a/docs/contributing.rst b/docs/contributing.rst index ecfaea90..b5230b18 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -12,13 +12,14 @@ If you want to contribute to Mopidy, here are some tips to get you started. Asking questions ================ -Please use one of these channels for requesting help with Mopidy and its -extensions: +Please get in touch with us in one of these ways when requesting help with +Mopidy and its extensions: - Our discussion forum: `discuss.mopidy.com `_. Just sign in and fire away. -- Our IRC channel: ``#mopidy`` on `irc.freenode.net `_, +- Our IRC channel: `#mopidy `_ + on `irc.freenode.net `_, with public `searchable logs `_. Be prepared to hang around for a while, as we're not always around to answer straight away. @@ -80,11 +81,13 @@ Pull request guidelines bug fix. Keep branches small and on topic, as that makes them far easier to review. We often use the following naming convention for branches: - - Features get the prefix ``feature/`` + - Features get the prefix ``feature/``, e.g. + ``feature/track-last-modified-as-ms``. - - Bug fixes get the prefix ``fix/`` + - Bug fixes get the prefix ``fix/``, e.g. ``fix/902-consume-track-on-next``. - - Improvements to the documentation get the prefix ``docs/`` + - Improvements to the documentation get the prefix ``docs/``, e.g. + ``docs/add-ext-mopidy-spotify-tunigo``. #. Follow the :ref:`code style `, especially make sure the ``flake8`` linter does not complain about anything. Travis CI will check @@ -110,7 +113,7 @@ Pull request guidelines - Write in the imperative, present tense: "add" not "added". - For more inspiration, read these blog posts: + For more inspiration, feel free to read these blog posts: - `Writing Git commit messages `_ From 3559e61d75ef2a32f63e3cb54c9ad1e276b3b8dd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 21:34:20 +0100 Subject: [PATCH 414/495] docs: Don't require Sphinx 1.3 to build --- docs/conf.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 22ecb6fa..71813ad7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -84,7 +84,7 @@ def setup(app): # -- General configuration ---------------------------------------------------- -needs_sphinx = '1.3' +needs_sphinx = '1.0' extensions = [ 'sphinx.ext.autodoc', @@ -114,7 +114,10 @@ modindex_common_prefix = ['mopidy.'] # -- Options for HTML output -------------------------------------------------- -html_theme = 'sphinx_rtd_theme' +# 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when +# building the docs as part of the Debian packages on e.g. Debian wheezy. +#html_theme = 'sphinx_rtd_theme' +html_theme = 'default' html_theme_path = ['_themes'] html_static_path = ['_static'] From 4972d1da57a1ecbc0a4f8c39567aee8b71d809ef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 22:02:34 +0100 Subject: [PATCH 415/495] Decode all strerror-based exception messages I reviewed all instances of: - EnvironmentError - OSError - IOError - socket.error In most cases, we already used encoding.locale_decode(). The case fixed in mopidy/utils/network.py fixes #971. The case fixed in mopidy/utils/path.py might be triggered during a local library scan. --- mopidy/utils/network.py | 3 ++- mopidy/utils/path.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index ce02ef0e..f55649e3 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -199,7 +199,8 @@ class Connection(object): except socket.error as e: if e.errno in (errno.EWOULDBLOCK, errno.EINTR): return data - self.stop('Unexpected client error: %s' % e) + self.stop( + 'Unexpected client error: %s' % encoding.locale_decode(e)) return b'' def enable_timeout(self): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 0c0d6676..8bca275d 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -12,6 +12,7 @@ import glib from mopidy import compat, exceptions from mopidy.compat import queue +from mopidy.utils import encoding logger = logging.getLogger(__name__) @@ -157,7 +158,8 @@ def _find_worker(relative, follow, done, work, results, errors): errors[path] = exceptions.FindError('Not a file or directory.') except OSError as e: - errors[path] = exceptions.FindError(e.strerror, e.errno) + errors[path] = exceptions.FindError( + encoding.locale_decode(e.strerror), e.errno) finally: work.task_done() From aae545f2fee44a5da7e0dc3f1e4ef82e0caff52b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 22:07:55 +0100 Subject: [PATCH 416/495] docs: Update changelog for PR#1044 --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 81d8a2f1..33bfc9f7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -144,6 +144,10 @@ v0.20.0 (UNRELEASED) - Start setting the ``Name`` field with the stream title when listening to radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`) +- Fix crash on socket error when using a locale causing the exception's error + message to contain characters not in ASCII. (Fixes: issue:`971`, PR: + :issue:`1044`) + **HTTP frontend** - **Deprecated:** Deprecated the :confval:`http/static_dir` config. Please make From fdc84c3905dc7b0d69414b9a1adcab6587b60fa7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 Mar 2015 23:41:09 +0100 Subject: [PATCH 417/495] core: Add uris argument to library.lookup (Fixes #1008) For now this doesn't add any corresponding APIs to backends, or for that matter tracklist.add(uris). This is just to get the API in for clients in 0.20. --- docs/changelog.rst | 3 +++ mopidy/core/library.py | 35 +++++++++++++++++++++++++++-------- tests/core/test_library.py | 11 +++++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 33bfc9f7..33468f60 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,6 +31,9 @@ v0.20.0 (UNRELEASED) - Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. (Fixes: :issue:`962`) +- Add ``uris`` argument to :method:`mopidy.core.LibraryController.lookup` + which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`) + - **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for volume and mute management have been deprecated. (Fixes: :issue:`962`) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 49a4a796..906618d8 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -162,7 +162,7 @@ class LibraryController(object): in self._get_backends_to_uris(uris).items()] return [result for result in pykka.get_all(futures) if result] - def lookup(self, uri): + def lookup(self, uri=None, uris=None): """ Lookup the given URI. @@ -170,14 +170,33 @@ class LibraryController(object): them all. :param uri: track URI - :type uri: string - :rtype: list of :class:`mopidy.models.Track` + :type uri: string or :class:`None` + :param uris: track URIs + :type uris: list of string or :class:`None` + :rtype: list of :class:`mopidy.models.Track` if uri was set or a + ``{uri: list of tracks}`` if uris was set. """ - backend = self._get_backend(uri) - if backend: - return backend.library.lookup(uri).get() - else: - return [] + none_set = uri is None and uris is None + both_set = uri is not None and uris is not None + + if none_set or both_set: + raise ValueError("One of 'uri' or 'uris' must be set") + + futures = {} + result = {} + backends = self._get_backends_to_uris([uri] if uri else uris) + + # TODO: lookup(uris) to backend APIs + for backend, backend_uris in backends.items(): + for u in backend_uris or []: + futures[u] = backend.library.lookup(u) + + for u, future in futures.items(): + result[u] = future.get() + + if uri: + return result.get(uri, []) + return result def refresh(self, uri=None): """ diff --git a/tests/core/test_library.py b/tests/core/test_library.py index ccf1b349..b71e5de5 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -157,6 +157,17 @@ class CoreLibraryTest(unittest.TestCase): self.assertFalse(self.library1.lookup.called) self.library2.lookup.assert_called_once_with('dummy2:a') + def test_lookup_fails_with_uri_and_uris_set(self): + with self.assertRaises(ValueError): + self.core.library.lookup('dummy1:a', ['dummy2:a']) + + def test_lookup_can_handle_uris(self): + self.library1.lookup().get.return_value = [1234] + self.library2.lookup().get.return_value = [5678] + + result = self.core.library.lookup(uris=['dummy1:a', 'dummy2:a']) + self.assertEqual(result, {'dummy2:a': [5678], 'dummy1:a': [1234]}) + def test_lookup_returns_nothing_for_dummy3_track(self): result = self.core.library.lookup('dummy3:a') From 08bdf5c14bc7790be31586cc1c32852fedb6a777 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Mar 2015 00:10:45 +0100 Subject: [PATCH 418/495] core: Update library.lookup() docstring --- mopidy/core/library.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 906618d8..ec94dccf 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -173,8 +173,13 @@ class LibraryController(object): :type uri: string or :class:`None` :param uris: track URIs :type uris: list of string or :class:`None` - :rtype: list of :class:`mopidy.models.Track` if uri was set or a - ``{uri: list of tracks}`` if uris was set. + :rtype: {uri: list of :class:`mopidy.models.Track`} + + .. versionadded:: 0.20 + The ``uris`` argument. + + .. deprecated:: 0.20 + The ``uri`` argument. Use ``uris`` instead. """ none_set = uri is None and uris is None both_set = uri is not None and uris is not None From 65c5242b14eed8303831dffe78ef4224a7868a29 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 23:37:31 +0100 Subject: [PATCH 419/495] backend: Remove default impl of PlaylistProvider.playlists The default was insane. For one, because overriding e.g. just the getter would make the property have a pair of working getter and setter that are entirely disconnected. --- mopidy/backend.py | 7 ++----- mopidy/local/playlists.py | 10 ++++++++++ tests/backend/test_backend.py | 12 +++++++++++- tests/dummy_backend.py | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index f7808ac8..7e020b77 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -1,7 +1,5 @@ from __future__ import absolute_import, unicode_literals -import copy - from mopidy import listener, models @@ -263,7 +261,6 @@ class PlaylistsProvider(object): def __init__(self, backend): self.backend = backend - self._playlists = [] # TODO Replace playlists property with a get_playlists() method which # returns playlist Ref's instead of the gigantic data structures we @@ -277,11 +274,11 @@ class PlaylistsProvider(object): Read/write. List of :class:`mopidy.models.Playlist`. """ - return copy.copy(self._playlists) + return [] @playlists.setter # noqa def playlists(self, playlists): - self._playlists = playlists + raise NotImplementedError def create(self, name): """ diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py index ba4dbf02..f2b712c5 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/local/playlists.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, division, unicode_literals +import copy import glob import logging import operator @@ -20,8 +21,17 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): super(LocalPlaylistsProvider, self).__init__(*args, **kwargs) self._media_dir = self.backend.config['local']['media_dir'] self._playlists_dir = self.backend.config['local']['playlists_dir'] + self._playlists = [] self.refresh() + @property + def playlists(self): + return copy.copy(self._playlists) + + @playlists.setter + def playlists(self, playlists): + self._playlists = playlists + def create(self, name): playlist = self._save_m3u(Playlist(name=name)) old_playlist = self.lookup(playlist.uri) diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index 7c6cc82b..c72633fb 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import unittest -from mopidy import models +from mopidy import backend, models from tests import dummy_backend @@ -28,3 +28,13 @@ class LibraryTest(unittest.TestCase): expected = {'trackuri': []} self.assertEqual(library.get_images(['trackuri']), expected) + + +class PlaylistsTest(unittest.TestCase): + def test_playlists_default_impl(self): + playlists = backend.PlaylistsProvider(backend=None) + + self.assertEqual(playlists.playlists, []) + + with self.assertRaises(NotImplementedError): + playlists.playlists = [] diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 9c5a8c0c..d0816096 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -6,6 +6,8 @@ used in tests of the frontends. from __future__ import absolute_import, unicode_literals +import copy + import pykka from mopidy import backend @@ -85,6 +87,18 @@ class DummyPlaybackProvider(backend.PlaybackProvider): class DummyPlaylistsProvider(backend.PlaylistsProvider): + def __init__(self, backend): + super(DummyPlaylistsProvider, self).__init__(backend) + self._playlists = [] + + @property + def playlists(self): + return copy.copy(self._playlists) + + @playlists.setter + def playlists(self, playlists): + self._playlists = playlists + def create(self, name): playlist = Playlist(name=name, uri='dummy:%s' % name) self._playlists.append(playlist) From accc1e72491a3a4375d1e299880bd9d00beb0963 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 23:41:18 +0100 Subject: [PATCH 420/495] docs: Update changelog for PR#1046 --- docs/changelog.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 33bfc9f7..e0415005 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -52,6 +52,12 @@ v0.20.0 (UNRELEASED) :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients know about the current song in streams. (PR: :issue:`938`, :issue:`1030`) +**Backend API** + +- Remove default implementation of + :attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially + backwards incompatible. (PR: :issue:`1046`) + **Commands** - Make the ``mopidy`` command print a friendly error message if the From 93afea50a20e9c6e1f5d38a88a79bb6ba4ba86dd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 18 Mar 2015 21:58:58 +0100 Subject: [PATCH 421/495] docs: Change next release from 0.20 to 1.0 --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e0415005..37716e96 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,8 +5,8 @@ Changelog This changelog is used to track all major changes to Mopidy. -v0.20.0 (UNRELEASED) -==================== +v1.0.0 (UNRELEASED) +=================== **Models** From a05c0971063902ed544d38c93dc6f77a026c2815 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 18 Mar 2015 21:59:58 +0100 Subject: [PATCH 422/495] docs: Change deprecated-in from 0.20 to 1.0 Fixes #1051 --- mopidy/audio/actor.py | 2 +- mopidy/core/actor.py | 4 ++-- mopidy/core/playback.py | 20 ++++++++++---------- mopidy/core/playlists.py | 2 +- mopidy/core/tracklist.py | 16 ++++++++-------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 788fbab4..b4c78ecb 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -602,7 +602,7 @@ class Audio(pykka.ThreadingActor): We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`emit_data` with a :class:`None` buffer instead. """ self._appsrc.push(None) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 671517ca..32070684 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -69,7 +69,7 @@ class Core( uri_schemes = deprecated_property(get_uri_schemes) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_uri_schemes` instead. """ @@ -79,7 +79,7 @@ class Core( version = deprecated_property(get_version) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_version` instead. """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d2e4a1c2..86bc54c0 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -51,7 +51,7 @@ class PlaybackController(object): current_tl_track = deprecated_property( get_current_tl_track, set_current_tl_track) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_current_tl_track` instead. """ @@ -69,7 +69,7 @@ class PlaybackController(object): current_track = deprecated_property(get_current_track) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_current_track` instead. """ @@ -106,7 +106,7 @@ class PlaybackController(object): state = deprecated_property(get_state, set_state) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_state` and :meth:`set_state` instead. """ @@ -120,13 +120,13 @@ class PlaybackController(object): time_position = deprecated_property(get_time_position) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_time_position` instead. """ def get_volume(self): """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.get_volume() ` instead. """ @@ -136,7 +136,7 @@ class PlaybackController(object): def set_volume(self, volume): """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.set_volume() ` instead. """ @@ -146,7 +146,7 @@ class PlaybackController(object): volume = deprecated_property(get_volume, set_volume) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.get_volume() ` and :meth:`core.mixer.set_volume() @@ -155,7 +155,7 @@ class PlaybackController(object): def get_mute(self): """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.get_mute() ` instead. """ @@ -164,7 +164,7 @@ class PlaybackController(object): def set_mute(self, mute): """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.set_mute() ` instead. """ @@ -173,7 +173,7 @@ class PlaybackController(object): mute = deprecated_property(get_mute, set_mute) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.get_mute() ` and :meth:`core.mixer.set_mute() diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 3d368c29..5680c018 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -32,7 +32,7 @@ class PlaylistsController(object): playlists = deprecated_property(get_playlists) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_playlists` instead. """ diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 08d08329..ad8e61d0 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -32,7 +32,7 @@ class TracklistController(object): tl_tracks = deprecated_property(get_tl_tracks) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_tl_tracks` instead. """ @@ -42,7 +42,7 @@ class TracklistController(object): tracks = deprecated_property(get_tracks) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_tracks` instead. """ @@ -52,7 +52,7 @@ class TracklistController(object): length = deprecated_property(get_length) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_length` instead. """ @@ -72,7 +72,7 @@ class TracklistController(object): version = deprecated_property(get_version) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_version` instead. """ @@ -100,7 +100,7 @@ class TracklistController(object): consume = deprecated_property(get_consume, set_consume) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_consume` and :meth:`set_consume` instead. """ @@ -132,7 +132,7 @@ class TracklistController(object): random = deprecated_property(get_random, set_random) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_random` and :meth:`set_random` instead. """ @@ -165,7 +165,7 @@ class TracklistController(object): repeat = deprecated_property(get_repeat, set_repeat) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_repeat` and :meth:`set_repeat` instead. """ @@ -195,7 +195,7 @@ class TracklistController(object): single = deprecated_property(get_single, set_single) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_single` and :meth:`set_single` instead. """ From 26d07b2cfe8cba89915e0e43479ddae0bdbfc88e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 18 Mar 2015 22:10:47 +0100 Subject: [PATCH 423/495] docs: Remove API stability disclaimers Not as if we've had the freedom to break anything for ages anyway. Fixes #1049 --- docs/api/http.rst | 12 ------------ docs/api/index.rst | 7 ++----- docs/api/js.rst | 12 ------------ 3 files changed, 2 insertions(+), 29 deletions(-) diff --git a/docs/api/http.rst b/docs/api/http.rst index 3eff14fd..9a7d56bb 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -14,18 +14,6 @@ WebSocket API for use both from browsers and Node.js. The :ref:`http-explore-extension` extension, can also be used to get you familiarized with HTTP based APIs. -.. warning:: API stability - - Since the HTTP JSON-RPC API exposes our internal core API directly it is to - be regarded as **experimental**. We cannot promise to keep any form of - backwards compatibility between releases as we will need to change the core - API while working out how to support new use cases. Thus, if you use this - API, you must expect to do small adjustments to your client for every - release of Mopidy. - - From Mopidy 1.0 and onwards, we intend to keep the core API far more - stable. - .. _http-post-api: diff --git a/docs/api/index.rst b/docs/api/index.rst index 5aac825c..2402186e 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -4,13 +4,10 @@ API reference ************* -.. warning:: API stability +.. note:: What is public? Only APIs documented here are public and open for use by Mopidy - extensions. We will change these APIs, but will keep the changelog up to - date with all breaking changes. - - From Mopidy 1.0 and onwards, we intend to keep these APIs far more stable. + extensions. .. toctree:: diff --git a/docs/api/js.rst b/docs/api/js.rst index fffb40fa..29866d14 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -8,18 +8,6 @@ We've made a JavaScript library, Mopidy.js, which wraps the :ref:`websocket-api` and gets you quickly started with working on your client instead of figuring out how to communicate with Mopidy. -.. warning:: API stability - - Since the Mopidy.js API exposes our internal core API directly it is to be - regarded as **experimental**. We cannot promise to keep any form of - backwards compatibility between releases as we will need to change the core - API while working out how to support new use cases. Thus, if you use this - API, you must expect to do small adjustments to your client for every - release of Mopidy. - - From Mopidy 1.0 and onwards, we intend to keep the core API far more - stable. - Getting the library for browser use =================================== From 71e2b21b5204b78d2b1f2f220cbce56037532bd4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Mar 2015 23:09:09 +0100 Subject: [PATCH 424/495] review: Minor fixes and updates --- docs/changelog.rst | 3 ++- mopidy/core/library.py | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 33468f60..f1a33c4b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,7 +32,8 @@ v0.20.0 (UNRELEASED) mute. (Fixes: :issue:`962`) - Add ``uris`` argument to :method:`mopidy.core.LibraryController.lookup` - which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`) + which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, + PR: :issue:`1047`) - **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for volume and mute management have been deprecated. (Fixes: :issue:`962`) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index ec94dccf..f2a8b9bd 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -173,12 +173,13 @@ class LibraryController(object): :type uri: string or :class:`None` :param uris: track URIs :type uris: list of string or :class:`None` - :rtype: {uri: list of :class:`mopidy.models.Track`} + :rtype: list of :class:`mopidy.models.Track` if uri was set or + a {uri: list of :class:`mopidy.models.Track`} if uris was set. - .. versionadded:: 0.20 + .. versionadded:: 1.0 The ``uris`` argument. - .. deprecated:: 0.20 + .. deprecated:: 1.0 The ``uri`` argument. Use ``uris`` instead. """ none_set = uri is None and uris is None From 4692e7305431a2ff533b14fab111f4f0b195fa9f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 18 Mar 2015 23:40:03 +0100 Subject: [PATCH 425/495] docs: Add section on semantic versioning Fixes #1050 --- docs/versioning.rst | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/versioning.rst b/docs/versioning.rst index cc7f58bc..cd428366 100644 --- a/docs/versioning.rst +++ b/docs/versioning.rst @@ -2,22 +2,36 @@ Versioning ********** -Mopidy uses `Semantic Versioning `_, but since we're still -pre-1.0 that doesn't mean much yet. +Mopidy follows `Semantic Versioning `_. In summary this +means that our version numbers have three parts, MAJOR.MINOR.PATCH, which +change according to the following rules: + +- When we *make incompatible API changes*, we increase the MAJOR number. + +- When we *add features* in a backwards-compatible manner, we increase the + MINOR number. + +- When we *fix bugs* in a backwards-compatible manner, we increase the PATCH + number. + +The promise is that if you make a Mopidy extension for Mopidy 1.0, it should +work unchanged with any Mopidy 1.x release, but probably not with 2.0. When a +new major version is released, you must review the incompatible changes and +update your extension accordingly. Release schedule ================ We intend to have about one feature release every month in periods of active -development. The feature releases are numbered 0.x.0. The features added is a -mix of what we feel is most important/requested of the missing features, and -features we develop just because we find them fun to make, even though they may -be useful for very few users or for a limited use case. +development. The features added is a mix of what we feel is most +important/requested of the missing features, and features we develop just +because we find them fun to make, even though they may be useful for very few +users or for a limited use case. -Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs -that are too serious to wait for the next feature release. We will only release -bugfix releases for the last feature release. E.g. when 0.14.0 is released, we -will no longer provide bugfix releases for the 0.13 series. In other words, -there will be just a single supported release at any point in time. This is to -not spread our limited resources too thin. +Bugfix releases will be released whenever we discover bugs that are too serious +to wait for the next feature release. We will only release bugfix releases for +the last feature release. E.g. when 1.2.0 is released, we will no longer +provide bugfix releases for the 1.1.x series. In other words, there will be just +a single supported release at any point in time. This is to not spread our +limited resources too thin. From c93dd34c93a1f0d3cc44a1e92f267d9c09c91328 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 Mar 2015 00:02:03 +0100 Subject: [PATCH 426/495] pypi: Up dev status to '5 - Production/Stable' --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0d29c041..49940c15 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ setup( ], }, classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: Apache Software License', From b28757979353220e48b0f4e15734757ca2250315 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 20 Mar 2015 08:11:14 +0100 Subject: [PATCH 427/495] docs: Add Mopidy-Local-Images. --- docs/ext/backends.rst | 11 +++++++++++ docs/ext/local_images.jpg | Bin 0 -> 69918 bytes docs/ext/web.rst | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 docs/ext/local_images.jpg diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 6f3195ff..17e2a7ca 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -90,6 +90,17 @@ Mopidy-Local Bundled with Mopidy. See :ref:`ext-local`. +Mopidy-Local-Images +=================== + +https://github.com/tkem/mopidy-local-images + +Extension which plugs into Mopidy-Local to allow Web clients access to +album art embedded in local media files. Not to be used on its own, +but acting as a proxy between ``mopidy local scan`` and the actual +local library provider being used. + + Mopidy-Local-SQLite =================== diff --git a/docs/ext/local_images.jpg b/docs/ext/local_images.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a5336c4672155469e948e6655b273258ad2ea901 GIT binary patch literal 69918 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 literal 0 HcmV?d00001 diff --git a/docs/ext/web.rst b/docs/ext/web.rst index b4a9660f..7355dbf9 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -30,6 +30,24 @@ To install, run:: pip install Mopidy-API-Explorer +Mopidy-Local-Images +=================== + +https://github.com/tkem/mopidy-local-images + +Not a full-featured Web client, but rather a local library and Web +extension which allows other Web clients access to album art embedded +in local media files. + +.. image:: /ext/local_images.jpg + :width: 640 + :height: 480 + +To install, run:: + + pip install Mopidy-Local-Images + + Mopidy-Mobile ============= From c57f3ec9b2f40497bd863cad250daf1aab50af0e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Mar 2015 22:28:38 +0100 Subject: [PATCH 428/495] core: Make tracklist.mark_*() private Fixes #1058 --- docs/changelog.rst | 7 +++++++ mopidy/core/playback.py | 8 ++++---- mopidy/core/tracklist.py | 12 ++++++------ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b588011c..803e4373 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -56,6 +56,13 @@ v1.0.0 (UNRELEASED) :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients know about the current song in streams. (PR: :issue:`938`, :issue:`1030`) +- The following methods were documented as internal. They are now fully private + and unavailable outside the core actor. (Fixes: :issue:`1058`) + + - :meth:`mopidy.core.TracklistController.mark_played` + - :meth:`mopidy.core.TracklistController.mark_playing` + - :meth:`mopidy.core.TracklistController.mark_unplayable` + **Backend API** - Remove default implementation of diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 86bc54c0..c00f86fd 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -220,7 +220,7 @@ class PlaybackController(object): self.stop() self.set_current_tl_track(None) - self.core.tracklist.mark_played(original_tl_track) + self.core.tracklist._mark_played(original_tl_track) def on_tracklist_change(self): """ @@ -255,7 +255,7 @@ class PlaybackController(object): self.stop() self.set_current_tl_track(None) - self.core.tracklist.mark_played(original_tl_track) + self.core.tracklist._mark_played(original_tl_track) def pause(self): """Pause playback.""" @@ -311,12 +311,12 @@ class PlaybackController(object): success = backend and backend.playback.play(tl_track.track).get() if success: - self.core.tracklist.mark_playing(tl_track) + self.core.tracklist._mark_playing(tl_track) self.core.history.add(tl_track.track) # TODO: replace with stream-changed self._trigger_track_playback_started() else: - self.core.tracklist.mark_unplayable(tl_track) + self.core.tracklist._mark_unplayable(tl_track) if on_error_step == 1: # TODO: can cause an endless loop for single track repeat. self.next() diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index ad8e61d0..456bddf6 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -499,19 +499,19 @@ class TracklistController(object): """ return self._tl_tracks[start:end] - def mark_playing(self, tl_track): - """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" + def _mark_playing(self, tl_track): + """Internal method for :class:`mopidy.core.PlaybackController`.""" if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) - def mark_unplayable(self, tl_track): - """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" + def _mark_unplayable(self, tl_track): + """Internal method for :class:`mopidy.core.PlaybackController`.""" logger.warning('Track is not playable: %s', tl_track.track.uri) if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) - def mark_played(self, tl_track): - """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" + def _mark_played(self, tl_track): + """Internal method for :class:`mopidy.core.PlaybackController`.""" if self.consume and tl_track is not None: self.remove(tlid=[tl_track.tlid]) return True From 35a8fecd5dec228751366e34500bf1097d83afa9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Mar 2015 22:39:56 +0100 Subject: [PATCH 429/495] docs: Add PR#1062 to changelog --- docs/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 803e4373..d63ce7b8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,7 +57,8 @@ v1.0.0 (UNRELEASED) know about the current song in streams. (PR: :issue:`938`, :issue:`1030`) - The following methods were documented as internal. They are now fully private - and unavailable outside the core actor. (Fixes: :issue:`1058`) + and unavailable outside the core actor. (Fixes: :issue:`1058`, PR: + :issue:`1062`) - :meth:`mopidy.core.TracklistController.mark_played` - :meth:`mopidy.core.TracklistController.mark_playing` From 861f60e6f10c6bd0d9fcbcba749cb2c0a4fc6cb1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Mar 2015 22:34:36 +0100 Subject: [PATCH 430/495] core: Make history.add() private Instead of changing the signature to add(uri, name) I opted for renaming it to _add_track(track). Since it's internal we may change it whenever we like to. Since you need different logic for extracting an interesting name from a track and from a ref or a stream title, it makes sense to add another method for adding refs/stream titles to the history when that time comes. Fixes #1056 --- docs/changelog.rst | 3 ++- mopidy/core/history.py | 4 +++- mopidy/core/playback.py | 2 +- tests/core/test_history.py | 10 +++++----- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d63ce7b8..5dc90c17 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,7 +26,8 @@ v1.0.0 (UNRELEASED) the Python API with the WebSocket/JavaScript API. (Fixes: :issue:`952`) - Add :class:`mopidy.core.HistoryController` which keeps track of what tracks - have been played. (Fixes: :issue:`423`, PR: :issue:`803`) + have been played. (Fixes: :issue:`423`, :issue:`1056`, PR: :issue:`803`, + :issue:`1063`) - Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. (Fixes: :issue:`962`) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 9d7cf59f..f0d5e9d4 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -15,9 +15,11 @@ class HistoryController(object): def __init__(self): self._history = [] - def add(self, track): + def _add_track(self, track): """Add track to the playback history. + Internal method for :class:`mopidy.core.PlaybackController`. + :param track: track to add :type track: :class:`mopidy.models.Track` """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index c00f86fd..0b714598 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -312,7 +312,7 @@ class PlaybackController(object): if success: self.core.tracklist._mark_playing(tl_track) - self.core.history.add(tl_track.track) + self.core.history._add_track(tl_track.track) # TODO: replace with stream-changed self._trigger_track_playback_started() else: diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 42922e52..48062aaf 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -18,24 +18,24 @@ class PlaybackHistoryTest(unittest.TestCase): self.history = HistoryController() def test_add_track(self): - self.history.add(self.tracks[0]) + self.history._add_track(self.tracks[0]) self.assertEqual(self.history.get_length(), 1) - self.history.add(self.tracks[1]) + self.history._add_track(self.tracks[1]) self.assertEqual(self.history.get_length(), 2) - self.history.add(self.tracks[2]) + self.history._add_track(self.tracks[2]) self.assertEqual(self.history.get_length(), 3) def test_non_tracks_are_rejected(self): with self.assertRaises(TypeError): - self.history.add(object()) + self.history._add_track(object()) self.assertEqual(self.history.get_length(), 0) def test_history_entry_contents(self): track = self.tracks[0] - self.history.add(track) + self.history._add_track(track) result = self.history.get_history() (timestamp, ref) = result[0] From bbf52eede9bb503c122225dd16414f33f591cf7f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 21 Mar 2015 00:05:00 +0100 Subject: [PATCH 431/495] backend: Change playback API (breaking change!) While trying to remove traces of stop calls in core to get gapless working I found we had no way to switch to switch tracks without triggering a play. This change fixes this by changing the backends playback provider API. - play() now _only_ starts playback and does not take any arguments. - prepare_change() has been added, this could have been avoided with a kwarg to change_track(track), but that would break more backends. - core has been updated to call prepare_change+change_track+play as needed. - tests have been updated to handle this change. Longer term I hope to completely rework the playback API in backends, as 99% of our backends only use change_track(track) to translate URIs. So we should make simple case simple, and handle mopidy-spotify / appsrc in some other way. Cherry picked from the WIP gapless branch. --- mopidy/backend.py | 29 +++++++++++++++++++++++------ mopidy/core/playback.py | 7 ++++++- tests/core/test_playback.py | 12 +++++++++--- tests/dummy_backend.py | 13 +++++++++++-- tests/local/test_playback.py | 12 ++++++++---- 5 files changed, 57 insertions(+), 16 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 7e020b77..3852b1d4 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -177,26 +177,40 @@ class PlaybackProvider(object): """ return self.audio.pause_playback().get() - def play(self, track): + def play(self): """ - Play given track. + Start playback. *MAY be reimplemented by subclass.* - :param track: the track to play - :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - self.audio.prepare_change() - self.change_track(track) return self.audio.start_playback().get() + def prepare_change(self): + """ + Indicate that an URI change is about to happen. + + *MAY be reimplemented by subclass.* + + It is extremely unlikely it makes sense for any backends to override + this. For most practical purposes it should be considered an internal + call between backends and core that backend authors should not touch. + """ + self.audio.prepare_change().get() + def change_track(self, track): """ Swith to provided track. *MAY be reimplemented by subclass.* + This is very likely the *only* thing you need to override as a backend + author. Typically this is where you convert any mopidy specific URIs + to real URIs and then return:: + + return super(MyBackend, self).change_track(track.copy(uri=new_uri)) + :param track: the track to play :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` @@ -232,6 +246,9 @@ class PlaybackProvider(object): *MAY be reimplemented by subclass.* + Should not be used for tracking if tracks have been played / when we + are done playing them. + :rtype: :class:`True` if successful, else :class:`False` """ return self.audio.stop_playback().get() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 0b714598..4f51f328 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -308,7 +308,12 @@ class PlaybackController(object): self.set_current_tl_track(tl_track) self.set_state(PlaybackState.PLAYING) backend = self._get_backend() - success = backend and backend.playback.play(tl_track.track).get() + success = False + + if backend: + backend.playback.prepare_change() + backend.playback.change_track(tl_track.track) + success = backend.playback.play().get() if success: self.core.tracklist._mark_playing(tl_track) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index e6dc3ce1..e84e7301 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -100,19 +100,25 @@ class CorePlaybackTest(unittest.TestCase): def test_play_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) - self.playback1.play.assert_called_once_with(self.tracks[0]) + self.playback1.prepare_change.assert_called_once_with() + self.playback1.change_track.assert_called_once_with(self.tracks[0]) + self.playback1.play.assert_called_once_with() self.assertFalse(self.playback2.play.called) def test_play_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) self.assertFalse(self.playback1.play.called) - self.playback2.play.assert_called_once_with(self.tracks[1]) + self.playback2.prepare_change.assert_called_once_with() + self.playback2.change_track.assert_called_once_with(self.tracks[1]) + self.playback2.play.assert_called_once_with() def test_play_skips_to_next_on_unplayable_track(self): self.core.playback.play(self.unplayable_tl_track) - self.playback1.play.assert_called_once_with(self.tracks[3]) + self.playback1.prepare_change.assert_called_once_with() + self.playback1.change_track.assert_called_once_with(self.tracks[3]) + self.playback1.play.assert_called_once_with() self.assertFalse(self.playback2.play.called) self.assertEqual( diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index d0816096..d4441673 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -62,15 +62,23 @@ class DummyLibraryProvider(backend.LibraryProvider): class DummyPlaybackProvider(backend.PlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) + self._uri = None self._time_position = 0 def pause(self): return True - def play(self, track): + def play(self): + return self._uri and self._uri != 'dummy:error' + + def change_track(self, track): """Pass a track with URI 'dummy:error' to force failure""" + self._uri = track.uri self._time_position = 0 - return track.uri != 'dummy:error' + return True + + def prepare_change(self): + pass def resume(self): return True @@ -80,6 +88,7 @@ class DummyPlaybackProvider(backend.PlaybackProvider): return True def stop(self): + self._uri = None return True def get_time_position(self): diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 3ccd8d8f..4c4ded24 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -154,7 +154,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_play_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[0] + return_values = [True, False] + self.backend.playback.play = lambda: return_values.pop() self.playback.play() self.assertNotEqual(self.playback.current_track, self.tracks[0]) self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -214,7 +215,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_previous_skips_to_previous_track_on_failure(self): # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] + return_values = [True, False, True] + self.backend.playback.play = lambda: return_values.pop() self.playback.play(self.tracklist.tl_tracks[2]) self.assertEqual(self.playback.current_track, self.tracks[2]) self.playback.previous() @@ -281,7 +283,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_next_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] + return_values = [True, False, True] + self.backend.playback.play = lambda: return_values.pop() self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.next() @@ -455,7 +458,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_end_of_track_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] + return_values = [True, False, True] + self.backend.playback.play = lambda: return_values.pop() self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.on_end_of_track() From f67e55618cbbd9c121520df5402c7d664ec4e120 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 21 Mar 2015 00:11:15 +0100 Subject: [PATCH 432/495] core: Make lookup(uris=...) return dict with all uris All uris given to lookup should be in the result even if there is no backend to handle the uri, and the lookup result thus is an empty list. As a side effect, future.get() is now called in the order of the URIs in the `uris` list, making it easier to mock out backend.library.lookup() in core layer tests. --- mopidy/core/library.py | 14 ++++++++++---- tests/core/test_library.py | 9 ++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index f2a8b9bd..b8018b16 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -188,20 +188,26 @@ class LibraryController(object): if none_set or both_set: raise ValueError("One of 'uri' or 'uris' must be set") + if uri is not None: + uris = [uri] + futures = {} result = {} - backends = self._get_backends_to_uris([uri] if uri else uris) + backends = self._get_backends_to_uris(uris) # TODO: lookup(uris) to backend APIs for backend, backend_uris in backends.items(): for u in backend_uris or []: futures[u] = backend.library.lookup(u) - for u, future in futures.items(): - result[u] = future.get() + for u in uris: + if u in futures: + result[u] = futures[u].get() + else: + result[u] = [] if uri: - return result.get(uri, []) + return result[uri] return result def refresh(self, uri=None): diff --git a/tests/core/test_library.py b/tests/core/test_library.py index b71e5de5..9eacd1a2 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -168,13 +168,20 @@ class CoreLibraryTest(unittest.TestCase): result = self.core.library.lookup(uris=['dummy1:a', 'dummy2:a']) self.assertEqual(result, {'dummy2:a': [5678], 'dummy1:a': [1234]}) - def test_lookup_returns_nothing_for_dummy3_track(self): + def test_lookup_uri_returns_empty_list_for_dummy3_track(self): result = self.core.library.lookup('dummy3:a') self.assertEqual(result, []) self.assertFalse(self.library1.lookup.called) self.assertFalse(self.library2.lookup.called) + def test_lookup_uris_returns_empty_list_for_dummy3_track(self): + result = self.core.library.lookup(uris=['dummy3:a']) + + self.assertEqual(result, {'dummy3:a': []}) + self.assertFalse(self.library1.lookup.called) + self.assertFalse(self.library2.lookup.called) + def test_refresh_with_uri_selects_dummy1_backend(self): self.core.library.refresh('dummy1:a') From 2bc3db0d0e95e8f5ba90f5fa39b616009427a0f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 21 Mar 2015 00:16:22 +0100 Subject: [PATCH 433/495] core: Add uris kwarg to tracklist.core() Fixes #1060 --- docs/changelog.rst | 4 ++++ mopidy/core/tracklist.py | 27 +++++++++++++++++++++------ tests/core/test_tracklist.py | 23 +++++++++++++++++++++-- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d63ce7b8..589178e0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -35,6 +35,10 @@ v1.0.0 (UNRELEASED) which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR: :issue:`1047`) +- Add ``uris`` argument to :method:`mopidy.core.TracklistController.add` + which allows for simpler addition of multiple URIs to the tracklist. (Fixes: + :issue:`1060`, PR: :issue:`1065`) + - **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for volume and mute management have been deprecated. (Fixes: :issue:`962`) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 456bddf6..963dcadf 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -299,13 +299,16 @@ class TracklistController(object): return self.get_tl_tracks()[position - 1] - def add(self, tracks=None, at_position=None, uri=None): + def add(self, tracks=None, at_position=None, uri=None, uris=None): """ Add the track or list of tracks to the tracklist. If ``uri`` is given instead of ``tracks``, the URI is looked up in the library and the resulting tracks are added to the tracklist. + If ``uris`` is given instead of ``tracks``, the URIs are looked up in + the library and the resulting tracks are added to the tracklist. + If ``at_position`` is given, the tracks placed at the given position in the tracklist. If ``at_position`` is not given, the tracks are appended to the end of the tracklist. @@ -319,12 +322,24 @@ class TracklistController(object): :param uri: URI for tracks to add :type uri: string :rtype: list of :class:`mopidy.models.TlTrack` - """ - assert tracks is not None or uri is not None, \ - 'tracks or uri must be provided' - if tracks is None and uri is not None: - tracks = self.core.library.lookup(uri) + .. versionadded:: 1.0 + The ``uris`` argument. + + .. deprecated:: 1.0 + The ``tracks`` and ``uri`` arguments. Use ``uris``. + """ + assert tracks is not None or uri is not None or uris is not None, \ + 'tracks, uri or uris must be provided' + + if tracks is None: + if uri is not None: + tracks = self.core.library.lookup(uri=uri) + elif uris is not None: + tracks = [] + track_map = self.core.library.lookup(uris=uris) + for uri in uris: + tracks.extend(track_map[uri]) tl_tracks = [] diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 7b5577f9..415d1fa0 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -26,8 +26,7 @@ class TracklistTest(unittest.TestCase): def test_add_by_uri_looks_up_uri_in_library(self): track = Track(uri='dummy1:x', name='x') - self.library.lookup().get.return_value = [track] - self.library.lookup.reset_mock() + self.library.lookup.return_value.get.return_value = [track] tl_tracks = self.core.tracklist.add(uri='dummy1:x') @@ -36,6 +35,26 @@ class TracklistTest(unittest.TestCase): self.assertEqual(track, tl_tracks[0].track) self.assertEqual(tl_tracks, self.core.tracklist.tl_tracks[-1:]) + def test_add_by_uris_looks_up_uris_in_library(self): + track1 = Track(uri='dummy1:x', name='x') + track2 = Track(uri='dummy1:y1', name='y1') + track3 = Track(uri='dummy1:y2', name='y2') + self.library.lookup.return_value.get.side_effect = [ + [track1], [track2, track3]] + + tl_tracks = self.core.tracklist.add(uris=['dummy1:x', 'dummy1:y']) + + self.library.lookup.assert_has_calls([ + mock.call('dummy1:x'), + mock.call('dummy1:y'), + ]) + self.assertEqual(3, len(tl_tracks)) + self.assertEqual(track1, tl_tracks[0].track) + self.assertEqual(track2, tl_tracks[1].track) + self.assertEqual(track3, tl_tracks[2].track) + self.assertEqual( + tl_tracks, self.core.tracklist.tl_tracks[-len(tl_tracks):]) + def test_remove_removes_tl_tracks_matching_query(self): tl_tracks = self.core.tracklist.remove(name=['foo']) From 8977f714117b9ab90f8ad061223cf84171608484 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 21 Mar 2015 00:59:54 +0100 Subject: [PATCH 434/495] docs: Fix syntax errors in changelog --- docs/changelog.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3e2d6daa..30c2f7c1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,12 +32,12 @@ v1.0.0 (UNRELEASED) - Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. (Fixes: :issue:`962`) -- Add ``uris`` argument to :method:`mopidy.core.LibraryController.lookup` - which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, - PR: :issue:`1047`) +- Add ``uris`` argument to :meth:`mopidy.core.LibraryController.lookup` which + allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR: + :issue:`1047`) -- Add ``uris`` argument to :method:`mopidy.core.TracklistController.add` - which allows for simpler addition of multiple URIs to the tracklist. (Fixes: +- Add ``uris`` argument to :meth:`mopidy.core.TracklistController.add` which + allows for simpler addition of multiple URIs to the tracklist. (Fixes: :issue:`1060`, PR: :issue:`1065`) - **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for From b2f60bc338eb37580932e0a5ccd40a387036e12f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 Mar 2015 00:02:52 +0100 Subject: [PATCH 435/495] m3u: Extract new M3U backend from local Fixes #1054 --- docs/changelog.rst | 11 +++ docs/ext/m3u.rst | 55 ++++++++++++ docs/index.rst | 1 + mopidy/local/__init__.py | 2 +- mopidy/local/actor.py | 2 - mopidy/local/ext.conf | 1 - mopidy/local/storage.py | 8 -- mopidy/local/translator.py | 99 --------------------- mopidy/m3u/__init__.py | 30 +++++++ mopidy/m3u/actor.py | 32 +++++++ mopidy/m3u/ext.conf | 3 + mopidy/m3u/library.py | 18 ++++ mopidy/{local => m3u}/playlists.py | 28 +++--- mopidy/m3u/translator.py | 110 ++++++++++++++++++++++++ setup.py | 1 + tests/m3u/__init__.py | 5 ++ tests/{local => m3u}/test_playlists.py | 63 +++++++------- tests/{local => m3u}/test_translator.py | 2 +- 18 files changed, 312 insertions(+), 159 deletions(-) create mode 100644 docs/ext/m3u.rst create mode 100644 mopidy/m3u/__init__.py create mode 100644 mopidy/m3u/actor.py create mode 100644 mopidy/m3u/ext.conf create mode 100644 mopidy/m3u/library.py rename mopidy/{local => m3u}/playlists.py (83%) create mode 100644 mopidy/m3u/translator.py create mode 100644 tests/m3u/__init__.py rename tests/{local => m3u}/test_playlists.py (84%) rename tests/{local => m3u}/test_translator.py (99%) diff --git a/docs/changelog.rst b/docs/changelog.rst index 30c2f7c1..da6e6bd7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -126,6 +126,11 @@ v1.0.0 (UNRELEASED) - Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`) +- Moved playlist support out to a new extension, :ref:`ext-m3u`. + +- *Deprecated:* The config value :confval:`local/playlists_dir` is no longer in + use and can be removed from your config. + **Local library API** - Implementors of :meth:`mopidy.local.Library.lookup` should now return a list @@ -139,6 +144,12 @@ v1.0.0 (UNRELEASED) - Add :meth:`mopidy.local.Library.get_images` for looking up images for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`) +**M3U backend** + +- Split the M3U playlist handling out of the local backend. See + :ref:`m3u-migration` for how to migrate your local playlists. (Fixes: + :issue:`1054`, PR: :issue:`1066`) + **MPD frontend** - In stored playlist names, replace "/", which are illegal, with "|" instead of diff --git a/docs/ext/m3u.rst b/docs/ext/m3u.rst new file mode 100644 index 00000000..d05f88f1 --- /dev/null +++ b/docs/ext/m3u.rst @@ -0,0 +1,55 @@ +.. _ext-m3u: + +********** +Mopidy-M3U +********** + +Mopidy-M3U is an extension for reading and writing M3U playlists stored +on disk. It is bundled with Mopidy and enabled by default. + +This backend handles URIs starting with ``m3u:``. + + +.. _m3u-migration: + +Migrating from Mopidy-Local playlists +===================================== + +Mopidy-M3U was split out of the Mopidy-Local extension in Mopidy 1.0. To +migrate your playlists from Mopidy-Local, simply move them from the +:confval:`local/playlists_dir` directory to the :confval:`m3u/playlists_dir` +directory. Assuming you have not changed the default config, run the following +commands to migrate:: + + mkdir -p ~/.local/share/mopidy/m3u/ + mv ~/.local/share/mopidy/local/playlists/* ~/.local/share/mopidy/m3u/ + + +Editing playlists +================= + +There is a core playlist API in place for editing playlists. This is supported +by a few Mopidy clients, but not through Mopidy's MPD server yet. + +It is possible to edit playlists by editing the M3U files located in the +:confval:`m3u/playlists_dir` directory, usually +:file:`~/.local/share/mopidy/m3u/`, by hand with a text editor. See `Wikipedia +`__ for a short description of the quite +simple M3U playlist format. + + +Configuration +============= + +See :ref:`config` for general help on configuring Mopidy. + +.. literalinclude:: ../../mopidy/m3u/ext.conf + :language: ini + +.. confval:: m3u/enabled + + If the M3U extension should be enabled or not. + +.. confval:: m3u/playlists_dir + + Path to directory with M3U files. diff --git a/docs/index.rst b/docs/index.rst index e91c491c..e9775030 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -94,6 +94,7 @@ Extensions :maxdepth: 2 ext/local + ext/m3u ext/stream ext/http ext/mpd diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 542d99f3..dedb8632 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -24,7 +24,7 @@ class Extension(ext.Extension): schema['library'] = config.String() schema['media_dir'] = config.Path() schema['data_dir'] = config.Path() - schema['playlists_dir'] = config.Path() + schema['playlists_dir'] = config.Deprecated() schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( minimum=1000, maximum=1000 * 60 * 60) diff --git a/mopidy/local/actor.py b/mopidy/local/actor.py index f315607a..435d19a5 100644 --- a/mopidy/local/actor.py +++ b/mopidy/local/actor.py @@ -8,7 +8,6 @@ from mopidy import backend from mopidy.local import storage from mopidy.local.library import LocalLibraryProvider from mopidy.local.playback import LocalPlaybackProvider -from mopidy.local.playlists import LocalPlaylistsProvider logger = logging.getLogger(__name__) @@ -36,5 +35,4 @@ class LocalBackend(pykka.ThreadingActor, backend.Backend): logger.warning('Local library %s not found', library_name) self.playback = LocalPlaybackProvider(audio=audio, backend=self) - self.playlists = LocalPlaylistsProvider(backend=self) self.library = LocalLibraryProvider(backend=self, library=library) diff --git a/mopidy/local/ext.conf b/mopidy/local/ext.conf index 535f4806..ebd7962f 100644 --- a/mopidy/local/ext.conf +++ b/mopidy/local/ext.conf @@ -3,7 +3,6 @@ enabled = true library = json media_dir = $XDG_MUSIC_DIR data_dir = $XDG_DATA_DIR/mopidy/local -playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists scan_timeout = 1000 scan_flush_threshold = 1000 scan_follow_symlinks = false diff --git a/mopidy/local/storage.py b/mopidy/local/storage.py index 9cdcd12e..21d278e5 100644 --- a/mopidy/local/storage.py +++ b/mopidy/local/storage.py @@ -20,11 +20,3 @@ def check_dirs_and_files(config): logger.warning( 'Could not create local data dir: %s', encoding.locale_decode(error)) - - # TODO: replace with data dir? - try: - path.get_or_create_dir(config['local']['playlists_dir']) - except EnvironmentError as error: - logger.warning( - 'Could not create local playlists dir: %s', - encoding.locale_decode(error)) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 6800c478..92b20a7b 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -2,18 +2,12 @@ from __future__ import absolute_import, unicode_literals import logging import os -import re import urllib -import urlparse from mopidy import compat -from mopidy.models import Track -from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri, uri_to_path -M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') - logger = logging.getLogger(__name__) @@ -28,13 +22,6 @@ def local_track_uri_to_path(uri, media_dir): return os.path.join(media_dir, file_path) -def local_playlist_uri_to_path(uri, playlists_dir): - if not uri.startswith('local:playlist:'): - raise ValueError('Invalid URI %s' % uri) - file_path = uri_to_path(uri).split(b':', 1)[1] - return os.path.join(playlists_dir, file_path) - - def path_to_local_track_uri(relpath): """Convert path relative to media_dir to local track URI.""" if isinstance(relpath, compat.text_type): @@ -47,89 +34,3 @@ def path_to_local_directory_uri(relpath): if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') return b'local:directory:%s' % urllib.quote(relpath) - - -def path_to_local_playlist_uri(relpath): - """Convert path relative to playlists_dir to local playlist URI.""" - if isinstance(relpath, compat.text_type): - relpath = relpath.encode('utf-8') - return b'local:playlist:%s' % urllib.quote(relpath) - - -def m3u_extinf_to_track(line): - """Convert extended M3U directive to track template.""" - m = M3U_EXTINF_RE.match(line) - if not m: - logger.warning('Invalid extended M3U directive: %s', line) - return Track() - (runtime, title) = m.groups() - if int(runtime) > 0: - return Track(name=title, length=1000 * int(runtime)) - else: - return Track(name=title) - - -def parse_m3u(file_path, media_dir): - r""" - Convert M3U file list to list of tracks - - Example M3U data:: - - # This is a comment - Alternative\Band - Song.mp3 - Classical\Other Band - New Song.mp3 - Stuff.mp3 - D:\More Music\Foo.mp3 - http://www.example.com:8000/Listen.pls - http://www.example.com/~user/Mine.mp3 - - Example extended M3U data:: - - #EXTM3U - #EXTINF:123, Sample artist - Sample title - Sample.mp3 - #EXTINF:321,Example Artist - Example title - Greatest Hits\Example.ogg - #EXTINF:-1,Radio XMP - http://mp3stream.example.com:8000/ - - - Relative paths of songs should be with respect to location of M3U. - - Paths are normally platform specific. - - Lines starting with # are ignored, except for extended M3U directives. - - Track.name and Track.length are set from extended M3U directives. - - m3u files are latin-1. - """ - # TODO: uris as bytes - tracks = [] - try: - with open(file_path) as m3u: - contents = m3u.readlines() - except IOError as error: - logger.warning('Couldn\'t open m3u: %s', locale_decode(error)) - return tracks - - if not contents: - return tracks - - extended = contents[0].decode('latin1').startswith('#EXTM3U') - - track = Track() - for line in contents: - line = line.strip().decode('latin1') - - if line.startswith('#'): - if extended and line.startswith('#EXTINF'): - track = m3u_extinf_to_track(line) - continue - - if urlparse.urlsplit(line).scheme: - tracks.append(track.copy(uri=line)) - elif os.path.normpath(line) == os.path.abspath(line): - path = path_to_uri(line) - tracks.append(track.copy(uri=path)) - else: - path = path_to_uri(os.path.join(media_dir, line)) - tracks.append(track.copy(uri=path)) - - track = Track() - return tracks diff --git a/mopidy/m3u/__init__.py b/mopidy/m3u/__init__.py new file mode 100644 index 00000000..e0fcf305 --- /dev/null +++ b/mopidy/m3u/__init__.py @@ -0,0 +1,30 @@ +from __future__ import absolute_import, unicode_literals + +import logging +import os + +import mopidy +from mopidy import config, ext + +logger = logging.getLogger(__name__) + + +class Extension(ext.Extension): + + dist_name = 'Mopidy-M3U' + ext_name = 'm3u' + version = mopidy.__version__ + + def get_default_config(self): + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return config.read(conf_file) + + def get_config_schema(self): + schema = super(Extension, self).get_config_schema() + schema['playlists_dir'] = config.Path() + return schema + + def setup(self, registry): + from .actor import M3UBackend + + registry.add('backend', M3UBackend) diff --git a/mopidy/m3u/actor.py b/mopidy/m3u/actor.py new file mode 100644 index 00000000..3908d938 --- /dev/null +++ b/mopidy/m3u/actor.py @@ -0,0 +1,32 @@ +from __future__ import absolute_import, unicode_literals + +import logging + +import pykka + +from mopidy import backend +from mopidy.m3u.library import M3ULibraryProvider +from mopidy.m3u.playlists import M3UPlaylistsProvider +from mopidy.utils import encoding, path + + +logger = logging.getLogger(__name__) + + +class M3UBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['m3u'] + + def __init__(self, config, audio): + super(M3UBackend, self).__init__() + + self._config = config + + try: + path.get_or_create_dir(config['m3u']['playlists_dir']) + except EnvironmentError as error: + logger.warning( + 'Could not create M3U playlists dir: %s', + encoding.locale_decode(error)) + + self.playlists = M3UPlaylistsProvider(backend=self) + self.library = M3ULibraryProvider(backend=self) diff --git a/mopidy/m3u/ext.conf b/mopidy/m3u/ext.conf new file mode 100644 index 00000000..0e828b1b --- /dev/null +++ b/mopidy/m3u/ext.conf @@ -0,0 +1,3 @@ +[m3u] +enabled = true +playlists_dir = $XDG_DATA_DIR/mopidy/m3u diff --git a/mopidy/m3u/library.py b/mopidy/m3u/library.py new file mode 100644 index 00000000..3b5bded1 --- /dev/null +++ b/mopidy/m3u/library.py @@ -0,0 +1,18 @@ +from __future__ import absolute_import, unicode_literals + +import logging + +from mopidy import backend + +logger = logging.getLogger(__name__) + + +class M3ULibraryProvider(backend.LibraryProvider): + """Library for looking up M3U playlists.""" + + def __init__(self, backend): + super(M3ULibraryProvider, self).__init__(backend) + + def lookup(self, uri): + # TODO Lookup tracks in M3U playlist + return [] diff --git a/mopidy/local/playlists.py b/mopidy/m3u/playlists.py similarity index 83% rename from mopidy/local/playlists.py rename to mopidy/m3u/playlists.py index f2b712c5..2dc11628 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/m3u/playlists.py @@ -8,19 +8,18 @@ import os import sys from mopidy import backend +from mopidy.m3u import translator from mopidy.models import Playlist -from .translator import local_playlist_uri_to_path, path_to_local_playlist_uri -from .translator import parse_m3u logger = logging.getLogger(__name__) -class LocalPlaylistsProvider(backend.PlaylistsProvider): +class M3UPlaylistsProvider(backend.PlaylistsProvider): def __init__(self, *args, **kwargs): - super(LocalPlaylistsProvider, self).__init__(*args, **kwargs) - self._media_dir = self.backend.config['local']['media_dir'] - self._playlists_dir = self.backend.config['local']['playlists_dir'] + super(M3UPlaylistsProvider, self).__init__(*args, **kwargs) + + self._playlists_dir = self.backend._config['m3u']['playlists_dir'] self._playlists = [] self.refresh() @@ -49,7 +48,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): if not playlist: logger.warn('Trying to delete unknown playlist %s', uri) return - path = local_playlist_uri_to_path(uri, self._playlists_dir) + path = translator.playlist_uri_to_path(uri, self._playlists_dir) if os.path.exists(path): os.remove(path) else: @@ -70,10 +69,10 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')): relpath = os.path.basename(path) name = os.path.splitext(relpath)[0].decode(encoding) - uri = path_to_local_playlist_uri(relpath) + uri = translator.path_to_playlist_uri(relpath) tracks = [] - for track in parse_m3u(path, self._media_dir): + for track in translator.parse_m3u(path): tracks.append(track) playlist = Playlist(uri=uri, name=name, tracks=tracks) @@ -82,7 +81,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self.playlists = sorted(playlists, key=operator.attrgetter('name')) logger.info( - 'Loaded %d local playlists from %s', + 'Loaded %d M3U playlists from %s', len(playlists), self._playlists_dir) def save(self, playlist): @@ -99,7 +98,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): playlist = self._save_m3u(playlist) if index >= 0 and uri != playlist.uri: - path = local_playlist_uri_to_path(uri, self._playlists_dir) + path = translator.playlist_uri_to_path(uri, self._playlists_dir) if os.path.exists(path): os.remove(path) else: @@ -125,11 +124,12 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): def _save_m3u(self, playlist, encoding=sys.getfilesystemencoding()): if playlist.name: name = self._sanitize_m3u_name(playlist.name, encoding) - uri = path_to_local_playlist_uri(name.encode(encoding) + b'.m3u') - path = local_playlist_uri_to_path(uri, self._playlists_dir) + uri = translator.path_to_playlist_uri( + name.encode(encoding) + b'.m3u') + path = translator.playlist_uri_to_path(uri, self._playlists_dir) elif playlist.uri: uri = playlist.uri - path = local_playlist_uri_to_path(uri, self._playlists_dir) + path = translator.playlist_uri_to_path(uri, self._playlists_dir) name, _ = os.path.splitext(os.path.basename(path).decode(encoding)) else: raise ValueError('M3U playlist needs name or URI') diff --git a/mopidy/m3u/translator.py b/mopidy/m3u/translator.py new file mode 100644 index 00000000..4eefce9d --- /dev/null +++ b/mopidy/m3u/translator.py @@ -0,0 +1,110 @@ +from __future__ import absolute_import, unicode_literals + +import logging +import os +import re +import urllib +import urlparse + +from mopidy import compat +from mopidy.models import Track +from mopidy.utils.encoding import locale_decode +from mopidy.utils.path import path_to_uri, uri_to_path + + +M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') + +logger = logging.getLogger(__name__) + + +def playlist_uri_to_path(uri, playlists_dir): + if not uri.startswith('m3u:'): + raise ValueError('Invalid URI %s' % uri) + file_path = uri_to_path(uri) + return os.path.join(playlists_dir, file_path) + + +def path_to_playlist_uri(relpath): + """Convert path relative to playlists_dir to M3U URI.""" + if isinstance(relpath, compat.text_type): + relpath = relpath.encode('utf-8') + return b'm3u:%s' % urllib.quote(relpath) + + +def m3u_extinf_to_track(line): + """Convert extended M3U directive to track template.""" + m = M3U_EXTINF_RE.match(line) + if not m: + logger.warning('Invalid extended M3U directive: %s', line) + return Track() + (runtime, title) = m.groups() + if int(runtime) > 0: + return Track(name=title, length=1000 * int(runtime)) + else: + return Track(name=title) + + +def parse_m3u(file_path, media_dir=None): + r""" + Convert M3U file list to list of tracks + + Example M3U data:: + + # This is a comment + Alternative\Band - Song.mp3 + Classical\Other Band - New Song.mp3 + Stuff.mp3 + D:\More Music\Foo.mp3 + http://www.example.com:8000/Listen.pls + http://www.example.com/~user/Mine.mp3 + + Example extended M3U data:: + + #EXTM3U + #EXTINF:123, Sample artist - Sample title + Sample.mp3 + #EXTINF:321,Example Artist - Example title + Greatest Hits\Example.ogg + #EXTINF:-1,Radio XMP + http://mp3stream.example.com:8000/ + + - Relative paths of songs should be with respect to location of M3U. + - Paths are normally platform specific. + - Lines starting with # are ignored, except for extended M3U directives. + - Track.name and Track.length are set from extended M3U directives. + - m3u files are latin-1. + """ + # TODO: uris as bytes + tracks = [] + try: + with open(file_path) as m3u: + contents = m3u.readlines() + except IOError as error: + logger.warning('Couldn\'t open m3u: %s', locale_decode(error)) + return tracks + + if not contents: + return tracks + + extended = contents[0].decode('latin1').startswith('#EXTM3U') + + track = Track() + for line in contents: + line = line.strip().decode('latin1') + + if line.startswith('#'): + if extended and line.startswith('#EXTINF'): + track = m3u_extinf_to_track(line) + continue + + if urlparse.urlsplit(line).scheme: + tracks.append(track.copy(uri=line)) + elif os.path.normpath(line) == os.path.abspath(line): + path = path_to_uri(line) + tracks.append(track.copy(uri=path)) + elif media_dir is not None: + path = path_to_uri(os.path.join(media_dir, line)) + tracks.append(track.copy(uri=path)) + + track = Track() + return tracks diff --git a/setup.py b/setup.py index 49940c15..a6f3050e 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ setup( 'mopidy.ext': [ 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', + 'm3u = mopidy.m3u:Extension', 'mpd = mopidy.mpd:Extension', 'softwaremixer = mopidy.softwaremixer:Extension', 'stream = mopidy.stream:Extension', diff --git a/tests/m3u/__init__.py b/tests/m3u/__init__.py new file mode 100644 index 00000000..702deac5 --- /dev/null +++ b/tests/m3u/__init__.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import, unicode_literals + + +def generate_song(i): + return 'dummy:track:song%s' % i diff --git a/tests/local/test_playlists.py b/tests/m3u/test_playlists.py similarity index 84% rename from tests/local/test_playlists.py rename to tests/m3u/test_playlists.py index 5af0debe..8c2187dc 100644 --- a/tests/local/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -8,30 +8,28 @@ import unittest import pykka from mopidy import core -from mopidy.local import actor -from mopidy.local.translator import local_playlist_uri_to_path +from mopidy.m3u import actor +from mopidy.m3u.translator import playlist_uri_to_path from mopidy.models import Playlist, Track from tests import dummy_audio, path_to_data_dir -from tests.local import generate_song +from tests.m3u import generate_song -class LocalPlaylistsProviderTest(unittest.TestCase): - backend_class = actor.LocalBackend +class M3UPlaylistsProviderTest(unittest.TestCase): + backend_class = actor.M3UBackend config = { - 'local': { - 'media_dir': path_to_data_dir(''), - 'data_dir': path_to_data_dir(''), - 'library': 'json', + 'm3u': { + 'playlists_dir': path_to_data_dir(''), } } def setUp(self): # noqa: N802 - self.config['local']['playlists_dir'] = tempfile.mkdtemp() - self.playlists_dir = self.config['local']['playlists_dir'] + self.config['m3u']['playlists_dir'] = tempfile.mkdtemp() + self.playlists_dir = self.config['m3u']['playlists_dir'] self.audio = dummy_audio.create_proxy() - self.backend = actor.LocalBackend.start( + self.backend = actor.M3UBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) @@ -42,8 +40,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase): shutil.rmtree(self.playlists_dir) def test_created_playlist_is_persisted(self): - uri = 'local:playlist:test.m3u' - path = local_playlist_uri_to_path(uri, self.playlists_dir) + uri = 'm3u:test.m3u' + path = playlist_uri_to_path(uri, self.playlists_dir) self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('test') @@ -54,16 +52,16 @@ class LocalPlaylistsProviderTest(unittest.TestCase): def test_create_sanitizes_playlist_name(self): playlist = self.core.playlists.create('../../test FOO baR') self.assertEqual('test FOO baR', playlist.name) - path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) self.assertEqual(self.playlists_dir, os.path.dirname(path)) self.assertTrue(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - uri1 = 'local:playlist:test1.m3u' - uri2 = 'local:playlist:test2.m3u' + uri1 = 'm3u:test1.m3u' + uri2 = 'm3u:test2.m3u' - path1 = local_playlist_uri_to_path(uri1, self.playlists_dir) - path2 = local_playlist_uri_to_path(uri2, self.playlists_dir) + path1 = playlist_uri_to_path(uri1, self.playlists_dir) + path2 = playlist_uri_to_path(uri2, self.playlists_dir) playlist = self.core.playlists.create('test1') self.assertEqual('test1', playlist.name) @@ -78,8 +76,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertTrue(os.path.exists(path2)) def test_deleted_playlist_is_removed(self): - uri = 'local:playlist:test.m3u' - path = local_playlist_uri_to_path(uri, self.playlists_dir) + uri = 'm3u:test.m3u' + path = playlist_uri_to_path(uri, self.playlists_dir) self.assertFalse(os.path.exists(path)) @@ -95,7 +93,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): track = Track(uri=generate_song(1)) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.copy(tracks=[track])) - path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) with open(path) as f: contents = f.read() @@ -106,7 +104,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): track = Track(uri=generate_song(1), name='Test', length=60000) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.copy(tracks=[track])) - path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) with open(path) as f: contents = f.read().splitlines() @@ -114,7 +112,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri]) def test_playlists_are_loaded_at_startup(self): - track = Track(uri='local:track:path2') + track = Track(uri='dummy:track:path2') playlist = self.core.playlists.create('test') playlist = playlist.copy(tracks=[track]) playlist = self.core.playlists.save(playlist) @@ -134,7 +132,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): pass @unittest.SkipTest - def test_playlist_dir_is_created(self): + def test_playlists_dir_is_created(self): pass def test_create_returns_playlist_with_name_set(self): @@ -154,7 +152,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assert_(not self.core.playlists.playlists) def test_delete_non_existant_playlist(self): - self.core.playlists.delete('local:playlist:unknown') + self.core.playlists.delete('m3u:unknown') def test_delete_playlist_removes_it_from_the_collection(self): playlist = self.core.playlists.create('test') @@ -168,7 +166,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): playlist = self.core.playlists.create('test') self.assertIn(playlist, self.core.playlists.playlists) - path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) self.assertTrue(os.path.exists(path)) os.remove(path) @@ -244,12 +242,12 @@ class LocalPlaylistsProviderTest(unittest.TestCase): def test_save_playlist_with_new_uri(self): # you *should* not do this - uri = 'local:playlist:test.m3u' + uri = 'm3u:test.m3u' playlist = self.core.playlists.save(Playlist(uri=uri)) self.assertIn(playlist, self.core.playlists.playlists) self.assertEqual(uri, playlist.uri) self.assertEqual('test', playlist.name) - path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) self.assertTrue(os.path.exists(path)) def test_playlist_with_unknown_track(self): @@ -261,8 +259,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): backend = self.backend_class(config=self.config, audio=self.audio) self.assert_(backend.playlists.playlists) - self.assertEqual( - 'local:playlist:test.m3u', backend.playlists.playlists[0].uri) + self.assertEqual('m3u:test.m3u', backend.playlists.playlists[0].uri) self.assertEqual( playlist.name, backend.playlists.playlists[0].name) self.assertEqual( @@ -282,12 +279,12 @@ class LocalPlaylistsProviderTest(unittest.TestCase): check_order(self.core.playlists.playlists, ['a', 'b', 'c']) - playlist = self.core.playlists.lookup('local:playlist:a.m3u') + playlist = self.core.playlists.lookup('m3u:a.m3u') playlist = playlist.copy(name='d') playlist = self.core.playlists.save(playlist) check_order(self.core.playlists.playlists, ['b', 'c', 'd']) - self.core.playlists.delete('local:playlist:c.m3u') + self.core.playlists.delete('m3u:c.m3u') check_order(self.core.playlists.playlists, ['b', 'd']) diff --git a/tests/local/test_translator.py b/tests/m3u/test_translator.py similarity index 99% rename from tests/local/test_translator.py rename to tests/m3u/test_translator.py index d3ba9e68..fc7fc958 100644 --- a/tests/local/test_translator.py +++ b/tests/m3u/test_translator.py @@ -6,7 +6,7 @@ import os import tempfile import unittest -from mopidy.local import translator +from mopidy.m3u import translator from mopidy.models import Track from mopidy.utils import path From a6ef1bb8d9ac07aa3694ff4e1ed1b3801b87a043 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 21 Mar 2015 11:43:46 +0100 Subject: [PATCH 436/495] backend: Add translate_uri for simpler API for the simple case. change_track(track) simply calls translate_uri(uri) by default now so that 90% of clients with custom URIs should be able to just implement this one method on the backend any ignore everything else. --- mopidy/backend.py | 25 ++++++++++++++++++++----- mopidy/local/playback.py | 12 +++--------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 3852b1d4..822c484c 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -199,23 +199,38 @@ class PlaybackProvider(object): """ self.audio.prepare_change().get() + def translate_uri(self, uri): + """ + Convert custom URI scheme to real playable uri. + + This is very likely the *only* thing you need to override as a backend + author. Typically this is where you convert any mopidy specific URIs + to real URIs and then return it. + + :param uri: the URI to translate. + :type uri: string + :rtype: string + """ + return uri + def change_track(self, track): """ Swith to provided track. *MAY be reimplemented by subclass.* - This is very likely the *only* thing you need to override as a backend - author. Typically this is where you convert any mopidy specific URIs - to real URIs and then return:: + It is unlikely it makes sense for any backends to override + this. For most practical purposes it should be considered an internal + call between backends and core that backend authors should not touch. - return super(MyBackend, self).change_track(track.copy(uri=new_uri)) + The default implementation will call :method:`translate_uri` which + is what you want to implement. :param track: the track to play :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - self.audio.set_uri(track.uri).get() + self.audio.set_uri(self.translate_uri(track.uri)).get() return True def resume(self): diff --git a/mopidy/local/playback.py b/mopidy/local/playback.py index 92dc6e15..82f27fdd 100644 --- a/mopidy/local/playback.py +++ b/mopidy/local/playback.py @@ -1,16 +1,10 @@ from __future__ import absolute_import, unicode_literals -import logging - from mopidy import backend from mopidy.local import translator -logger = logging.getLogger(__name__) - - class LocalPlaybackProvider(backend.PlaybackProvider): - def change_track(self, track): - track = track.copy(uri=translator.local_track_uri_to_file_uri( - track.uri, self.backend.config['local']['media_dir'])) - return super(LocalPlaybackProvider, self).change_track(track) + def translate_uri(self, uri): + return translator.local_track_uri_to_file_uri( + uri, self.backend.config['local']['media_dir']) From 87ba52f1246b7e311f3eb25a505ecd3fd4b071a4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 21 Mar 2015 23:12:44 +0100 Subject: [PATCH 437/495] review: Docstring updates --- mopidy/backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 822c484c..b41b92c0 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -204,7 +204,7 @@ class PlaybackProvider(object): Convert custom URI scheme to real playable uri. This is very likely the *only* thing you need to override as a backend - author. Typically this is where you convert any mopidy specific URIs + author. Typically this is where you convert any Mopidy specific URIs to real URIs and then return it. :param uri: the URI to translate. @@ -261,7 +261,7 @@ class PlaybackProvider(object): *MAY be reimplemented by subclass.* - Should not be used for tracking if tracks have been played / when we + Should not be used for tracking if tracks have been played or when we are done playing them. :rtype: :class:`True` if successful, else :class:`False` From ebba3a3d14c822257b1d192bf66b0f8b96bd9fa2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 21 Mar 2015 23:16:05 +0100 Subject: [PATCH 438/495] backend: Allow None as return from translate_uri() --- mopidy/backend.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index b41b92c0..ffefe047 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -203,13 +203,16 @@ class PlaybackProvider(object): """ Convert custom URI scheme to real playable uri. + *MAY be reimplemented by subclass.* + This is very likely the *only* thing you need to override as a backend - author. Typically this is where you convert any Mopidy specific URIs - to real URIs and then return it. + author. Typically this is where you convert any Mopidy specific URI + to a real URI and then return it. If you can't convert the URI just + return :class:`None`. :param uri: the URI to translate. :type uri: string - :rtype: string + :rtype: string or :class:`None` if the URI could not be translated. """ return uri @@ -230,7 +233,10 @@ class PlaybackProvider(object): :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - self.audio.set_uri(self.translate_uri(track.uri)).get() + uri = self.translate_uri(track.uri) + if not uri: + return False + self.audio.set_uri(uri).get() return True def resume(self): From c620e3a00faf8815eb632e72e14b376cc72d2933 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 01:27:07 +0100 Subject: [PATCH 439/495] docs: Add changelog for backend API breakage --- docs/changelog.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5dc90c17..3dd62478 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -71,6 +71,25 @@ v1.0.0 (UNRELEASED) :attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially backwards incompatible. (PR: :issue:`1046`) +- Changed the API for :class:`mopidy.backend.PlaybackProvider`, note that this + change is **not** backwards compatible for certain backends. These changes + are crucial to adding gapless in one of the upcoming releases. + (Fixes: :issue:`1052`, PR: :issue:`1064`) + + - :meth:`mopidy.backend.PlaybackProvider.translate_uri` has been added. It is + strongly recommended that all backends migrate to using this API for + translating "Mopidy URIs" to real ones for playback. + + - The semantics and signature of :meth:`mopidy.backend.PlaybackProvider.play` + has changed. The method is now only used to set the playback state to + playing, and no longer takes a track. + + Backends must migrate to + :meth:`mopidy.backend.PlaybackProvider.translate_uri` or + :meth:`mopidy.backend.PlaybackProvider.change_track` to continue working. + + - :meth:`mopidy.backend.PlaybackProvider.prepare_change` has been added. + **Commands** - Make the ``mopidy`` command print a friendly error message if the From 912995559211313f0e70d059830a3d35975daef8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 09:21:11 +0100 Subject: [PATCH 440/495] backend: Minor docstring adjustments Did it myself rather than holding off PR #1064 any longer. --- mopidy/backend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index ffefe047..bb90cbf4 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -201,7 +201,7 @@ class PlaybackProvider(object): def translate_uri(self, uri): """ - Convert custom URI scheme to real playable uri. + Convert custom URI scheme to real playable URI. *MAY be reimplemented by subclass.* @@ -210,9 +210,9 @@ class PlaybackProvider(object): to a real URI and then return it. If you can't convert the URI just return :class:`None`. - :param uri: the URI to translate. + :param uri: the URI to translate :type uri: string - :rtype: string or :class:`None` if the URI could not be translated. + :rtype: string or :class:`None` if the URI could not be translated """ return uri From f36110983241853eb0f257203f74085a6edccf3a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 09:23:07 +0100 Subject: [PATCH 441/495] setup: Explicitly close file Instead of relying on GC. --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index a6f3050e..9f33236f 100644 --- a/setup.py +++ b/setup.py @@ -6,9 +6,9 @@ from setuptools import find_packages, setup def get_version(filename): - init_py = open(filename).read() - metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py)) - return metadata['version'] + with open(filename) as fh: + metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fh.read())) + return metadata['version'] setup( From 12649265b18f448d3b07b0cd537b389c07b0ad59 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 09:25:04 +0100 Subject: [PATCH 442/495] Bump version to 1.0.0 So that the development version of extensions can start depending on 1.0.0 and test that they work with the changed APIs. --- mopidy/__init__.py | 2 +- tests/test_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 60d7a428..388bb9f0 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.19.5' +__version__ = '1.0.0' diff --git a/tests/test_version.py b/tests/test_version.py index 8c3f9404..932cc639 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -54,5 +54,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('0.19.1', '0.19.2') self.assertVersionLess('0.19.2', '0.19.3') self.assertVersionLess('0.19.3', '0.19.4') - self.assertVersionLess('0.19.4', __version__) - self.assertVersionLess(__version__, '0.19.6') + self.assertVersionLess('0.19.4', '0.19.5') + self.assertVersionLess('0.19.5', __version__) + self.assertVersionLess(__version__, '1.0.1') From 15872ca02ae068dd63170efc44bbc46013329863 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 20:26:11 +0100 Subject: [PATCH 443/495] travis: Don't build the debian branch --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index 8e14280f..2058fcc7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,6 +23,10 @@ script: after_success: - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" +branches: + except: + - debian + notifications: irc: channels: From fe8d6aa4e837421922481442b41f138961bb14a0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 21:28:35 +0100 Subject: [PATCH 444/495] core: Use 'must' instead of 'should' where appropriate --- mopidy/core/playlists.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 5680c018..15d35aa9 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -45,7 +45,7 @@ class PlaylistsController(object): :class:`None` or doesn't match a current backend, the first backend is asked to create the playlist. - All new playlists should be created by calling this method, and **not** + All new playlists must be created by calling this method, and **not** by creating new instances of :class:`mopidy.models.Playlist`. :param name: name of the new playlist @@ -150,14 +150,14 @@ class PlaylistsController(object): Save the playlist. For a playlist to be saveable, it must have the ``uri`` attribute set. - You should not set the ``uri`` atribute yourself, but use playlist + You must not set the ``uri`` atribute yourself, but use playlist objects returned by :meth:`create` or retrieved from :attr:`playlists`, which will always give you saveable playlists. The method returns the saved playlist. The return playlist may differ from the saved playlist. E.g. if the playlist name was changed, the returned playlist may have a different URI. The caller of this method - should throw away the playlist sent to this method, and use the + must throw away the playlist sent to this method, and use the returned playlist instead. If the playlist's URI isn't set or doesn't match the URI scheme of a From 67d4dac8620eb51cd0e6678aebe4c9c4fd211524 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sun, 22 Mar 2015 21:54:26 +0100 Subject: [PATCH 445/495] m3u: Store by URI internally Based upon tkem's PR #1053 --- mopidy/m3u/playlists.py | 80 ++++++++++++------------------------- tests/m3u/test_playlists.py | 21 +++------- 2 files changed, 31 insertions(+), 70 deletions(-) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index 2dc11628..a753f00a 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, division, unicode_literals -import copy import glob import logging import operator @@ -20,65 +19,50 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): super(M3UPlaylistsProvider, self).__init__(*args, **kwargs) self._playlists_dir = self.backend._config['m3u']['playlists_dir'] - self._playlists = [] + self._playlists = {} self.refresh() @property def playlists(self): - return copy.copy(self._playlists) + return sorted( + self._playlists.values(), key=operator.attrgetter('name')) @playlists.setter def playlists(self, playlists): - self._playlists = playlists + self._playlists = {playlist.uri: playlist for playlist in playlists} def create(self, name): playlist = self._save_m3u(Playlist(name=name)) - old_playlist = self.lookup(playlist.uri) - if old_playlist is not None: - index = self._playlists.index(old_playlist) - self._playlists[index] = playlist - else: - self._playlists.append(playlist) - self._playlists.sort(key=operator.attrgetter('name')) + self._playlists[playlist.uri] = playlist logger.info('Created playlist %s', playlist.uri) return playlist def delete(self, uri): - playlist = self.lookup(uri) - if not playlist: - logger.warn('Trying to delete unknown playlist %s', uri) - return - path = translator.playlist_uri_to_path(uri, self._playlists_dir) - if os.path.exists(path): - os.remove(path) + if uri in self._playlists: + path = translator.playlist_uri_to_path(uri, self._playlists_dir) + if os.path.exists(path): + os.remove(path) + else: + logger.warn('Trying to delete missing playlist file %s', path) + del self._playlists[uri] else: - logger.warn('Trying to delete missing playlist file %s', path) - self._playlists.remove(playlist) + logger.warn('Trying to delete unknown playlist %s', uri) def lookup(self, uri): - # TODO: store as {uri: playlist} when get_playlists() gets - # implemented - for playlist in self._playlists: - if playlist.uri == uri: - return playlist + return self._playlists.get(uri) def refresh(self): - playlists = [] + playlists = {} encoding = sys.getfilesystemencoding() for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')): relpath = os.path.basename(path) - name = os.path.splitext(relpath)[0].decode(encoding) uri = translator.path_to_playlist_uri(relpath) + name = os.path.splitext(relpath)[0].decode(encoding) + tracks = translator.parse_m3u(path) + playlists[uri] = Playlist(uri=uri, name=name, tracks=tracks) - tracks = [] - for track in translator.parse_m3u(path): - tracks.append(track) - - playlist = Playlist(uri=uri, name=name, tracks=tracks) - playlists.append(playlist) - - self.playlists = sorted(playlists, key=operator.attrgetter('name')) + self._playlists = playlists logger.info( 'Loaded %d M3U playlists from %s', @@ -86,28 +70,14 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' + assert playlist.uri in self._playlists, \ + 'Cannot save playlist with unknown URI: %s' % playlist.uri - uri = playlist.uri - # TODO: require existing (created) playlist - currently, this - # is a *should* in https://docs.mopidy.com/en/latest/api/core/ - try: - index = self._playlists.index(self.lookup(uri)) - except ValueError: - logger.warn('Saving playlist with new URI %s', uri) - index = -1 - + original_uri = playlist.uri playlist = self._save_m3u(playlist) - if index >= 0 and uri != playlist.uri: - path = translator.playlist_uri_to_path(uri, self._playlists_dir) - if os.path.exists(path): - os.remove(path) - else: - logger.warn('Trying to delete missing playlist file %s', path) - if index >= 0: - self._playlists[index] = playlist - else: - self._playlists.append(playlist) - self._playlists.sort(key=operator.attrgetter('name')) + if playlist.uri != original_uri and original_uri in self._playlists: + self.delete(original_uri) + self._playlists[playlist.uri] = playlist return playlist def _write_m3u_extinf(self, file_handle, track): diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 8c2187dc..fd77348c 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -192,14 +192,6 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.backend.playlists.playlists = [Playlist(name='a'), playlist] self.assertEqual([playlist], self.core.playlists.filter(name='b')) - def test_filter_by_name_returns_multiple_matches(self): - playlist = Playlist(name='b') - self.backend.playlists.playlists = [ - playlist, Playlist(name='a'), Playlist(name='b')] - playlists = self.core.playlists.filter(name='b') - self.assertIn(playlist, playlists) - self.assertEqual(2, len(playlists)) - def test_filter_by_name_returns_no_matches(self): self.backend.playlists.playlists = [ Playlist(name='a'), Playlist(name='b')] @@ -241,14 +233,13 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertIn(playlist2, self.core.playlists.playlists) def test_save_playlist_with_new_uri(self): - # you *should* not do this uri = 'm3u:test.m3u' - playlist = self.core.playlists.save(Playlist(uri=uri)) - self.assertIn(playlist, self.core.playlists.playlists) - self.assertEqual(uri, playlist.uri) - self.assertEqual('test', playlist.name) - path = playlist_uri_to_path(playlist.uri, self.playlists_dir) - self.assertTrue(os.path.exists(path)) + + with self.assertRaises(AssertionError): + self.core.playlists.save(Playlist(uri=uri)) + + path = playlist_uri_to_path(uri, self.playlists_dir) + self.assertFalse(os.path.exists(path)) def test_playlist_with_unknown_track(self): track = Track(uri='file:///dev/null') From efe9430c7af630dd36df52677ea6834100c11af0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 22:12:51 +0100 Subject: [PATCH 446/495] core: Update playback code to take change track into account. This change has us checking the return value of change_track when deciding if the play call was a success or if the track is unplayable. Which ensures that the following can no longer happen: 1) play stream 2) play stream that fails change_track 3) stream 1) continues playing. Correct behavior being the next stream playing instead. --- mopidy/core/playback.py | 4 ++-- tests/core/test_playback.py | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 4f51f328..d5736808 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -312,8 +312,8 @@ class PlaybackController(object): if backend: backend.playback.prepare_change() - backend.playback.change_track(tl_track.track) - success = backend.playback.play().get() + success = (backend.playback.change_track(tl_track.track).get() and + backend.playback.play().get()) if success: self.core.tracklist._mark_playing(tl_track) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index e84e7301..5f305c4e 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -12,6 +12,7 @@ from mopidy.models import Track from tests import dummy_audio as audio +# TODO: split into smaller easier to follow tests. setup is way to complex. class CorePlaybackTest(unittest.TestCase): def setUp(self): # noqa: N802 self.backend1 = mock.Mock() @@ -113,7 +114,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.change_track.assert_called_once_with(self.tracks[1]) self.playback2.play.assert_called_once_with() - def test_play_skips_to_next_on_unplayable_track(self): + def test_play_skips_to_next_on_track_without_playback_backend(self): self.core.playback.play(self.unplayable_tl_track) self.playback1.prepare_change.assert_called_once_with() @@ -124,6 +125,22 @@ class CorePlaybackTest(unittest.TestCase): self.assertEqual( self.core.playback.current_tl_track, self.tl_tracks[3]) + def test_play_skips_to_next_on_unplayable_track(self): + """Checks that we handle change track failing.""" + self.playback2.change_track().get.return_value = False + + self.core.tracklist.clear() + self.core.tracklist.add(self.tracks[:2]) + tl_tracks = self.core.tracklist.tl_tracks + + self.core.playback.play(tl_tracks[0]) + self.core.playback.play(tl_tracks[1]) + + # TODO: we really want to check that the track was marked unplayable + # and that next was called. This is just an indirect way of checking + # this :( + self.assertEqual(self.core.playback.state, core.PlaybackState.STOPPED) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_play_when_stopped_emits_events(self, listener_mock): From b8130f03cdf0d877451b9f4787065b0a1c8098b2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 22:18:25 +0100 Subject: [PATCH 447/495] Fix flake8 warning --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 71813ad7..fa75dd79 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -116,7 +116,7 @@ modindex_common_prefix = ['mopidy.'] # 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when # building the docs as part of the Debian packages on e.g. Debian wheezy. -#html_theme = 'sphinx_rtd_theme' +# html_theme = 'sphinx_rtd_theme' html_theme = 'default' html_theme_path = ['_themes'] html_static_path = ['_static'] From 08f729de76b266bb0179befb3319612bd78377b2 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Sun, 22 Mar 2015 21:30:50 +0000 Subject: [PATCH 448/495] docs: fix translate_uri method reference --- mopidy/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index bb90cbf4..0dc656ad 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -226,7 +226,7 @@ class PlaybackProvider(object): this. For most practical purposes it should be considered an internal call between backends and core that backend authors should not touch. - The default implementation will call :method:`translate_uri` which + The default implementation will call :meth:`translate_uri` which is what you want to implement. :param track: the track to play From a3e295026ac312c37e5d456e20a478164191da36 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 22:37:47 +0100 Subject: [PATCH 449/495] docs: Add changelog for core play behaviour change --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index eea122f9..91c83006 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -69,6 +69,10 @@ v1.0.0 (UNRELEASED) - :meth:`mopidy.core.TracklistController.mark_playing` - :meth:`mopidy.core.TracklistController.mark_unplayable` +- Updated :meth:`mopidy.core.PlaybackController.play` to take + :meth:`mopidy.backend.PlaybackProvider.change_track` into account when + determining success. (PR: :issue:`1071`) + **Backend API** - Remove default implementation of From 28f8a9909089f1f65feacb07579fb62d92824fd8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 23:14:29 +0100 Subject: [PATCH 450/495] review: Fixed mock use and docstring --- tests/core/test_playback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 5f305c4e..3a665d85 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -126,8 +126,8 @@ class CorePlaybackTest(unittest.TestCase): self.core.playback.current_tl_track, self.tl_tracks[3]) def test_play_skips_to_next_on_unplayable_track(self): - """Checks that we handle change track failing.""" - self.playback2.change_track().get.return_value = False + """Checks that we handle backend.change_track failing.""" + self.playback2.change_track.return_value.get.return_value = False self.core.tracklist.clear() self.core.tracklist.add(self.tracks[:2]) From 7ec23429212d819b05defb68793cf568134a9b24 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 23:03:02 +0100 Subject: [PATCH 451/495] core: Normalize search queries This is needed as otherwise each and every backend needs to handle the fact that some "bad" clients might send {'field': 'value'} instead of {'field': ['value']} Though the real problem isn't the clients but our organically grown query API. --- docs/changelog.rst | 4 ++++ mopidy/core/library.py | 20 ++++++++++++++++++-- mopidy/local/search.py | 4 ---- tests/core/test_library.py | 10 ++++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 91c83006..7261e3fb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -73,6 +73,10 @@ v1.0.0 (UNRELEASED) :meth:`mopidy.backend.PlaybackProvider.change_track` into account when determining success. (PR: :issue:`1071`) +- Updated :meth:`mopidy.core.LibraryController.search` and + :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about + bad queries from clients. (Fixes: :issue:`1067`) + **Backend API** - Remove default implementation of diff --git a/mopidy/core/library.py b/mopidy/core/library.py index b8018b16..ee0c2e64 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,11 +1,14 @@ from __future__ import absolute_import, unicode_literals import collections +import logging import operator import urlparse import pykka +logger = logging.getLogger(__name__) + class LibraryController(object): pykka_traversable = True @@ -155,7 +158,7 @@ class LibraryController(object): :type uris: list of strings or :class:`None` :rtype: list of :class:`mopidy.models.SearchResult` """ - query = query or kwargs + query = _normalize_query(query or kwargs) futures = [ backend.library.find_exact(query=query, uris=backend_uris) for (backend, backend_uris) @@ -263,9 +266,22 @@ class LibraryController(object): :type uris: list of strings or :class:`None` :rtype: list of :class:`mopidy.models.SearchResult` """ - query = query or kwargs + query = _normalize_query(query or kwargs) futures = [ backend.library.search(query=query, uris=backend_uris) for (backend, backend_uris) in self._get_backends_to_uris(uris).items()] return [result for result in pykka.get_all(futures) if result] + + +def _normalize_query(query): + broken_client = False + for (field, values) in query.items(): + if isinstance(values, basestring): + broken_client = True + query[field] = [values] + if broken_client: + logger.warning( + 'Client sent a broken search query, values must be lists. Please ' + 'check which client sent this query and file a bug against them.') + return query diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 9d6edea7..fdbe871c 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -23,8 +23,6 @@ def find_exact(tracks, query=None, limit=100, offset=0, uris=None): _validate_query(query) for (field, values) in query.items(): - if not hasattr(values, '__iter__'): - values = [values] # FIXME this is bound to be slow for large libraries for value in values: if field == 'track_no': @@ -134,8 +132,6 @@ def search(tracks, query=None, limit=100, offset=0, uris=None): _validate_query(query) for (field, values) in query.items(): - if not hasattr(values, '__iter__'): - values = [values] # FIXME this is bound to be slow for large libraries for value in values: if field == 'track_no': diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 9eacd1a2..9a23d874 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -355,3 +355,13 @@ class CoreLibraryTest(unittest.TestCase): query=dict(any=['a']), uris=None) self.library2.search.assert_called_once_with( query=dict(any=['a']), uris=None) + + def test_search_normalises_bad_queries(self): + self.core.library.search({'any': 'foobar'}) + self.library1.search.assert_called_once_with( + query={'any': ['foobar']}, uris=None) + + def test_find_exact_normalises_bad_queries(self): + self.core.library.find_exact({'any': 'foobar'}) + self.library1.find_exact.assert_called_once_with( + query={'any': ['foobar']}, uris=None) From a74bc24bdc11ee7ffcb0f2de6e7ba9e2e26a6153 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 23:54:37 +0100 Subject: [PATCH 452/495] core: Protect against old clients that implement backend.play --- mopidy/core/playback.py | 9 +++++++-- tests/core/test_playback.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d5736808..453a07d7 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -312,8 +312,13 @@ class PlaybackController(object): if backend: backend.playback.prepare_change() - success = (backend.playback.change_track(tl_track.track).get() and - backend.playback.play().get()) + try: + success = ( + backend.playback.change_track(tl_track.track).get() and + backend.playback.play().get()) + except TypeError: + logger.error('%s needs to be updated to work with this ' + 'version of Mopidy.', backend) if success: self.core.tracklist._mark_playing(tl_track) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 3a665d85..6f3c3274 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -650,3 +650,15 @@ class TestStream(unittest.TestCase): self.replay_audio_events() self.assertEqual(self.playback.get_stream_title(), None) + + +class CorePlaybackWithOldBackendTest(unittest.TestCase): + def test_type_error_from_old_backend_does_not_crash_core(self): + b = mock.Mock() + b.uri_schemes.get.return_value = ['dummy1'] + b.playback = mock.Mock(spec=backend.PlaybackProvider) + b.playback.play.side_effect = TypeError + + c = core.Core(mixer=None, backends=[b]) + c.tracklist.add([Track(uri='dummy1:a', length=40000)]) + c.playback.play() # No TypeError == test passed. From ca3c40b8bb1b4db97de064c2779ffc5e370abda5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:01:45 +0100 Subject: [PATCH 453/495] docs: Add PR #1073 to changelog --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7261e3fb..5155fc79 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -75,7 +75,7 @@ v1.0.0 (UNRELEASED) - Updated :meth:`mopidy.core.LibraryController.search` and :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about - bad queries from clients. (Fixes: :issue:`1067`) + bad queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) **Backend API** From 55b1eb73835d67cf08a7401344440df55fcac0a5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 22:16:03 +0100 Subject: [PATCH 454/495] backend: Add playlists.as_list() and playlists.get_items(uri) --- mopidy/backend.py | 30 ++++++++++++++++++++++++++++++ tests/backend/test_backend.py | 19 +++++++++++++++---- tests/dummy_backend.py | 11 +++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 0dc656ad..c1554c7f 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -318,6 +318,36 @@ class PlaylistsProvider(object): def playlists(self, playlists): raise NotImplementedError + def as_list(self): + """ + Get a list of the currently available playlists. + + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlists. In other words, no information about the playlists' content + is given. + + :rtype: list of :class:`mopidy.models.Ref` + + .. versionadded:: 1.0 + """ + raise NotImplementedError + + def get_items(self, uri): + """ + Get the items in a playlist specified by ``uri``. + + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlist's items. + + If a playlist with the given ``uri`` doesn't exist, it returns + :class:`None`. + + :rtype: list of :class:`mopidy.models.Ref`, or :class:`None` + + .. versionadded:: 1.0 + """ + raise NotImplementedError + def create(self, name): """ Create a new empty playlist with the given name. diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index c72633fb..23cfedd5 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -8,6 +8,7 @@ from tests import dummy_backend class LibraryTest(unittest.TestCase): + def test_default_get_images_impl_falls_back_to_album_image(self): album = models.Album(images=['imageuri']) track = models.Track(uri='trackuri', album=album) @@ -31,10 +32,20 @@ class LibraryTest(unittest.TestCase): class PlaylistsTest(unittest.TestCase): - def test_playlists_default_impl(self): - playlists = backend.PlaylistsProvider(backend=None) - self.assertEqual(playlists.playlists, []) + def setUp(self): # noqa: N802 + self.provider = backend.PlaylistsProvider(backend=None) + + def test_playlists_default_impl(self): + self.assertEqual(self.provider.playlists, []) with self.assertRaises(NotImplementedError): - playlists.playlists = [] + self.provider.playlists = [] + + def test_as_list_default_impl(self): + with self.assertRaises(NotImplementedError): + self.provider.as_list() + + def test_get_items_default_impl(self): + with self.assertRaises(NotImplementedError): + self.provider.get_items('some uri') diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index d4441673..9f4a0986 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -100,6 +100,17 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): super(DummyPlaylistsProvider, self).__init__(backend) self._playlists = [] + def as_list(self): + return [ + Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] + + def get_items(self, uri): + playlist = self._playlists.get(uri) + if playlist is None: + return + return [ + Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] + @property def playlists(self): return copy.copy(self._playlists) From 4f3a0839b33221124657e8e82d81190d900fa0fc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 22:51:55 +0100 Subject: [PATCH 455/495] core: Add playlists.as_list() and playlists.get_items(uri) --- docs/changelog.rst | 11 +++++++ mopidy/core/playlists.py | 50 +++++++++++++++++++++++++++---- tests/core/test_playlists.py | 57 +++++++++++++++++++++++++++++------- 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5155fc79..0fdcaa16 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -77,6 +77,17 @@ v1.0.0 (UNRELEASED) :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about bad queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) +- Add :meth:`mopidy.core.PlaylistsController.as_list`. (Fixes: :issue:`1057`, + PR: :issue:`1075`) + +- Add :meth:`mopidy.core.PlaylistsController.get_items`. (Fixes: :issue:`1057`, + PR: :issue:`1075`) + +- **Deprecated:** :meth:`mopidy.core.PlaylistsController.get_playlists`. Use + :meth:`~mopidy.core.PlaylistsController.as_list` and + :meth:`~mopidy.core.PlaylistsController.get_items` instead. (Fixes: + :issue:`1057`, PR: :issue:`1075`) + **Backend API** - Remove default implementation of diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 15d35aa9..146b8058 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -16,12 +16,52 @@ class PlaylistsController(object): self.backends = backends self.core = core - """ - Get the available playlists. + def as_list(self): + """ + Get a list of the currently available playlists. + + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlists. In other words, no information about the playlists' content + is given. + + :rtype: list of :class:`mopidy.models.Ref` + + .. versionadded:: 1.0 + """ + futures = [ + b.playlists.as_list() + for b in self.backends.with_playlists.values()] + results = pykka.get_all(futures) + return list(itertools.chain(*results)) + + def get_items(self, uri): + """ + Get the items in a playlist specified by ``uri``. + + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlist's items. + + If a playlist with the given ``uri`` doesn't exist, it returns + :class:`None`. + + :rtype: list of :class:`mopidy.models.Ref`, or :class:`None` + + .. versionadded:: 1.0 + """ + uri_scheme = urlparse.urlparse(uri).scheme + backend = self.backends.with_playlists.get(uri_scheme, None) + if backend: + return backend.playlists.get_items(uri).get() - Returns a list of :class:`mopidy.models.Playlist`. - """ def get_playlists(self, include_tracks=True): + """ + Get the available playlists. + + :rtype: list of :class:`mopidy.models.Playlist` + + .. deprecated:: 1.0 + Use :meth:`as_list` and :meth:`get_items` instead. + """ futures = [b.playlists.playlists for b in self.backends.with_playlists.values()] results = pykka.get_all(futures) @@ -33,7 +73,7 @@ class PlaylistsController(object): playlists = deprecated_property(get_playlists) """ .. deprecated:: 1.0 - Use :meth:`get_playlists` instead. + Use :meth:`as_list` and :meth:`get_items` instead. """ def create(self, name, uri_scheme=None): diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 55a75767..232631d7 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -5,19 +5,37 @@ import unittest import mock from mopidy import backend, core -from mopidy.models import Playlist, Track +from mopidy.models import Playlist, Ref, Track class PlaylistsTest(unittest.TestCase): def setUp(self): # noqa: N802 + self.plr1a = Ref.playlist(name='A', uri='dummy1:pl:a') + self.plr1b = Ref.playlist(name='B', uri='dummy1:pl:b') + self.plr2a = Ref.playlist(name='A', uri='dummy2:pl:a') + self.plr2b = Ref.playlist(name='B', uri='dummy2:pl:b') + + self.pl1a = Playlist(name='A', tracks=[Track(uri='dummy1:t:a')]) + self.pl1b = Playlist(name='B', tracks=[Track(uri='dummy1:t:b')]) + self.pl2a = Playlist(name='A', tracks=[Track(uri='dummy2:t:a')]) + self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:t:b')]) + + self.sp1 = mock.Mock(spec=backend.PlaylistsProvider) + self.sp1.as_list.return_value.get.return_value = [ + self.plr1a, self.plr1b] + self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] + + self.sp2 = mock.Mock(spec=backend.PlaylistsProvider) + self.sp2.as_list.return_value.get.return_value = [ + self.plr2a, self.plr2b] + self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] + self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] - self.sp1 = mock.Mock(spec=backend.PlaylistsProvider) self.backend1.playlists = self.sp1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] - self.sp2 = mock.Mock(spec=backend.PlaylistsProvider) self.backend2.playlists = self.sp2 # A backend without the optional playlists provider @@ -26,17 +44,34 @@ class PlaylistsTest(unittest.TestCase): self.backend3.has_playlists().get.return_value = False self.backend3.playlists = None - self.pl1a = Playlist(name='A', tracks=[Track(uri='dummy1:a')]) - self.pl1b = Playlist(name='B', tracks=[Track(uri='dummy1:b')]) - self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] - - self.pl2a = Playlist(name='A', tracks=[Track(uri='dummy2:a')]) - self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:b')]) - self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] - self.core = core.Core(mixer=None, backends=[ self.backend3, self.backend1, self.backend2]) + def test_as_list_combines_result_from_backends(self): + result = self.core.playlists.as_list() + + self.assertIn(self.plr1a, result) + self.assertIn(self.plr1b, result) + self.assertIn(self.plr2a, result) + self.assertIn(self.plr2b, result) + + def test_get_items_selects_the_matching_backend(self): + ref = Ref.track() + self.sp2.get_items.return_value.get.return_value = [ref] + + result = self.core.playlists.get_items('dummy2:pl:a') + + self.assertEqual([ref], result) + self.assertFalse(self.sp1.get_items.called) + self.sp2.get_items.assert_called_once_with('dummy2:pl:a') + + def test_get_items_with_unknown_uri_scheme_does_nothing(self): + result = self.core.playlists.get_items('unknown:a') + + self.assertIsNone(result) + self.assertFalse(self.sp1.delete.called) + self.assertFalse(self.sp2.delete.called) + def test_get_playlists_combines_result_from_backends(self): result = self.core.playlists.playlists From bd2e4f7af0cabeaa0d271aaca1cd6a8b0e7077fa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 23:43:57 +0100 Subject: [PATCH 456/495] core: Reimplement get_playlists() using new backend API --- mopidy/core/playlists.py | 16 +++++++++------- tests/core/test_playlists.py | 6 +++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 146b8058..715e5870 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -6,6 +6,7 @@ import urlparse import pykka from mopidy.core import listener +from mopidy.models import Playlist from mopidy.utils.deprecation import deprecated_property @@ -62,13 +63,14 @@ class PlaylistsController(object): .. deprecated:: 1.0 Use :meth:`as_list` and :meth:`get_items` instead. """ - futures = [b.playlists.playlists - for b in self.backends.with_playlists.values()] - results = pykka.get_all(futures) - playlists = list(itertools.chain(*results)) - if not include_tracks: - playlists = [p.copy(tracks=[]) for p in playlists] - return playlists + playlist_refs = self.as_list() + + if include_tracks: + playlists = [self.lookup(r.uri) for r in playlist_refs] + return [pl for pl in playlists if pl is not None] + else: + return [ + Playlist(uri=r.uri, name=r.name) for r in playlist_refs] playlists = deprecated_property(get_playlists) """ diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 232631d7..fecbbdcb 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -23,12 +23,12 @@ class PlaylistsTest(unittest.TestCase): self.sp1 = mock.Mock(spec=backend.PlaylistsProvider) self.sp1.as_list.return_value.get.return_value = [ self.plr1a, self.plr1b] - self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] + self.sp1.lookup.return_value.get.side_effect = [self.pl1a, self.pl1b] self.sp2 = mock.Mock(spec=backend.PlaylistsProvider) self.sp2.as_list.return_value.get.return_value = [ self.plr2a, self.plr2b] - self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] + self.sp2.lookup.return_value.get.side_effect = [self.pl2a, self.pl2b] self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] @@ -73,7 +73,7 @@ class PlaylistsTest(unittest.TestCase): self.assertFalse(self.sp2.delete.called) def test_get_playlists_combines_result_from_backends(self): - result = self.core.playlists.playlists + result = self.core.playlists.get_playlists() self.assertIn(self.pl1a, result) self.assertIn(self.pl1b, result) From 5693b454eefbcb7cf44c71de8ccae1072756c7c4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:15:34 +0100 Subject: [PATCH 457/495] m3u: Use lookup() instead of playlists prop in tests --- tests/m3u/test_playlists.py | 44 ++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index fd77348c..443cfb9e 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -120,12 +120,10 @@ class M3UPlaylistsProviderTest(unittest.TestCase): backend = self.backend_class(config=self.config, audio=self.audio) self.assert_(backend.playlists.playlists) - self.assertEqual( - playlist.uri, backend.playlists.playlists[0].uri) - self.assertEqual( - playlist.name, backend.playlists.playlists[0].name) - self.assertEqual( - track.uri, backend.playlists.playlists[0].tracks[0].uri) + result = backend.playlists.lookup(playlist.uri) + self.assertEqual(playlist.uri, result.uri) + self.assertEqual(playlist.name, result.name) + self.assertEqual(track.uri, result.tracks[0].uri) @unittest.SkipTest def test_santitising_of_playlist_filenames(self): @@ -156,15 +154,15 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_delete_playlist_removes_it_from_the_collection(self): playlist = self.core.playlists.create('test') - self.assertIn(playlist, self.core.playlists.playlists) + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) self.core.playlists.delete(playlist.uri) - self.assertNotIn(playlist, self.core.playlists.playlists) + self.assertIsNone(self.core.playlists.lookup(playlist.uri)) def test_delete_playlist_without_file(self): playlist = self.core.playlists.create('test') - self.assertIn(playlist, self.core.playlists.playlists) + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) path = playlist_uri_to_path(playlist.uri, self.playlists_dir) self.assertTrue(os.path.exists(path)) @@ -173,11 +171,11 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertFalse(os.path.exists(path)) self.core.playlists.delete(playlist.uri) - self.assertNotIn(playlist, self.core.playlists.playlists) + self.assertIsNone(self.core.playlists.lookup(playlist.uri)) def test_filter_without_criteria(self): self.assertEqual( - self.core.playlists.playlists, self.core.playlists.filter()) + self.core.playlists.get_playlists(), self.core.playlists.filter()) def test_filter_with_wrong_criteria(self): self.assertEqual([], self.core.playlists.filter(name='foo')) @@ -188,13 +186,14 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertEqual([playlist], playlists) def test_filter_by_name_returns_single_match(self): - playlist = Playlist(name='b') - self.backend.playlists.playlists = [Playlist(name='a'), playlist] + playlist = Playlist(uri='m3u:b', name='b') + self.backend.playlists.playlists = [ + Playlist(uri='m3u:a', name='a'), playlist] self.assertEqual([playlist], self.core.playlists.filter(name='b')) def test_filter_by_name_returns_no_matches(self): self.backend.playlists.playlists = [ - Playlist(name='a'), Playlist(name='b')] + Playlist(uri='m3u:a', name='a'), Playlist(uri='m3u:b', name='b')] self.assertEqual([], self.core.playlists.filter(name='c')) def test_lookup_finds_playlist_by_uri(self): @@ -206,31 +205,32 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_refresh(self): playlist = self.core.playlists.create('test') - self.assertIn(playlist, self.core.playlists.playlists) + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) self.core.playlists.refresh() - self.assertIn(playlist, self.core.playlists.playlists) + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) def test_save_replaces_existing_playlist_with_updated_playlist(self): playlist1 = self.core.playlists.create('test1') - self.assertIn(playlist1, self.core.playlists.playlists) + self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) playlist2 = playlist1.copy(name='test2') playlist2 = self.core.playlists.save(playlist2) - self.assertNotIn(playlist1, self.core.playlists.playlists) - self.assertIn(playlist2, self.core.playlists.playlists) + self.assertIsNone(self.core.playlists.lookup(playlist1.uri)) + self.assertEqual(playlist2, self.core.playlists.lookup(playlist2.uri)) def test_create_replaces_existing_playlist_with_updated_playlist(self): track = Track(uri=generate_song(1)) playlist1 = self.core.playlists.create('test') playlist1 = self.core.playlists.save(playlist1.copy(tracks=[track])) - self.assertIn(playlist1, self.core.playlists.playlists) + self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) playlist2 = self.core.playlists.create('test') self.assertEqual(playlist1.uri, playlist2.uri) - self.assertNotIn(playlist1, self.core.playlists.playlists) - self.assertIn(playlist2, self.core.playlists.playlists) + self.assertNotEqual( + playlist1, self.core.playlists.lookup(playlist1.uri)) + self.assertEqual(playlist2, self.core.playlists.lookup(playlist1.uri)) def test_save_playlist_with_new_uri(self): uri = 'm3u:test.m3u' From 4bae9c874c44dedaae64f26dbe19ec165636bab8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:16:13 +0100 Subject: [PATCH 458/495] m3u: Add playlists.as_list() --- mopidy/m3u/playlists.py | 8 +++++++- tests/m3u/test_playlists.py | 23 +++++++++++------------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index a753f00a..d9eb341e 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -8,7 +8,7 @@ import sys from mopidy import backend from mopidy.m3u import translator -from mopidy.models import Playlist +from mopidy.models import Playlist, Ref logger = logging.getLogger(__name__) @@ -22,6 +22,12 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): self._playlists = {} self.refresh() + def as_list(self): + refs = [ + Ref.playlist(uri=pl.uri, name=pl.name) + for pl in self._playlists.values()] + return sorted(refs, key=operator.attrgetter('name')) + @property def playlists(self): return sorted( diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 443cfb9e..83dec321 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -146,8 +146,8 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assert_(self.core.playlists.playlists) self.assertIn(playlist, self.core.playlists.playlists) - def test_playlists_empty_to_start_with(self): - self.assert_(not self.core.playlists.playlists) + def test_as_list_empty_to_start_with(self): + self.assertEqual(len(self.core.playlists.as_list()), 0) def test_delete_non_existant_playlist(self): self.core.playlists.delete('m3u:unknown') @@ -249,12 +249,11 @@ class M3UPlaylistsProviderTest(unittest.TestCase): backend = self.backend_class(config=self.config, audio=self.audio) - self.assert_(backend.playlists.playlists) - self.assertEqual('m3u:test.m3u', backend.playlists.playlists[0].uri) - self.assertEqual( - playlist.name, backend.playlists.playlists[0].name) - self.assertEqual( - track.uri, backend.playlists.playlists[0].tracks[0].uri) + self.assertEqual(len(backend.playlists.as_list()), 1) + result = backend.playlists.lookup('m3u:test.m3u') + self.assertEqual('m3u:test.m3u', result.uri) + self.assertEqual(playlist.name, result.name) + self.assertEqual(track.uri, result.tracks[0].uri) def test_playlist_sort_order(self): def check_order(playlists, names): @@ -264,18 +263,18 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.core.playlists.create('a') self.core.playlists.create('b') - check_order(self.core.playlists.playlists, ['a', 'b', 'c']) + check_order(self.core.playlists.as_list(), ['a', 'b', 'c']) self.core.playlists.refresh() - check_order(self.core.playlists.playlists, ['a', 'b', 'c']) + check_order(self.core.playlists.as_list(), ['a', 'b', 'c']) playlist = self.core.playlists.lookup('m3u:a.m3u') playlist = playlist.copy(name='d') playlist = self.core.playlists.save(playlist) - check_order(self.core.playlists.playlists, ['b', 'c', 'd']) + check_order(self.core.playlists.as_list(), ['b', 'c', 'd']) self.core.playlists.delete('m3u:c.m3u') - check_order(self.core.playlists.playlists, ['b', 'd']) + check_order(self.core.playlists.as_list(), ['b', 'd']) From e3f2e368c7ebca2cc05d324b3bc360075ffe2b29 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:28:49 +0100 Subject: [PATCH 459/495] m3u: Add playlists.get_items() --- mopidy/m3u/playlists.py | 6 ++++++ tests/m3u/test_playlists.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index d9eb341e..d5f2b1e9 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -28,6 +28,12 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): for pl in self._playlists.values()] return sorted(refs, key=operator.attrgetter('name')) + def get_items(self, uri): + playlist = self._playlists.get(uri) + if playlist is None: + return None + return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] + @property def playlists(self): return sorted( diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 83dec321..be94ed2f 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -278,3 +278,20 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.core.playlists.delete('m3u:c.m3u') check_order(self.core.playlists.as_list(), ['b', 'd']) + + def test_get_items_returns_item_refs(self): + track = Track(uri='dummy:a', name='A', length=60000) + playlist = self.core.playlists.create('test') + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + + item_refs = self.core.playlists.get_items(playlist.uri) + + self.assertEqual(len(item_refs), 1) + self.assertEqual(item_refs[0].type, 'track') + self.assertEqual(item_refs[0].uri, 'dummy:a') + self.assertEqual(item_refs[0].name, 'A') + + def test_get_items_of_unknown_playlist_returns_none(self): + item_refs = self.core.playlists.get_items('dummy:unknown') + + self.assertIsNone(item_refs) From d37bd62bb1a921360370eb6bb9b2a2b475fc0b55 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:32:19 +0100 Subject: [PATCH 460/495] backend: Remove playlists.playlists property --- docs/changelog.rst | 12 +++++++++++- mopidy/backend.py | 18 ------------------ tests/backend/test_backend.py | 6 ------ 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0fdcaa16..5d68143d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -94,7 +94,7 @@ v1.0.0 (UNRELEASED) :attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially backwards incompatible. (PR: :issue:`1046`) -- Changed the API for :class:`mopidy.backend.PlaybackProvider`, note that this +- Changed the API for :class:`mopidy.backend.PlaybackProvider`. Note that this change is **not** backwards compatible for certain backends. These changes are crucial to adding gapless in one of the upcoming releases. (Fixes: :issue:`1052`, PR: :issue:`1064`) @@ -113,6 +113,16 @@ v1.0.0 (UNRELEASED) - :meth:`mopidy.backend.PlaybackProvider.prepare_change` has been added. +- Changed the API for :class:`mopidy.backend.PlaylistsProvider`. Note that this + change is **not** backwards compatible. These changes are important to reduce + the Mopidy startup time. (Fixes: :issue:`1057`, PR: :issue:`1075`) + + - Add :meth:`mopidy.backend.PlaylistsProvider.as_list`. + + - Add :meth:`mopidy.backend.PlaylistsProvider.get_items`. + + - Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property. + **Commands** - Make the ``mopidy`` command print a friendly error message if the diff --git a/mopidy/backend.py b/mopidy/backend.py index c1554c7f..02a624d9 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -300,24 +300,6 @@ class PlaylistsProvider(object): def __init__(self, backend): self.backend = backend - # TODO Replace playlists property with a get_playlists() method which - # returns playlist Ref's instead of the gigantic data structures we - # currently make available. lookup() should be used for getting full - # playlists with all details. - - @property - def playlists(self): - """ - Currently available playlists. - - Read/write. List of :class:`mopidy.models.Playlist`. - """ - return [] - - @playlists.setter # noqa - def playlists(self, playlists): - raise NotImplementedError - def as_list(self): """ Get a list of the currently available playlists. diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index 23cfedd5..e6aac76f 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -36,12 +36,6 @@ class PlaylistsTest(unittest.TestCase): def setUp(self): # noqa: N802 self.provider = backend.PlaylistsProvider(backend=None) - def test_playlists_default_impl(self): - self.assertEqual(self.provider.playlists, []) - - with self.assertRaises(NotImplementedError): - self.provider.playlists = [] - def test_as_list_default_impl(self): with self.assertRaises(NotImplementedError): self.provider.as_list() From df604bb3e54edb5de5a770775cc03b032e09801e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:49:56 +0100 Subject: [PATCH 461/495] core: Deprecated playlists.filter() --- docs/changelog.rst | 3 +++ mopidy/core/playlists.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5d68143d..4a8464c6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -88,6 +88,9 @@ v1.0.0 (UNRELEASED) :meth:`~mopidy.core.PlaylistsController.get_items` instead. (Fixes: :issue:`1057`, PR: :issue:`1075`) +- **Deprecated:** :meth:`mopidy.core.PlaylistsController.filter`. Use + :meth:`~mopidy.core.PlaylistsController.as_list` and filter yourself. + **Backend API** - Remove default implementation of diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 715e5870..0262deaa 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -141,6 +141,9 @@ class PlaylistsController(object): :param criteria: one or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.Playlist` + + .. deprecated:: 1.0 + Use :meth:`as_list` and filter yourself. """ criteria = criteria or kwargs matches = self.playlists From ca02dbb676989023dddafc2383b2a8bf92234b85 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 00:11:59 +0100 Subject: [PATCH 462/495] core: Make change_track internal as it going away in 1.x --- mopidy/core/playback.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 453a07d7..96c5e7da 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -183,7 +183,7 @@ class PlaybackController(object): # Methods # TODO: remove this. - def change_track(self, tl_track, on_error_step=1): + def _change_track(self, tl_track, on_error_step=1): """ Change to the given track, keeping the current playback state. @@ -215,7 +215,7 @@ class PlaybackController(object): next_tl_track = self.core.tracklist.eot_track(original_tl_track) if next_tl_track: - self.change_track(next_tl_track) + self._change_track(next_tl_track) else: self.stop() self.set_current_tl_track(None) @@ -250,7 +250,7 @@ class PlaybackController(object): # TODO: switch to: # backend.play(track) # wait for state change? - self.change_track(next_tl_track) + self._change_track(next_tl_track) else: self.stop() self.set_current_tl_track(None) @@ -344,7 +344,7 @@ class PlaybackController(object): # TODO: switch to: # self.play(....) # wait for state change? - self.change_track( + self._change_track( self.core.tracklist.previous_track(tl_track), on_error_step=-1) def resume(self): From fd04cd918fd5cd6c17ae1cde9d0f77e672117c9d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 00:15:56 +0100 Subject: [PATCH 463/495] core: Remove on_error_step from play arguments --- mopidy/core/playback.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 96c5e7da..aeb5edbf 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -197,7 +197,7 @@ class PlaybackController(object): self.stop() self.set_current_tl_track(tl_track) if old_state == PlaybackState.PLAYING: - self.play(on_error_step=on_error_step) + self._play(on_error_step=on_error_step) elif old_state == PlaybackState.PAUSED: self.pause() @@ -267,20 +267,17 @@ class PlaybackController(object): self.set_state(PlaybackState.PAUSED) self._trigger_track_playback_paused() - def play(self, tl_track=None, on_error_step=1): + def play(self, tl_track=None): """ Play the given track, or if the given track is :class:`None`, play the currently active track. :param tl_track: track to play :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` - :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track. **INTERNAL** - :type on_error_step: int, -1 or 1 """ + self._play(tl_track, 1) - assert on_error_step in (-1, 1) - + def _play(self, tl_track=None, on_error_step=1): if tl_track is None: if self.get_state() == PlaybackState.PAUSED: return self.resume() From 07f0453c6ee1d8865b366f500f91143ae6b86c08 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 00:37:50 +0100 Subject: [PATCH 464/495] core: Make event triggers internal --- mopidy/core/actor.py | 4 +-- mopidy/core/playback.py | 6 ++-- mopidy/core/tracklist.py | 2 +- tests/core/test_playback.py | 9 ++++-- tests/local/test_playback.py | 53 +++++++++++++++++++----------------- 5 files changed, 40 insertions(+), 34 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 32070684..b21e9e20 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -84,10 +84,10 @@ class Core( """ def reached_end_of_stream(self): - self.playback.on_end_of_track() + self.playback._on_end_of_track() def stream_changed(self, uri): - self.playback.on_stream_changed(uri) + self.playback._on_stream_changed(uri) def state_changed(self, old_state, new_state, target_state): # XXX: This is a temporary fix for issue #232 while we wait for a more diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index aeb5edbf..e97d5c5e 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -202,7 +202,7 @@ class PlaybackController(object): self.pause() # TODO: this is not really end of track, this is on_need_next_track - def on_end_of_track(self): + def _on_end_of_track(self): """ Tell the playback controller that end of track is reached. @@ -222,7 +222,7 @@ class PlaybackController(object): self.core.tracklist._mark_played(original_tl_track) - def on_tracklist_change(self): + def _on_tracklist_change(self): """ Tell the playback controller that the current playlist has changed. @@ -233,7 +233,7 @@ class PlaybackController(object): self.stop() self.set_current_tl_track(None) - def on_stream_changed(self, uri): + def _on_stream_changed(self, uri): self._stream_title = None def next(self): diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 963dcadf..9186ae42 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -67,7 +67,7 @@ class TracklistController(object): def _increase_version(self): self._version += 1 - self.core.playback.on_tracklist_change() + self.core.playback._on_tracklist_change() self._trigger_tracklist_changed() version = deprecated_property(get_version) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 6f3c3274..972b6dea 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -50,6 +50,9 @@ class CorePlaybackTest(unittest.TestCase): self.unplayable_tl_track = self.tl_tracks[2] self.duration_less_tl_track = self.tl_tracks[4] + def trigger_end_of_track(self): + self.core.playback._on_end_of_track() + def test_get_current_tl_track_none(self): self.core.playback.set_current_tl_track(None) @@ -419,7 +422,7 @@ class CorePlaybackTest(unittest.TestCase): tl_track = self.tl_tracks[0] self.core.playback.play(tl_track) - self.core.playback.on_end_of_track() + self.trigger_end_of_track() self.assertIn(tl_track, self.core.tracklist.tl_tracks) @@ -428,7 +431,7 @@ class CorePlaybackTest(unittest.TestCase): self.core.playback.play(tl_track) self.core.tracklist.consume = True - self.core.playback.on_end_of_track() + self.trigger_end_of_track() self.assertNotIn(tl_track, self.core.tracklist.tl_tracks) @@ -438,7 +441,7 @@ class CorePlaybackTest(unittest.TestCase): self.core.playback.play(self.tl_tracks[0]) listener_mock.reset_mock() - self.core.playback.on_end_of_track() + self.trigger_end_of_track() self.assertListEqual( listener_mock.send.mock_calls, diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 4c4ded24..6ea82f2d 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -39,6 +39,9 @@ class LocalPlaybackProviderTest(unittest.TestCase): track = Track(uri=uri, length=4464) self.tracklist.add([track]) + def trigger_end_of_track(self): + self.playback._on_end_of_track() + def setUp(self): # noqa: N802 self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( @@ -163,7 +166,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_current_track_after_completed_playlist(self): self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @@ -406,7 +409,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): old_position = self.tracklist.index(tl_track) old_uri = tl_track.track.uri - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual( @@ -416,11 +419,11 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_end_of_track_return_value(self): self.playback.play() - self.assertEqual(self.playback.on_end_of_track(), None) + self.assertEqual(self.trigger_end_of_track(), None) @populate_tracklist def test_end_of_track_does_not_trigger_playback(self): - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_tracklist @@ -433,7 +436,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.index(tl_track), i) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) @@ -442,7 +445,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.playback.play() for _ in self.tracks: - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) @@ -452,7 +455,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.assertEqual(self.playback.current_track, self.tracks[0]) def test_end_of_track_for_empty_playlist(self): - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_tracklist @@ -462,7 +465,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.backend.playback.play = lambda: return_values.pop() self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertNotEqual(self.playback.current_track, self.tracks[1]) self.assertEqual(self.playback.current_track, self.tracks[2]) @@ -482,7 +485,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_end_of_track_track_after_previous(self): self.playback.play() - self.playback.on_end_of_track() + self.trigger_end_of_track() self.playback.previous() tl_track = self.playback.current_tl_track self.assertEqual( @@ -496,7 +499,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): def test_end_of_track_track_at_end_of_playlist(self): self.playback.play() for _ in self.tracklist.tl_tracks[1:]: - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.next_track(tl_track), None) @@ -505,7 +508,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.repeat = True self.playback.play() for _ in self.tracks[1:]: - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual( self.tracklist.next_track(tl_track), self.tl_tracks[0]) @@ -524,7 +527,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): def test_end_of_track_with_consume(self): self.tracklist.consume = True self.playback.play() - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertNotIn(self.tracks[0], self.tracklist.tracks) @populate_tracklist @@ -535,7 +538,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[-1]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[-2]) @populate_tracklist @@ -654,19 +657,19 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_tracklist_position_at_end_of_playlist(self): self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.index(tl_track), None) def test_on_tracklist_change_gets_called(self): - callback = self.playback.on_tracklist_change + callback = self.playback._on_tracklist_change def wrapper(): wrapper.called = True return callback() wrapper.called = False - self.playback.on_tracklist_change = wrapper + self.playback._on_tracklist_change = wrapper self.tracklist.add([Track()]) self.assert_(wrapper.called) @@ -917,7 +920,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.consume = True self.playback.play() for _ in range(len(self.tracklist.tracks)): - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(len(self.tracklist.tracks), 0) @populate_tracklist @@ -944,7 +947,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_end_of_song_starts_next_track(self): self.playback.play() - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_tracklist @@ -953,7 +956,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.repeat = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[0]) @populate_tracklist @@ -963,7 +966,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() current_track = self.playback.current_track - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, current_track) @populate_tracklist @@ -971,7 +974,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.single = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) @@ -980,14 +983,14 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.single = True self.tracklist.random = True self.playback.play() - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_tracklist def test_end_of_playlist_stops(self): self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_repeat_off_by_default(self): @@ -1013,7 +1016,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() for _ in self.tracks[1:]: - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.eot_track(tl_track), None) @@ -1034,7 +1037,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() for _ in self.tracks: - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertNotEqual(self.tracklist.eot_track(tl_track), None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) From 6d22c4fd5970430f96e937432862b0cf23d18004 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 00:48:48 +0100 Subject: [PATCH 465/495] core: Remove set_current_tl_track --- mopidy/core/playback.py | 15 +++++++-------- tests/core/test_playback.py | 17 ++++++++++------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index e97d5c5e..8d9b7777 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -41,15 +41,14 @@ class PlaybackController(object): """ return self._current_tl_track - def set_current_tl_track(self, value): + def _set_current_tl_track(self, value): """Set the currently playing or selected track. *Internal:* This is only for use by Mopidy's test suite. """ self._current_tl_track = value - current_tl_track = deprecated_property( - get_current_tl_track, set_current_tl_track) + current_tl_track = deprecated_property(get_current_tl_track) """ .. deprecated:: 1.0 Use :meth:`get_current_tl_track` instead. @@ -195,7 +194,7 @@ class PlaybackController(object): """ old_state = self.get_state() self.stop() - self.set_current_tl_track(tl_track) + self._set_current_tl_track(tl_track) if old_state == PlaybackState.PLAYING: self._play(on_error_step=on_error_step) elif old_state == PlaybackState.PAUSED: @@ -218,7 +217,7 @@ class PlaybackController(object): self._change_track(next_tl_track) else: self.stop() - self.set_current_tl_track(None) + self._set_current_tl_track(None) self.core.tracklist._mark_played(original_tl_track) @@ -231,7 +230,7 @@ class PlaybackController(object): tracklist = self.core.tracklist.get_tl_tracks() if self.get_current_tl_track() not in tracklist: self.stop() - self.set_current_tl_track(None) + self._set_current_tl_track(None) def _on_stream_changed(self, uri): self._stream_title = None @@ -253,7 +252,7 @@ class PlaybackController(object): self._change_track(next_tl_track) else: self.stop() - self.set_current_tl_track(None) + self._set_current_tl_track(None) self.core.tracklist._mark_played(original_tl_track) @@ -302,7 +301,7 @@ class PlaybackController(object): if self.get_state() == PlaybackState.PLAYING: self.stop() - self.set_current_tl_track(tl_track) + self._set_current_tl_track(tl_track) self.set_state(PlaybackState.PLAYING) backend = self._get_backend() success = False diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 972b6dea..7c4db0d6 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -53,8 +53,11 @@ class CorePlaybackTest(unittest.TestCase): def trigger_end_of_track(self): self.core.playback._on_end_of_track() + def set_current_tl_track(self, tl_track): + self.core.playback._set_current_tl_track(tl_track) + def test_get_current_tl_track_none(self): - self.core.playback.set_current_tl_track(None) + self.set_current_tl_track(None) self.assertEqual( self.core.playback.get_current_tl_track(), None) @@ -217,7 +220,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.pause.assert_called_once_with() def test_pause_changes_state_even_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.pause() self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) @@ -260,7 +263,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.resume.assert_called_once_with() def test_resume_does_nothing_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.state = core.PlaybackState.PAUSED self.core.playback.resume() @@ -303,7 +306,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.stop.assert_called_once_with() def test_stop_changes_state_even_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.state = core.PlaybackState.PAUSED self.core.playback.stop() @@ -498,7 +501,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.seek.assert_called_once_with(10000) def test_seek_fails_for_unplayable_track(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.state = core.PlaybackState.PLAYING success = self.core.playback.seek(1000) @@ -507,7 +510,7 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.seek.called) def test_seek_fails_for_track_without_duration(self): - self.core.playback.current_tl_track = self.duration_less_tl_track + self.set_current_tl_track(self.duration_less_tl_track) self.core.playback.state = core.PlaybackState.PLAYING success = self.core.playback.seek(1000) @@ -557,7 +560,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.get_time_position.assert_called_once_with() def test_time_position_returns_0_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) result = self.core.playback.time_position From 6815868e241400e9ae2144fa434ae7b3a507de1e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 13:22:50 +0100 Subject: [PATCH 466/495] core: Doc Playlist.last_modified not being set ...if get_playlists() is called with include_tracks=False --- mopidy/core/playlists.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 0262deaa..54797abe 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -60,6 +60,11 @@ class PlaylistsController(object): :rtype: list of :class:`mopidy.models.Playlist` + .. versionchanged:: 1.0 + If you call the method with ``include_tracks=False``, the + :attr:`~mopidy.models.Playlist.last_modified` field of the returned + playlists is no longer set. + .. deprecated:: 1.0 Use :meth:`as_list` and :meth:`get_items` instead. """ From dbe4165a0f3663bfe9b77faa6f47edff5c1563df Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 13:31:25 +0100 Subject: [PATCH 467/495] m3u: Only test through core actor --- tests/m3u/test_playlists.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index be94ed2f..07ffc0a3 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -28,10 +28,10 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.config['m3u']['playlists_dir'] = tempfile.mkdtemp() self.playlists_dir = self.config['m3u']['playlists_dir'] - self.audio = dummy_audio.create_proxy() - self.backend = actor.M3UBackend.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core(backends=[self.backend]) + audio = dummy_audio.create_proxy() + backend = actor.M3UBackend.start( + config=self.config, audio=audio).proxy() + self.core = core.Core(backends=[backend]) def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() @@ -117,10 +117,8 @@ class M3UPlaylistsProviderTest(unittest.TestCase): playlist = playlist.copy(tracks=[track]) playlist = self.core.playlists.save(playlist) - backend = self.backend_class(config=self.config, audio=self.audio) - - self.assert_(backend.playlists.playlists) - result = backend.playlists.lookup(playlist.uri) + self.assertEqual(len(self.core.playlists.as_list()), 1) + result = self.core.playlists.lookup(playlist.uri) self.assertEqual(playlist.uri, result.uri) self.assertEqual(playlist.name, result.name) self.assertEqual(track.uri, result.tracks[0].uri) @@ -186,14 +184,15 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertEqual([playlist], playlists) def test_filter_by_name_returns_single_match(self): - playlist = Playlist(uri='m3u:b', name='b') - self.backend.playlists.playlists = [ - Playlist(uri='m3u:a', name='a'), playlist] + self.core.playlists.create('a') + playlist = self.core.playlists.create('b') + self.assertEqual([playlist], self.core.playlists.filter(name='b')) def test_filter_by_name_returns_no_matches(self): - self.backend.playlists.playlists = [ - Playlist(uri='m3u:a', name='a'), Playlist(uri='m3u:b', name='b')] + self.core.playlists.create('a') + self.core.playlists.create('b') + self.assertEqual([], self.core.playlists.filter(name='c')) def test_lookup_finds_playlist_by_uri(self): @@ -247,10 +246,8 @@ class M3UPlaylistsProviderTest(unittest.TestCase): playlist = playlist.copy(tracks=[track]) playlist = self.core.playlists.save(playlist) - backend = self.backend_class(config=self.config, audio=self.audio) - - self.assertEqual(len(backend.playlists.as_list()), 1) - result = backend.playlists.lookup('m3u:test.m3u') + self.assertEqual(len(self.core.playlists.as_list()), 1) + result = self.core.playlists.lookup('m3u:test.m3u') self.assertEqual('m3u:test.m3u', result.uri) self.assertEqual(playlist.name, result.name) self.assertEqual(track.uri, result.tracks[0].uri) From c0f99466c3d6aa1bea3c1e98e2dbd72fe89078b2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 13:31:42 +0100 Subject: [PATCH 468/495] m3u: Remove playlists property --- mopidy/m3u/playlists.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index d5f2b1e9..1fc5b4c3 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -34,15 +34,6 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): return None return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] - @property - def playlists(self): - return sorted( - self._playlists.values(), key=operator.attrgetter('name')) - - @playlists.setter - def playlists(self, playlists): - self._playlists = {playlist.uri: playlist for playlist in playlists} - def create(self, name): playlist = self._save_m3u(Playlist(name=name)) self._playlists[playlist.uri] = playlist From 97fd102fa2520d2e992e6e4be3fcb19f363db0fe Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 15:02:25 +0100 Subject: [PATCH 469/495] docs: Add core API cleanup to changelog --- docs/changelog.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5155fc79..40d707a9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -77,6 +77,18 @@ v1.0.0 (UNRELEASED) :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about bad queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) +- Reduced API surface of core. (Fixes: :issue:`1070`, PR: :issue:`1076`) + + - Made ``mopidy.core.PlaybackController.change_track`` internal. + - Removed ``on_error_step`` from :meth:`mopidy.core.PlaybackController.play` + - Made the following event triggers internal: + + - ``mopidy.core.PlaybackController.on_end_of_track`` + - ``mopidy.core.PlaybackController.on_stream_changed`` + - ``mopidy.core.PlaybackController.on_tracklist_changed`` + + - Made ``mopidy.core.PlaybackController.set_current_tl_track`` internal. + **Backend API** - Remove default implementation of From f4452b22db064b7843fd8111d94103521cf9a2e6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 15:02:37 +0100 Subject: [PATCH 470/495] core: Minor readability improvement --- mopidy/core/playback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 8d9b7777..61bbc60c 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -274,7 +274,7 @@ class PlaybackController(object): :param tl_track: track to play :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` """ - self._play(tl_track, 1) + self._play(tl_track, on_error_step=1) def _play(self, tl_track=None, on_error_step=1): if tl_track is None: From 81f2e5c6f06318d1649aae25e704470085f8a168 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 23:09:31 +0100 Subject: [PATCH 471/495] core: Deprecate empty queries (Fixes #1072) --- mopidy/core/library.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index ee0c2e64..2904d451 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -124,8 +124,10 @@ class LibraryController(object): """ Search the library for tracks where ``field`` is ``values``. - If the query is empty, and the backend can support it, all available - tracks are returned. + .. deprecated:: 1.0 + Previously, if the query was empty, and the backend could support + it, all available tracks were returned. This has not changed, but + it is strongly discouraged. No new code should rely on this If ``uris`` is given, the search is limited to results from within the URI roots. For example passing ``uris=['file:']`` will limit the search @@ -233,8 +235,11 @@ class LibraryController(object): """ Search the library for tracks where ``field`` contains ``values``. - If the query is empty, and the backend can support it, all available - tracks are returned. + .. deprecated:: 1.0 + Previously, if the query was empty, and the backend could support + it, all available tracks were returned. This has not changed, but + it is strongly discouraged. No new code should rely on this + behavior. If ``uris`` is given, the search is limited to results from within the URI roots. For example passing ``uris=['file:']`` will limit the search @@ -282,6 +287,12 @@ def _normalize_query(query): query[field] = [values] if broken_client: logger.warning( - 'Client sent a broken search query, values must be lists. Please ' - 'check which client sent this query and file a bug against them.') + 'A client or frontend made a broken library search. Values in ' + 'queries must be lists of strings, not a string. Please check what' + ' sent this query and file a bug. Query: %s', query) + if not query: + logger.warning( + 'A client or frontend made a library search with an empty query. ' + 'This is strongly discouraged. Please check what sent this query ' + 'and file a bug.') return query From 636d8f11157d7f2b492e5fa2d24b58307c4c2f15 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 23:16:35 +0100 Subject: [PATCH 472/495] core: Add verionadded annotations to LibraryController methods --- mopidy/core/library.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 2904d451..2ab90bef 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -63,6 +63,8 @@ class LibraryController(object): :param string uri: URI to browse :rtype: list of :class:`mopidy.models.Ref` + + .. versionadded:: 0.18 """ if uri is None: backends = self.backends.with_library_browse.values() @@ -88,6 +90,8 @@ class LibraryController(object): :param dict query: Query to use for limiting results, see :meth:`search` for details about the query format. :rtype: set of values corresponding to the requested field type. + + .. versionadded:: 1.0 """ futures = [b.library.get_distinct(field, query) for b in self.backends.with_library.values()] @@ -108,6 +112,8 @@ class LibraryController(object): :param list uris: list of URIs to find images for :rtype: {uri: tuple of :class:`mopidy.models.Image`} + + .. versionadded:: 1.0 """ futures = [ backend.library.get_images(backend_uris) From 24fe242d561803d105e07457b762446d97db7866 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 23:55:03 +0100 Subject: [PATCH 473/495] core/backend: Remove find_exact from backends Functionality has been replaced with an `exact` param in the search method. Backends that still implement find_exact will continue being called via the old method for now. --- docs/changelog.rst | 5 +++ mopidy/backend.py | 14 ++----- mopidy/core/library.py | 19 +++++++-- tests/core/test_library.py | 84 +++++++++++++++++++++++--------------- 4 files changed, 75 insertions(+), 47 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 09bf743a..94f35433 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -138,6 +138,11 @@ v1.0.0 (UNRELEASED) - Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property. +- Removed ``find_exact`` from :class:`mopidy.backend.LibraryProvider` and + added an ``exact`` param to :meth:`mopidy.backend.LibraryProvider.search` + to replace the old code path. Core will continue supporting backends that + have not upgraded for now. + **Commands** - Make the ``mopidy`` command print a friendly error message if the diff --git a/mopidy/backend.py b/mopidy/backend.py index 02a624d9..63184853 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -119,15 +119,6 @@ class LibraryProvider(object): result[uri] = [models.Image(uri=u) for u in image_uris] return result - # TODO: replace with search(query, exact=True, ...) - def find_exact(self, query=None, uris=None): - """ - See :meth:`mopidy.core.LibraryController.find_exact`. - - *MAY be implemented by subclass.* - """ - pass - def lookup(self, uri): """ See :meth:`mopidy.core.LibraryController.lookup`. @@ -144,11 +135,14 @@ class LibraryProvider(object): """ pass - def search(self, query=None, uris=None): + def search(self, query=None, uris=None, exact=False): """ See :meth:`mopidy.core.LibraryController.search`. *MAY be implemented by subclass.* + + .. versionadded:: 1.0 + The ``exact`` param which replaces the old ``find_exact``. """ pass diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 2ab90bef..80c61bbb 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -160,6 +160,12 @@ class LibraryController(object): {'any': ['a']}, uris=['file:///media/music', 'spotify:']) find_exact(any=['a'], uris=['file:///media/music', 'spotify:']) + .. versionchanged:: 1.0 + This method now calls + :meth:`~mopidy.backend.LibraryProvider.search` on the backends + instead of the deprecated ``find_exact``. If the backend still + implements ``find_exact`` we will continue to use it for now. + :param query: one or more queries to search for :type query: dict :param uris: zero or more URI roots to limit the search to @@ -167,10 +173,15 @@ class LibraryController(object): :rtype: list of :class:`mopidy.models.SearchResult` """ query = _normalize_query(query or kwargs) - futures = [ - backend.library.find_exact(query=query, uris=backend_uris) - for (backend, backend_uris) - in self._get_backends_to_uris(uris).items()] + futures = [] + for backend, backend_uris in self._get_backends_to_uris(uris).items(): + if hasattr(backend.library, 'find_exact'): + futures.append(backend.library.find_exact( + query=query, uris=backend_uris)) + else: + futures.append(backend.library.search( + query=query, uris=backend_uris, exact=True)) + return [result for result in pykka.get_all(futures) if result] def lookup(self, uri=None, uris=None): diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 9a23d874..98b25f38 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -212,54 +212,50 @@ class CoreLibraryTest(unittest.TestCase): result1 = SearchResult(tracks=[track1]) result2 = SearchResult(tracks=[track2]) - self.library1.find_exact().get.return_value = result1 - self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = result2 - self.library2.find_exact.reset_mock() + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = result2 result = self.core.library.find_exact(any=['a']) self.assertIn(result1, result) self.assertIn(result2, result) - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) - self.library2.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) def test_find_exact_with_uris_selects_dummy1_backend(self): self.core.library.find_exact( any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy3:']) - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) - self.assertFalse(self.library2.find_exact.called) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) + self.assertFalse(self.library2.search.called) def test_find_exact_with_uris_selects_both_backends(self): self.core.library.find_exact( any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy2:']) - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) - self.library2.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:']) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy2:'], exact=True) def test_find_exact_filters_out_none(self): track1 = Track(uri='dummy1:a') result1 = SearchResult(tracks=[track1]) - self.library1.find_exact().get.return_value = result1 - self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = None - self.library2.find_exact.reset_mock() + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = None result = self.core.library.find_exact(any=['a']) self.assertIn(result1, result) self.assertNotIn(None, result) - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) - self.library2.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) def test_find_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -267,19 +263,17 @@ class CoreLibraryTest(unittest.TestCase): result1 = SearchResult(tracks=[track1]) result2 = SearchResult(tracks=[track2]) - self.library1.find_exact().get.return_value = result1 - self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = result2 - self.library2.find_exact.reset_mock() + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = result2 result = self.core.library.find_exact(dict(any=['a'])) self.assertIn(result1, result) self.assertIn(result2, result) - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) - self.library2.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') @@ -363,5 +357,29 @@ class CoreLibraryTest(unittest.TestCase): def test_find_exact_normalises_bad_queries(self): self.core.library.find_exact({'any': 'foobar'}) - self.library1.find_exact.assert_called_once_with( - query={'any': ['foobar']}, uris=None) + self.library1.search.assert_called_once_with( + query={'any': ['foobar']}, uris=None, exact=True) + + +class LegacyLibraryProvider(backend.LibraryProvider): + def find_exact(self, query=None, uris=None): + pass + + +class LegacyCoreLibraryTest(unittest.TestCase): + def test_backend_with_find_exact_still_works(self): + b1 = mock.Mock() + b1.uri_schemes.get.return_value = ['dummy1'] + b1.library = mock.Mock(spec=LegacyLibraryProvider) + + b2 = mock.Mock() + b2.uri_schemes.get.return_value = ['dummy2'] + b2.library = mock.Mock(spec=backend.LibraryProvider) + + c = core.Core(mixer=None, backends=[b1, b2]) + c.library.find_exact(query={'any': ['a']}) + + b1.library.find_exact.assert_called_once_with( + query=dict(any=['a']), uris=None) + b2.library.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) From 4e30fb2f488a8a6c8f1a9f4f4468a982c55dd8c2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 00:40:55 +0100 Subject: [PATCH 474/495] core: Make get_playlists() maintain folder hierarchy --- mopidy/core/playlists.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 54797abe..e791380f 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -71,8 +71,12 @@ class PlaylistsController(object): playlist_refs = self.as_list() if include_tracks: - playlists = [self.lookup(r.uri) for r in playlist_refs] - return [pl for pl in playlists if pl is not None] + playlists = {r.uri: self.lookup(r.uri) for r in playlist_refs} + # Use the playlist name from as_list() because it knows about any + # playlist folder hierarchy, which lookup() does not. + return [ + playlists[r.uri].copy(name=r.name) + for r in playlist_refs if playlists[r.uri] is not None] else: return [ Playlist(uri=r.uri, name=r.name) for r in playlist_refs] From e06c7708a7059433fc3036eb5d0332f239d3a594 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 01:04:26 +0100 Subject: [PATCH 475/495] utils: Add time_logger context manager --- mopidy/utils/timer.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 mopidy/utils/timer.py diff --git a/mopidy/utils/timer.py b/mopidy/utils/timer.py new file mode 100644 index 00000000..b8dcb30d --- /dev/null +++ b/mopidy/utils/timer.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + +import contextlib +import logging +import time + + +logger = logging.getLogger(__name__) +TRACE = logging.getLevelName('TRACE') + + +@contextlib.contextmanager +def time_logger(name, level=TRACE): + start = time.time() + yield + logger.log(level, '%s took %dms', name, (time.time() - start) * 1000) From f48a8ad938ada0c563c6a493854a3456ef85daec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 01:15:53 +0100 Subject: [PATCH 476/495] mpd: Move playlist.lookup() out of helper --- mopidy/mpd/dispatcher.py | 4 ++-- mopidy/mpd/protocol/music_db.py | 3 ++- mopidy/mpd/protocol/stored_playlists.py | 9 ++++++--- mopidy/mpd/uri_mapper.py | 9 +++------ 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index eece86d9..d156b891 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -245,11 +245,11 @@ class MpdContext(object): self.subscriptions = set() self._uri_map = uri_map - def lookup_playlist_from_name(self, name): + def lookup_playlist_uri_from_name(self, name): """ Helper function to retrieve a playlist from its unique MPD name. """ - return self._uri_map.playlist_from_name(name) + return self._uri_map.playlist_uri_from_name(name) def lookup_playlist_name_from_uri(self, uri): """ diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 62147b7d..a942abf5 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -465,7 +465,8 @@ def searchaddpl(context, *args): return results = context.core.library.search(**query).get() - playlist = context.lookup_playlist_from_name(playlist_name) + uri = context.lookup_playlist_uri_from_name(playlist_name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: playlist = context.core.playlists.create(playlist_name).get() tracks = list(playlist.tracks) + _get_tracks(results) diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index f273e9b9..c24b2f6e 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -20,7 +20,8 @@ def listplaylist(context, name): file: relative/path/to/file2.ogg file: relative/path/to/file3.mp3 """ - playlist = context.lookup_playlist_from_name(name) + uri = context.lookup_playlist_uri_from_name(name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') return ['file: %s' % t.uri for t in playlist.tracks] @@ -40,7 +41,8 @@ def listplaylistinfo(context, name): Standard track listing, with fields: file, Time, Title, Date, Album, Artist, Track """ - playlist = context.lookup_playlist_from_name(name) + uri = context.lookup_playlist_uri_from_name(name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') return translator.playlist_to_mpd_format(playlist) @@ -121,7 +123,8 @@ def load(context, name, playlist_slice=slice(0, None)): - MPD 0.17.1 does not fail if the specified range is outside the playlist, in either or both ends. """ - playlist = context.lookup_playlist_from_name(name) + uri = context.lookup_playlist_uri_from_name(name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') context.core.tracklist.add(playlist.tracks[playlist_slice]) diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py index 082f1311..eba9191e 100644 --- a/mopidy/mpd/uri_mapper.py +++ b/mopidy/mpd/uri_mapper.py @@ -59,16 +59,13 @@ class MpdUriMapper(object): name = self._invalid_playlist_chars.sub('|', playlist.name) self.insert(name, playlist.uri) - def playlist_from_name(self, name): + def playlist_uri_from_name(self, name): """ - Helper function to retrieve a playlist from its unique MPD name. + Helper function to retrieve a playlist URI from its unique MPD name. """ if not self._uri_from_name: self.refresh_playlists_mapping() - if name not in self._uri_from_name: - return None - uri = self._uri_from_name[name] - return self.core.playlists.lookup(uri).get() + return self._uri_from_name.get(name) def playlist_name_from_uri(self, uri): """ From af727bba4e3a57875b023c29f3f5de4d1510b6f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 01:25:41 +0100 Subject: [PATCH 477/495] mpd: Use as_list() to build URI-to-MPD-name map --- mopidy/mpd/uri_mapper.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py index eba9191e..08c7f689 100644 --- a/mopidy/mpd/uri_mapper.py +++ b/mopidy/mpd/uri_mapper.py @@ -52,12 +52,11 @@ class MpdUriMapper(object): MPD. """ if self.core is not None: - for playlist in self.core.playlists.playlists.get(): - if not playlist.name: + for playlist_ref in self.core.playlists.as_list().get(): + if not playlist_ref.name: continue - # TODO: add scheme to name perhaps 'foo (spotify)' etc. - name = self._invalid_playlist_chars.sub('|', playlist.name) - self.insert(name, playlist.uri) + name = self._invalid_playlist_chars.sub('|', playlist_ref.name) + self.insert(name, playlist_ref.uri) def playlist_uri_from_name(self, name): """ From 45ce75586ea4814dd0e5ebae85708c93d3ff2c68 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 01:28:14 +0100 Subject: [PATCH 478/495] mpd: Use get_playlists() in listplaylists --- mopidy/mpd/protocol/stored_playlists.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index c24b2f6e..9d9f66e0 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -75,7 +75,7 @@ def listplaylists(context): ignore playlists without names, which isn't very useful anyway. """ result = [] - for playlist in context.core.playlists.playlists.get(): + for playlist in context.core.playlists.get_playlists().get(): if not playlist.name: continue name = context.lookup_playlist_name_from_uri(playlist.uri) From 23e2295c460a20f4e36b213c7a244d136bfbda3a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 01:37:30 +0100 Subject: [PATCH 479/495] dummy: Fix playlists.get_items() bug --- tests/dummy_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 9f4a0986..babaf0de 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -105,7 +105,7 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] def get_items(self, uri): - playlist = self._playlists.get(uri) + playlist = self.lookup(uri) if playlist is None: return return [ From 3ceb16095d1f9234367f1b996deaa27ce59c2a2a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Mar 2015 08:46:52 +0100 Subject: [PATCH 480/495] utils: Install TRACE log level add module import time. --- mopidy/utils/log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 6343a866..d2dcca70 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -17,6 +17,7 @@ LOG_LEVELS = { # Custom log level which has even lower priority than DEBUG TRACE_LOG_LEVEL = 5 +logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE') class DelayedHandler(logging.Handler): @@ -46,7 +47,6 @@ def bootstrap_delayed_logging(): def setup_logging(config, verbosity_level, save_debug_log): - logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE') logging.captureWarnings(True) From 3e361d48709e3673140bdc4072f61a25297200ee Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Mar 2015 08:47:32 +0100 Subject: [PATCH 481/495] local: Use the new debug timer instead of our own --- mopidy/local/json.py | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 969049d6..22fcfa5b 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -8,12 +8,11 @@ import os import re import sys import tempfile -import time import mopidy from mopidy import compat, local, models from mopidy.local import search, storage, translator -from mopidy.utils import encoding +from mopidy.utils import encoding, timer logger = logging.getLogger(__name__) @@ -109,20 +108,6 @@ class _BrowseCache(object): return self._cache.get(uri, {}).values() -# TODO: make this available to other code? -class DebugTimer(object): - def __init__(self, msg): - self.msg = msg - self.start = None - - def __enter__(self): - self.start = time.time() - - def __exit__(self, exc_type, exc_value, traceback): - duration = (time.time() - self.start) * 1000 - logger.debug('%s: %dms', self.msg, duration) - - class JsonLibrary(local.Library): name = 'json' @@ -142,10 +127,10 @@ class JsonLibrary(local.Library): def load(self): logger.debug('Loading library: %s', self._json_file) - with DebugTimer('Loading tracks'): + with timer.time_logger('Loading tracks'): library = load_library(self._json_file) self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) - with DebugTimer('Building browse cache'): + with timer.time_logger('Building browse cache'): self._browse_cache = _BrowseCache(sorted(self._tracks.keys())) return len(self._tracks) From 141c14ad45c7c09da481f067f6188f4332deb696 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Mar 2015 09:26:11 +0100 Subject: [PATCH 482/495] core: Add exact to search() and deprecate find_exact() Backends that still implement find_exact will be called without exact as an argument to search, and we will continue to use find_exact. Please remove find_exact from such backends and switch to the new search API. --- docs/changelog.rst | 5 +++ mopidy/core/library.py | 83 ++++++++++++-------------------------- tests/core/test_library.py | 39 +++++++++++++----- 3 files changed, 58 insertions(+), 69 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 94f35433..d3573e89 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -103,6 +103,11 @@ v1.0.0 (UNRELEASED) - **Deprecated:** :meth:`mopidy.core.PlaylistsController.filter`. Use :meth:`~mopidy.core.PlaylistsController.as_list` and filter yourself. +- Add ``exact`` to :meth:`mopidy.core.LibraryController.search`. + +- **Deprecated:** :meth:`mopidy.core.LibraryController.find_exact`. Use + :meth:`mopidy.core.LibraryController.search` with ``exact`` set. + **Backend API** - Remove default implementation of diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 80c61bbb..44375f58 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -127,62 +127,12 @@ class LibraryController(object): return results def find_exact(self, query=None, uris=None, **kwargs): - """ - Search the library for tracks where ``field`` is ``values``. + """Search the library for tracks where ``field`` is ``values``. .. deprecated:: 1.0 - Previously, if the query was empty, and the backend could support - it, all available tracks were returned. This has not changed, but - it is strongly discouraged. No new code should rely on this - - If ``uris`` is given, the search is limited to results from within the - URI roots. For example passing ``uris=['file:']`` will limit the search - to the local backend. - - Examples:: - - # Returns results matching 'a' from any backend - find_exact({'any': ['a']}) - find_exact(any=['a']) - - # Returns results matching artist 'xyz' from any backend - find_exact({'artist': ['xyz']}) - find_exact(artist=['xyz']) - - # Returns results matching 'a' and 'b' and artist 'xyz' from any - # backend - find_exact({'any': ['a', 'b'], 'artist': ['xyz']}) - find_exact(any=['a', 'b'], artist=['xyz']) - - # Returns results matching 'a' if within the given URI roots - # "file:///media/music" and "spotify:" - find_exact( - {'any': ['a']}, uris=['file:///media/music', 'spotify:']) - find_exact(any=['a'], uris=['file:///media/music', 'spotify:']) - - .. versionchanged:: 1.0 - This method now calls - :meth:`~mopidy.backend.LibraryProvider.search` on the backends - instead of the deprecated ``find_exact``. If the backend still - implements ``find_exact`` we will continue to use it for now. - - :param query: one or more queries to search for - :type query: dict - :param uris: zero or more URI roots to limit the search to - :type uris: list of strings or :class:`None` - :rtype: list of :class:`mopidy.models.SearchResult` + Use :meth:`search` with ``exact`` set. """ - query = _normalize_query(query or kwargs) - futures = [] - for backend, backend_uris in self._get_backends_to_uris(uris).items(): - if hasattr(backend.library, 'find_exact'): - futures.append(backend.library.find_exact( - query=query, uris=backend_uris)) - else: - futures.append(backend.library.search( - query=query, uris=backend_uris, exact=True)) - - return [result for result in pykka.get_all(futures) if result] + return self.search(query=query, uris=uris, exact=True, **kwargs) def lookup(self, uri=None, uris=None): """ @@ -248,7 +198,7 @@ class LibraryController(object): for b in self.backends.with_library.values()] pykka.get_all(futures) - def search(self, query=None, uris=None, **kwargs): + def search(self, query=None, uris=None, exact=False, **kwargs): """ Search the library for tracks where ``field`` contains ``values``. @@ -287,12 +237,29 @@ class LibraryController(object): :param uris: zero or more URI roots to limit the search to :type uris: list of strings or :class:`None` :rtype: list of :class:`mopidy.models.SearchResult` + + .. versionadded:: 1.0 + The ``exact`` keyword argument, which replaces :meth:`find_exact`. """ query = _normalize_query(query or kwargs) - futures = [ - backend.library.search(query=query, uris=backend_uris) - for (backend, backend_uris) - in self._get_backends_to_uris(uris).items()] + futures = [] + for backend, backend_uris in self._get_backends_to_uris(uris).items(): + if hasattr(backend.library, 'find_exact'): + # Backends with find_exact probably don't have support for + # search with the exact kwarg, so give them the legacy calls. + if exact: + futures.append(backend.library.find_exact( + query=query, uris=backend_uris)) + else: + futures.append(backend.library.search( + query=query, uris=backend_uris)) + else: + # Assume backends without find_exact are up to date. Worst case + # the exact gets swallowed by the **kwargs and things hopefully + # still work. + futures.append(backend.library.search( + query=query, uris=backend_uris, exact=exact)) + return [result for result in pykka.get_all(futures) if result] diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 98b25f38..50eb834f 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -291,16 +291,16 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) def test_search_with_uris_selects_dummy1_backend(self): self.core.library.search( query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy3:']) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=False) self.assertFalse(self.library2.search.called) def test_search_with_uris_selects_both_backends(self): @@ -308,9 +308,9 @@ class CoreLibraryTest(unittest.TestCase): query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy2:']) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:']) + query=dict(any=['a']), uris=['dummy2:'], exact=False) def test_search_filters_out_none(self): track1 = Track(uri='dummy1:a') @@ -326,9 +326,9 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertNotIn(None, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) def test_search_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -346,14 +346,14 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) def test_search_normalises_bad_queries(self): self.core.library.search({'any': 'foobar'}) self.library1.search.assert_called_once_with( - query={'any': ['foobar']}, uris=None) + query={'any': ['foobar']}, uris=None, exact=False) def test_find_exact_normalises_bad_queries(self): self.core.library.find_exact({'any': 'foobar'}) @@ -367,7 +367,7 @@ class LegacyLibraryProvider(backend.LibraryProvider): class LegacyCoreLibraryTest(unittest.TestCase): - def test_backend_with_find_exact_still_works(self): + def test_backend_with_find_exact_gets_find_exact_call(self): b1 = mock.Mock() b1.uri_schemes.get.return_value = ['dummy1'] b1.library = mock.Mock(spec=LegacyLibraryProvider) @@ -383,3 +383,20 @@ class LegacyCoreLibraryTest(unittest.TestCase): query=dict(any=['a']), uris=None) b2.library.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=True) + + def test_backend_with_find_exact_gets_search_without_exact_arg(self): + b1 = mock.Mock() + b1.uri_schemes.get.return_value = ['dummy1'] + b1.library = mock.Mock(spec=LegacyLibraryProvider) + + b2 = mock.Mock() + b2.uri_schemes.get.return_value = ['dummy2'] + b2.library = mock.Mock(spec=backend.LibraryProvider) + + c = core.Core(mixer=None, backends=[b1, b2]) + c.library.search(query={'any': ['a']}) + + b1.library.search.assert_called_once_with( + query=dict(any=['a']), uris=None) + b2.library.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=False) From 779a399c59837e05ebac96307e2cb443585e5269 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Mar 2015 20:09:17 +0100 Subject: [PATCH 483/495] main: Use timer.time_logger helper --- mopidy/commands.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/mopidy/commands.py b/mopidy/commands.py index 5df8dd5a..ebb2c891 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -2,11 +2,9 @@ from __future__ import absolute_import, print_function, unicode_literals import argparse import collections -import contextlib import logging import os import sys -import time import glib @@ -15,7 +13,7 @@ import gobject from mopidy import config as config_lib, exceptions from mopidy.audio import Audio from mopidy.core import Core -from mopidy.utils import deps, process, versioning +from mopidy.utils import deps, process, timer, versioning logger = logging.getLogger(__name__) @@ -65,13 +63,6 @@ class _HelpAction(argparse.Action): raise _HelpError() -@contextlib.contextmanager -def _startup_timer(name): - start = time.time() - yield - logger.debug('%s startup took %dms', name, (time.time() - start) * 1000) - - class Command(object): """Command parser and runner for building trees of commands. @@ -356,7 +347,7 @@ class RootCommand(Command): backends = [] for backend_class in backend_classes: try: - with _startup_timer(backend_class.__name__): + with timer.time_logger(backend_class.__name__): backend = backend_class.start( config=config, audio=audio).proxy() backends.append(backend) @@ -379,7 +370,7 @@ class RootCommand(Command): for frontend_class in frontend_classes: try: - with _startup_timer(frontend_class.__name__): + with timer.time_logger(frontend_class.__name__): frontend_class.start(config=config, core=core) except exceptions.FrontendError as exc: logger.error( From e0d0e785e06afc3d5bed634f61fc724ceab82419 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 21:35:12 +0100 Subject: [PATCH 484/495] docs: Cleanup v1.0.0 changelog Fixes #1079 --- docs/changelog.rst | 376 ++++++++++++++++++++++++++++----------------- 1 file changed, 237 insertions(+), 139 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d3573e89..47c30a72 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,22 +8,49 @@ This changelog is used to track all major changes to Mopidy. v1.0.0 (UNRELEASED) =================== -**Models** +Three months after our fifth anniversary, Mopidy 1.0 is finally here! -- Add :class:`mopidy.models.Image` model to be returned by - :meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`) +Since the release of 0.19, we've closed or merged approximately 140 issues and +pull requests through more than 600 commits by a record high 19 extraordinary +people, including seven newcomers. Thanks to everyone that has contributed! -- Change the semantics of :attr:`mopidy.models.Track.last_modified` to be - milliseconds instead of seconds since Unix epoch, or a simple counter, - depending on the source of the track. This makes it match the semantics of - :attr:`mopidy.models.Playlist.last_modified`. (Fixes: :issue:`678`, PR: - :issue:`1036`) +For the longest time, the focus of Mopidy 1.0 was to be another incremental +improvement, to be numbered 0.20. The result is still very much an incremental +improvement, with lots of small and larger improvements across Mopidy's +functionality. -**Core API** +The major features of Mopidy 1.0 are: -- **Deprecated:** Deprecate all properties in the core API. The previously - undocumented getter and setter methods are now the official API. This aligns - the Python API with the WebSocket/JavaScript API. (Fixes: :issue:`952`) +- A promise to follow not break APIs before Mopidy 2.0. A Mopidy extension + working with Mopidy 1.0 should continue to work with all Mopidy 1.x releases. + +- Preparation work to enable gapless playback in the near future. + +TODO: to be continued + +Dependencies +------------ + +Since the previous release there is no changes to Mopidy's dependencies. +However, porting from GStreamer 0.10 to 1.x and support for running Mopidy with +Python 3.4+ is not far off on our roadmap. + +Core API +-------- + +In the API used by all frontends and web extensions there is lots of methods +and arguments that are now deprecated in preparation for the next major +release. With the exception of some internals that leaked out in the playback +controller, no core APIs have been removed in this release. In other words, +most clients should continue to work unchanged when upgrading to Mopidy 1.0. +Though, it is strongly encouraged to review any use of the deprecated parts of +the API as those parts will be removed in Mopidy 2.0. + +- **Deprecated:** Deprecate all Python properties in the core API. The + previously undocumented getter and setter methods are now the official API. + This aligns the Python API with the WebSocket/JavaScript API. Python + frontends needs to be updated. WebSocket/JavaScript API users are not + affected. (Fixes: :issue:`952`) - Add :class:`mopidy.core.HistoryController` which keeps track of what tracks have been played. (Fixes: :issue:`423`, :issue:`1056`, PR: :issue:`803`, @@ -32,68 +59,37 @@ v1.0.0 (UNRELEASED) - Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. (Fixes: :issue:`962`) -- Add ``uris`` argument to :meth:`mopidy.core.LibraryController.lookup` which - allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR: +Core library controller +~~~~~~~~~~~~~~~~~~~~~~~ + +- **Deprecated:** :meth:`mopidy.core.LibraryController.find_exact`. Use + :meth:`mopidy.core.LibraryController.search` with the ``exact`` keyword + argument set to :class:`True`. + +- **Deprecated:** The ``uri`` argument to + :meth:`mopidy.core.LibraryController.lookup`. Use new ``uris`` keyword + argument instead. + +- Add ``exact`` keyword argument to + :meth:`mopidy.core.LibraryController.search`. + +- Add ``uris`` keyword argument to :meth:`mopidy.core.LibraryController.lookup` + which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR: :issue:`1047`) -- Add ``uris`` argument to :meth:`mopidy.core.TracklistController.add` which - allows for simpler addition of multiple URIs to the tracklist. (Fixes: - :issue:`1060`, PR: :issue:`1065`) - -- **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for - volume and mute management have been deprecated. (Fixes: :issue:`962`) - -- Remove ``clear_current_track`` keyword argument to - :meth:`mopidy.core.PlaybackController.stop`. It was a leaky internal - abstraction, which was never intended to be used externally. - -- Add :meth:`mopidy.core.LibraryController.get_images` for looking up images - for any URI backends know about. (Fixes :issue:`973`, PR: :issue:`981`, - :issue:`992` and :issue:`1013`) - -- When seeking in paused state, do not change to playing state. (Fixes: - :issue:`939`, PR: :issue:`1018`) +- Updated :meth:`mopidy.core.LibraryController.search` and + :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about + malformed queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) - Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique values for a given field. (Fixes: :issue:`913`, PR: :issue:`1022`) -- Add :meth:`mopidy.core.Listener.stream_title_changed` and - :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients - know about the current song in streams. (PR: :issue:`938`, :issue:`1030`) +- Add :meth:`mopidy.core.LibraryController.get_images` for looking up images + for any URI that is known to the backends. (Fixes :issue:`973`, PR: + :issue:`981`, :issue:`992` and :issue:`1013`) -- The following methods were documented as internal. They are now fully private - and unavailable outside the core actor. (Fixes: :issue:`1058`, PR: - :issue:`1062`) - - - :meth:`mopidy.core.TracklistController.mark_played` - - :meth:`mopidy.core.TracklistController.mark_playing` - - :meth:`mopidy.core.TracklistController.mark_unplayable` - -- Updated :meth:`mopidy.core.PlaybackController.play` to take - :meth:`mopidy.backend.PlaybackProvider.change_track` into account when - determining success. (PR: :issue:`1071`) - -- Updated :meth:`mopidy.core.LibraryController.search` and - :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about - bad queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) - -- Reduced API surface of core. (Fixes: :issue:`1070`, PR: :issue:`1076`) - - - Made ``mopidy.core.PlaybackController.change_track`` internal. - - Removed ``on_error_step`` from :meth:`mopidy.core.PlaybackController.play` - - Made the following event triggers internal: - - - ``mopidy.core.PlaybackController.on_end_of_track`` - - ``mopidy.core.PlaybackController.on_stream_changed`` - - ``mopidy.core.PlaybackController.on_tracklist_changed`` - - - Made ``mopidy.core.PlaybackController.set_current_tl_track`` internal. - -- Add :meth:`mopidy.core.PlaylistsController.as_list`. (Fixes: :issue:`1057`, - PR: :issue:`1075`) - -- Add :meth:`mopidy.core.PlaylistsController.get_items`. (Fixes: :issue:`1057`, - PR: :issue:`1075`) +Core playlist controller +~~~~~~~~~~~~~~~~~~~~~~~~ - **Deprecated:** :meth:`mopidy.core.PlaylistsController.get_playlists`. Use :meth:`~mopidy.core.PlaylistsController.as_list` and @@ -103,17 +99,105 @@ v1.0.0 (UNRELEASED) - **Deprecated:** :meth:`mopidy.core.PlaylistsController.filter`. Use :meth:`~mopidy.core.PlaylistsController.as_list` and filter yourself. -- Add ``exact`` to :meth:`mopidy.core.LibraryController.search`. +- Add :meth:`mopidy.core.PlaylistsController.as_list`. (Fixes: :issue:`1057`, + PR: :issue:`1075`) -- **Deprecated:** :meth:`mopidy.core.LibraryController.find_exact`. Use - :meth:`mopidy.core.LibraryController.search` with ``exact`` set. +- Add :meth:`mopidy.core.PlaylistsController.get_items`. (Fixes: :issue:`1057`, + PR: :issue:`1075`) -**Backend API** +Core tracklist controller +~~~~~~~~~~~~~~~~~~~~~~~~~ -- Remove default implementation of +- **Removed:** The following methods were documented as internal. They are now + fully private and unavailable outside the core actor. (Fixes: :issue:`1058`, + PR: :issue:`1062`) + + - :meth:`mopidy.core.TracklistController.mark_played` + - :meth:`mopidy.core.TracklistController.mark_playing` + - :meth:`mopidy.core.TracklistController.mark_unplayable` + +- Add ``uris`` argument to :meth:`mopidy.core.TracklistController.add` which + allows for simpler addition of multiple URIs to the tracklist. (Fixes: + :issue:`1060`, PR: :issue:`1065`) + +Core playback controller +~~~~~~~~~~~~~~~~~~~~~~~~ + +- **Removed:** Remove several internal parts that was leaking into the public + API and was never intended to be used externally. (Fixes: :issue:`1070`, PR: + :issue:`1076`) + + - :meth:`mopidy.core.PlaybackController.change_track` is now internal. + + - Removed ``on_error_step`` keyword argument from + :meth:`mopidy.core.PlaybackController.play` + + - Removed ``clear_current_track`` keyword argument to + :meth:`mopidy.core.PlaybackController.stop`. + + - Made the following event triggers internal: + + - :meth:`mopidy.core.PlaybackController.on_end_of_track` + - :meth:`mopidy.core.PlaybackController.on_stream_changed` + - :meth:`mopidy.core.PlaybackController.on_tracklist_changed` + + - :meth:`mopidy.core.PlaybackController.set_current_tl_track` is now + internal. + +- **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` + for volume and mute management have been deprecated. Use + :class:`mopidy.core.MixerController` instead. (Fixes: :issue:`962`) + +- When seeking while paused, we no longer change to playing. (Fixes: + :issue:`939`, PR: :issue:`1018`) + +- Changed :meth:`mopidy.core.PlaybackController.play` to take the return value + from :meth:`mopidy.backend.PlaybackProvider.change_track` into account when + determining the success of the :meth:`~mopidy.core.PlaybackController.play` + call. (PR: :issue:`1071`) + +- Add :meth:`mopidy.core.Listener.stream_title_changed` and + :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients + know about the current title in streams. (PR: :issue:`938`, :issue:`1030`) + +Backend API +----------- + +In the API implemented by all backends there have been way fewer but somewhat +more dramatic changes with some methods removed and new ones being required for +certain functionality to continue working. Most backends are already updated to +be compatible with Mopidy 1.0 before the release. New versions of the backends +will be released shortly after Mopidy itself. + +Backend library providers +~~~~~~~~~~~~~~~~~~~~~~~~~ + +- **Removed:** Remove :meth:`mopidy.backend.LibraryProvider.find_exact`. + +- Add an ``exact`` keyword argument to + :meth:`mopidy.backend.LibraryProvider.search` to replace the old + :meth:`~mopidy.backend.LibraryProvider.find_exact` method. + +Backend playlist providers +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- **Removed:** Remove default implementation of :attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially backwards incompatible. (PR: :issue:`1046`) +- Changed the API for :class:`mopidy.backend.PlaylistsProvider`. Note that this + change is **not** backwards compatible. These changes are important to reduce + the Mopidy startup time. (Fixes: :issue:`1057`, PR: :issue:`1075`) + + - Add :meth:`mopidy.backend.PlaylistsProvider.as_list`. + + - Add :meth:`mopidy.backend.PlaylistsProvider.get_items`. + + - Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property. + +Backend playback providers +~~~~~~~~~~~~~~~~~~~~~~~~~~ + - Changed the API for :class:`mopidy.backend.PlaybackProvider`. Note that this change is **not** backwards compatible for certain backends. These changes are crucial to adding gapless in one of the upcoming releases. @@ -133,22 +217,20 @@ v1.0.0 (UNRELEASED) - :meth:`mopidy.backend.PlaybackProvider.prepare_change` has been added. -- Changed the API for :class:`mopidy.backend.PlaylistsProvider`. Note that this - change is **not** backwards compatible. These changes are important to reduce - the Mopidy startup time. (Fixes: :issue:`1057`, PR: :issue:`1075`) +Models +------ - - Add :meth:`mopidy.backend.PlaylistsProvider.as_list`. +- Add :class:`mopidy.models.Image` model to be returned by + :meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`) - - Add :meth:`mopidy.backend.PlaylistsProvider.get_items`. +- Change the semantics of :attr:`mopidy.models.Track.last_modified` to be + milliseconds instead of seconds since Unix epoch, or a simple counter, + depending on the source of the track. This makes it match the semantics of + :attr:`mopidy.models.Playlist.last_modified`. (Fixes: :issue:`678`, PR: + :issue:`1036`) - - Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property. - -- Removed ``find_exact`` from :class:`mopidy.backend.LibraryProvider` and - added an ``exact`` param to :meth:`mopidy.backend.LibraryProvider.search` - to replace the old code path. Core will continue supporting backends that - have not upgraded for now. - -**Commands** +Commands +-------- - Make the ``mopidy`` command print a friendly error message if the :mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`) @@ -161,7 +243,8 @@ v1.0.0 (UNRELEASED) deps``. This make it easier to see that a user is using pip-installed Mopidy instead of APT-installed Mopidy without asking for ``which mopidy`` output. -**Configuration** +Configuration +------------- - Add support for the log level value ``all`` to the loglevels configurations. This can be used to show absolutely all log records, including those at @@ -169,7 +252,8 @@ v1.0.0 (UNRELEASED) - Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) -**Logging** +Logging +------- - Add custom log level ``TRACE`` (numerical level 5), which can be used by Mopidy and extensions to log at an even more detailed level than ``DEBUG``. @@ -177,7 +261,8 @@ v1.0.0 (UNRELEASED) - Add support for per logger color overrides. (Fixes: :issue:`808`, PR: :issue:`1005`) -**Local backend** +Local backend +------------- - Improve error logging for scanner. (Fixes: :issue:`856`, PR: :issue:`874`) @@ -204,7 +289,8 @@ v1.0.0 (UNRELEASED) - *Deprecated:* The config value :confval:`local/playlists_dir` is no longer in use and can be removed from your config. -**Local library API** +Local library API +~~~~~~~~~~~~~~~~~ - Implementors of :meth:`mopidy.local.Library.lookup` should now return a list of :class:`~mopidy.models.Track` instead of a single track, just like the @@ -217,70 +303,91 @@ v1.0.0 (UNRELEASED) - Add :meth:`mopidy.local.Library.get_images` for looking up images for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`) -**M3U backend** +Stream backend +-------------- -- Split the M3U playlist handling out of the local backend. See - :ref:`m3u-migration` for how to migrate your local playlists. (Fixes: +- Add support for HTTP proxies when doing initial metadata lookup for a stream. + (Fixes :issue:`390`, PR: :issue:`982`) + +- Add basic tests for the stream library provider. + +M3U backend +----------- + +- Mopidy-M3U is a new bundled backend. It is the same M3U support as was + previously part of the local backend. See :ref:`m3u-migration` for how to + migrate your local playlists to work with the M3U backend. (Fixes: :issue:`1054`, PR: :issue:`1066`) -**MPD frontend** - -- In stored playlist names, replace "/", which are illegal, with "|" instead of - a whitespace. Pipes are more similar to forward slash. - -- Enable browsing of artist references, in addition to albums and playlists. - (PR: :issue:`884`) - -- Share a single mapping between names and URIs across all MPD sessions. (Fixes: - :issue:`934`, PR: :issue:`968`) +MPD frontend +------------ - Add support for blacklisting MPD commands. This is used to prevent clients from using ``listall`` and ``listallinfo`` which recursively lookup the entire "database". If you insist on using a client that needs these commands change :confval:`mpd/command_blacklist`. -- Switch the ``list`` command over to using +- Start setting the ``Name`` field with the stream title when listening to + radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`) + +- Enable browsing of artist references, in addition to albums and playlists. + (PR: :issue:`884`) + +- Switch the ``list`` command over to using the new method :meth:`mopidy.core.LibraryController.get_distinct` for increased performance. (Fixes: :issue:`913`) +- In stored playlist names, replace "/", which are illegal, with "|" instead of + a whitespace. Pipes are more similar to forward slash. + +- Share a single mapping between names and URIs across all MPD sessions. (Fixes: + :issue:`934`, PR: :issue:`968`) + - Add support for ``toggleoutput`` command. (PR: :issue:`1015`) - The ``mixrampdb`` and ``mixrampdelay`` commands are now known to Mopidy, but are not implemented. (PR: :issue:`1015`) -- Start setting the ``Name`` field with the stream title when listening to - radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`) - - Fix crash on socket error when using a locale causing the exception's error message to contain characters not in ASCII. (Fixes: issue:`971`, PR: :issue:`1044`) -**HTTP frontend** +HTTP frontend +------------- - **Deprecated:** Deprecated the :confval:`http/static_dir` config. Please make your web clients pip-installable Mopidy extensions to make it easier to install for end users. -- Prevent race condition in WebSocket broadcast from breaking the web server. - (PR: :issue:`1020`) +- Prevent a race condition in WebSocket event broadcasting from crashing the + web server. (PR: :issue:`1020`) -**Mixer** +Mixers +------ - Add support for disabling volume control in Mopidy entirely by setting the configuration :confval:`audio/mixer` to ``none``. (Fixes: :issue:`936`, PR: :issue:`1015`, :issue:`1035`) -**Audio** +Audio +----- + +- **Removed:** Kill support for visualizers and the + :confval:`audio/visualizer` config value. The feature was originally added as + a workaround for all the people asking for ncmpcpp visualizer support, and + since we could get it almost for free thanks to GStreamer. But, this feature + did never make sense for a server such as Mopidy. The only way to find out if + it is in use and will be missed is to go ahead and remove it. - **Deprecated:** Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end - the stream. + the stream. This should only affect Mopidy-Spotify. -- Kill support for visualizers. Feature was originally added as a workaround for - all the people asking for ncmpcpp visualizer support. And since we could get - it almost for free thanks to GStreamer. But this feature didn't really ever - make sense for a server such as Mopidy. Currently the only way to find out if - it is in use and will be missed is to go ahead and remove it. +- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new + tags are found. + +- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current + tags of the playing media. - Internal code cleanup within audio subsystem: @@ -294,26 +401,18 @@ v1.0.0 (UNRELEASED) - Add internal helper for converting GStreamer data types to Python. - - Move MusicBrainz coverart code out of audio and into local. - - - Reduce scope of audio scanner to just tags + duration. Mtime, uri and min - length handling are now outside of this class. + - Reduce scope of audio scanner to just find tags and duration. Modification + time, URI and minimum length handling are now outside of this class. - Update scanner to operate with milliseconds for duration. - - Update scanner to use a custom src, typefind and decodebin. This allows us - to catch playlists before we try to decode them. + - Update scanner to use a custom source, typefind and decodebin. This allows + us to detect playlists before we try to decode them. - - Refactored scanner to create a new pipeline per song, this is needed as + - Refactored scanner to create a new pipeline per track, this is needed as reseting decodebin is much slower than tearing it down and making a fresh one. -- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new tags - are found. - -- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current - tags of the playing media. - - Move and rename helper for converting tags to tracks. - Ignore albums without a name when converting tags to tracks. @@ -331,14 +430,8 @@ v1.0.0 (UNRELEASED) - Added support for checking if the media is seekable, and getting the initial MIME type guess. (PR: :issue:`1033`) -**Stream backend** - -- Add basic tests for the stream library provider. - -- Add support for proxies when doing initial metadata lookup for stream. - (Fixes :issue:`390`, PR: :issue:`982`) - -**Mopidy.js client library** +Mopidy.js client library +------------------------ This version has been released to npm as Mopidy.js v0.5.0. @@ -351,7 +444,12 @@ This version has been released to npm as Mopidy.js v0.5.0. - Upgrade dependencies. -**Development** +Development +----------- + +- Add new :ref:`contribution guidelines `. + +- Add new :ref:`development guide `. - Speed up event emitting. From 426a56d66b3f68da5a4412d3f42c6d937be22651 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 23:02:43 +0100 Subject: [PATCH 485/495] docs: Fix changelog review comments --- docs/changelog.rst | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 47c30a72..ab7d3f87 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,7 +12,7 @@ Three months after our fifth anniversary, Mopidy 1.0 is finally here! Since the release of 0.19, we've closed or merged approximately 140 issues and pull requests through more than 600 commits by a record high 19 extraordinary -people, including seven newcomers. Thanks to everyone that has contributed! +people, including seven newcomers. Thanks to everyone who has contributed! For the longest time, the focus of Mopidy 1.0 was to be another incremental improvement, to be numbered 0.20. The result is still very much an incremental @@ -21,17 +21,20 @@ functionality. The major features of Mopidy 1.0 are: -- A promise to follow not break APIs before Mopidy 2.0. A Mopidy extension - working with Mopidy 1.0 should continue to work with all Mopidy 1.x releases. +- Semantical versioning. We promise to not break APIs before Mopidy 2.0. A + Mopidy extension working with Mopidy 1.0 should continue to work with all + Mopidy 1.x releases. -- Preparation work to enable gapless playback in the near future. +- Preparation work to ease migration to a cleaned up and leaner core API in + Mopidy 2.0, and to give us some of the benefits of the cleaned up core API + right away. -TODO: to be continued +- Preparation work to enable gapless playback in an upcoming 1.x release. Dependencies ------------ -Since the previous release there is no changes to Mopidy's dependencies. +Since the previous release there are no changes to Mopidy's dependencies. However, porting from GStreamer 0.10 to 1.x and support for running Mopidy with Python 3.4+ is not far off on our roadmap. @@ -123,7 +126,7 @@ Core tracklist controller Core playback controller ~~~~~~~~~~~~~~~~~~~~~~~~ -- **Removed:** Remove several internal parts that was leaking into the public +- **Removed:** Remove several internal parts that were leaking into the public API and was never intended to be used externally. (Fixes: :issue:`1070`, PR: :issue:`1076`) @@ -164,8 +167,8 @@ Backend API ----------- In the API implemented by all backends there have been way fewer but somewhat -more dramatic changes with some methods removed and new ones being required for -certain functionality to continue working. Most backends are already updated to +more drastic changes with some methods removed and new ones being required for +certain functionality to continue working. Most backends were already updated to be compatible with Mopidy 1.0 before the release. New versions of the backends will be released shortly after Mopidy itself. @@ -237,7 +240,7 @@ Commands - Add support for repeating the :option:`-v ` argument four times to set the log level for all loggers to the lowest possible value, including - log records at levels lover than ``DEBUG`` too. + log records at levels lower than ``DEBUG`` too. - Add path to the current ``mopidy`` executable to the output of ``mopidy deps``. This make it easier to see that a user is using pip-installed Mopidy @@ -314,7 +317,7 @@ Stream backend M3U backend ----------- -- Mopidy-M3U is a new bundled backend. It is the same M3U support as was +- Mopidy-M3U is a new bundled backend. It provides the same M3U support as was previously part of the local backend. See :ref:`m3u-migration` for how to migrate your local playlists to work with the M3U backend. (Fixes: :issue:`1054`, PR: :issue:`1066`) @@ -372,12 +375,11 @@ Mixers Audio ----- -- **Removed:** Kill support for visualizers and the - :confval:`audio/visualizer` config value. The feature was originally added as - a workaround for all the people asking for ncmpcpp visualizer support, and - since we could get it almost for free thanks to GStreamer. But, this feature - did never make sense for a server such as Mopidy. The only way to find out if - it is in use and will be missed is to go ahead and remove it. +- **Removed:** Support for visualizers and the :confval:`audio/visualizer` + config value. The feature was originally added as a workaround for all the + people asking for ncmpcpp visualizer support, and since we could get it + almost for free thanks to GStreamer. But, this feature did never make sense + for a server such as Mopidy. - **Deprecated:** Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end From 08c7f311c4337f8662853c776b2617fa7bcc2015 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 23:08:38 +0100 Subject: [PATCH 486/495] docs: Fix more comments, add refs to relevant docs --- docs/changelog.rst | 9 +++++---- docs/versioning.rst | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ab7d3f87..1f07203b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,7 +12,8 @@ Three months after our fifth anniversary, Mopidy 1.0 is finally here! Since the release of 0.19, we've closed or merged approximately 140 issues and pull requests through more than 600 commits by a record high 19 extraordinary -people, including seven newcomers. Thanks to everyone who has contributed! +people, including seven newcomers. Thanks to :ref:`everyone ` who has +:ref:`contributed `! For the longest time, the focus of Mopidy 1.0 was to be another incremental improvement, to be numbered 0.20. The result is still very much an incremental @@ -21,9 +22,9 @@ functionality. The major features of Mopidy 1.0 are: -- Semantical versioning. We promise to not break APIs before Mopidy 2.0. A - Mopidy extension working with Mopidy 1.0 should continue to work with all - Mopidy 1.x releases. +- :ref:`Semantic Versioning `. We promise to not break APIs before + Mopidy 2.0. A Mopidy extension working with Mopidy 1.0 should continue to + work with all Mopidy 1.x releases. - Preparation work to ease migration to a cleaned up and leaner core API in Mopidy 2.0, and to give us some of the benefits of the cleaned up core API diff --git a/docs/versioning.rst b/docs/versioning.rst index cd428366..bc93275b 100644 --- a/docs/versioning.rst +++ b/docs/versioning.rst @@ -1,3 +1,5 @@ +.. _versioning: + ********** Versioning ********** From a8e6cd26dc58e6a9a64b7d4ace9f70daa64c9e08 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 23:40:46 +0100 Subject: [PATCH 487/495] core: Warn if backend does not implement as_list() Fixes #1080 --- mopidy/core/playlists.py | 24 ++++++++++++++++++------ tests/core/test_playlists.py | 11 +++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index e791380f..669e1f35 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -1,6 +1,6 @@ from __future__ import absolute_import, unicode_literals -import itertools +import logging import urlparse import pykka @@ -10,6 +10,9 @@ from mopidy.models import Playlist from mopidy.utils.deprecation import deprecated_property +logger = logging.getLogger(__name__) + + class PlaylistsController(object): pykka_traversable = True @@ -29,11 +32,20 @@ class PlaylistsController(object): .. versionadded:: 1.0 """ - futures = [ - b.playlists.as_list() - for b in self.backends.with_playlists.values()] - results = pykka.get_all(futures) - return list(itertools.chain(*results)) + futures = { + b.actor_ref.actor_class.__name__: b.playlists.as_list() + for b in set(self.backends.with_playlists.values())} + + results = [] + for backend_name, future in futures.items(): + try: + results.extend(future.get()) + except NotImplementedError: + logger.warning( + '%s does not implement playlists.as_list(). ' + 'Please upgrade it.', backend_name) + + return results def get_items(self, uri): """ diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index fecbbdcb..081f73e6 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -31,10 +31,12 @@ class PlaylistsTest(unittest.TestCase): self.sp2.lookup.return_value.get.side_effect = [self.pl2a, self.pl2b] self.backend1 = mock.Mock() + self.backend1.actor_ref.actor_class.__name__ = 'Backend1' self.backend1.uri_schemes.get.return_value = ['dummy1'] self.backend1.playlists = self.sp1 self.backend2 = mock.Mock() + self.backend2.actor_ref.actor_class.__name__ = 'Backend2' self.backend2.uri_schemes.get.return_value = ['dummy2'] self.backend2.playlists = self.sp2 @@ -55,6 +57,15 @@ class PlaylistsTest(unittest.TestCase): self.assertIn(self.plr2a, result) self.assertIn(self.plr2b, result) + def test_as_list_ignores_backends_that_dont_support_it(self): + self.sp2.as_list.return_value.get.side_effect = NotImplementedError + + result = self.core.playlists.as_list() + + self.assertEqual(len(result), 2) + self.assertIn(self.plr1a, result) + self.assertIn(self.plr1b, result) + def test_get_items_selects_the_matching_backend(self): ref = Ref.track() self.sp2.get_items.return_value.get.return_value = [ref] From ead725e9952670b18559f89e66d91c3de373dee2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Mar 2015 23:54:49 +0100 Subject: [PATCH 488/495] core/backend: Stop supporting old search signatures All backends are expected to support the exact argument. A friendly log message will be printed to prompt users to upgrade backends that fail due to this. --- mopidy/core/library.py | 30 ++++++++----------- mopidy/local/library.py | 9 ++---- tests/core/test_library.py | 61 +++++++++++++++++--------------------- tests/dummy_backend.py | 7 ++--- 4 files changed, 45 insertions(+), 62 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 44375f58..16e33d33 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -242,25 +242,21 @@ class LibraryController(object): The ``exact`` keyword argument, which replaces :meth:`find_exact`. """ query = _normalize_query(query or kwargs) - futures = [] + futures = {} for backend, backend_uris in self._get_backends_to_uris(uris).items(): - if hasattr(backend.library, 'find_exact'): - # Backends with find_exact probably don't have support for - # search with the exact kwarg, so give them the legacy calls. - if exact: - futures.append(backend.library.find_exact( - query=query, uris=backend_uris)) - else: - futures.append(backend.library.search( - query=query, uris=backend_uris)) - else: - # Assume backends without find_exact are up to date. Worst case - # the exact gets swallowed by the **kwargs and things hopefully - # still work. - futures.append(backend.library.search( - query=query, uris=backend_uris, exact=exact)) + futures[backend] = backend.library.search( + query=query, uris=backend_uris, exact=exact) - return [result for result in pykka.get_all(futures) if result] + results = [] + for backend, future in futures.items(): + try: + results.append(future.get()) + except TypeError: + backend_name = backend.actor_ref.actor_class.__name__ + logger.warning( + '%s does not implement library.search() with exact ' + 'support. Please upgrade it.', backend_name) + return [r for r in results if r] def _normalize_query(query): diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 77c122bd..5e98964c 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -51,12 +51,7 @@ class LocalLibraryProvider(backend.LibraryProvider): tracks = [tracks] return tracks - def find_exact(self, query=None, uris=None): + def search(self, query=None, uris=None, exact=False): if not self._library: return None - return self._library.search(query=query, uris=uris, exact=True) - - def search(self, query=None, uris=None): - if not self._library: - return None - return self._library.search(query=query, uris=uris, exact=False) + return self._library.search(query=query, uris=uris, exact=exact) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 50eb834f..51313daa 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -361,42 +361,35 @@ class CoreLibraryTest(unittest.TestCase): query={'any': ['foobar']}, uris=None, exact=True) -class LegacyLibraryProvider(backend.LibraryProvider): - def find_exact(self, query=None, uris=None): - pass +class LegacyFindExactToSearchLibraryTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.backend = mock.Mock() + self.backend.actor_ref.actor_class.__name__ = 'DummyBackend' + self.backend.uri_schemes.get.return_value = ['dummy'] + self.backend.library = mock.Mock(spec=backend.LibraryProvider) + self.core = core.Core(mixer=None, backends=[self.backend]) - -class LegacyCoreLibraryTest(unittest.TestCase): - def test_backend_with_find_exact_gets_find_exact_call(self): - b1 = mock.Mock() - b1.uri_schemes.get.return_value = ['dummy1'] - b1.library = mock.Mock(spec=LegacyLibraryProvider) - - b2 = mock.Mock() - b2.uri_schemes.get.return_value = ['dummy2'] - b2.library = mock.Mock(spec=backend.LibraryProvider) - - c = core.Core(mixer=None, backends=[b1, b2]) - c.library.find_exact(query={'any': ['a']}) - - b1.library.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) - b2.library.search.assert_called_once_with( + def test_core_find_exact_calls_backend_search_with_exact(self): + self.core.library.find_exact(query={'any': ['a']}) + self.backend.library.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=True) - def test_backend_with_find_exact_gets_search_without_exact_arg(self): - b1 = mock.Mock() - b1.uri_schemes.get.return_value = ['dummy1'] - b1.library = mock.Mock(spec=LegacyLibraryProvider) + def test_core_find_exact_handles_legacy_backend(self): + self.backend.library.search.return_value.get.side_effect = TypeError + self.core.library.find_exact(query={'any': ['a']}) + # We are just testing that this doesn't fail. - b2 = mock.Mock() - b2.uri_schemes.get.return_value = ['dummy2'] - b2.library = mock.Mock(spec=backend.LibraryProvider) - - c = core.Core(mixer=None, backends=[b1, b2]) - c.library.search(query={'any': ['a']}) - - b1.library.search.assert_called_once_with( - query=dict(any=['a']), uris=None) - b2.library.search.assert_called_once_with( + def test_core_search_call_backend_search_with_exact(self): + self.core.library.search(query={'any': ['a']}) + self.backend.library.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=False) + + def test_core_search_with_exact_call_backend_search_with_exact(self): + self.core.library.search(query={'any': ['a']}, exact=True) + self.backend.library.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + + def test_core_search_with_handles_legacy_backend(self): + self.backend.library.search.return_value.get.side_effect = TypeError + self.core.library.search(query={'any': ['a']}, exact=True) + # We are just testing that this doesn't fail. diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index babaf0de..99031ee1 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -46,16 +46,15 @@ class DummyLibraryProvider(backend.LibraryProvider): def get_distinct(self, field, query=None): return self.dummy_get_distinct_result.get(field, set()) - def find_exact(self, **query): - return self.dummy_find_exact_result - def lookup(self, uri): return [t for t in self.dummy_library if uri == t.uri] def refresh(self, uri=None): pass - def search(self, **query): + def search(self, query=None, uris=None, exact=False): + if exact: # TODO: remove uses of dummy_find_exact_result + return self.dummy_find_exact_result return self.dummy_search_result From f2a56edbf0ae45d4adb73f459fb4ae25a5df3c25 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 Mar 2015 00:03:48 +0100 Subject: [PATCH 489/495] dummy: Replace playlists property with test-only helper --- tests/dummy_backend.py | 24 ++++----- tests/mpd/protocol/test_music_db.py | 16 +++--- tests/mpd/protocol/test_stored_playlists.py | 54 ++++++++++----------- 3 files changed, 45 insertions(+), 49 deletions(-) diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index babaf0de..f2867b7b 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -100,6 +100,10 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): super(DummyPlaylistsProvider, self).__init__(backend) self._playlists = [] + def set_playlists(self, playlists): + """For tests using the dummy provider through an actor proxy.""" + self._playlists = playlists + def as_list(self): return [ Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] @@ -111,13 +115,13 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): return [ Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] - @property - def playlists(self): - return copy.copy(self._playlists) + def lookup(self, uri): + for playlist in self._playlists: + if playlist.uri == uri: + return playlist - @playlists.setter - def playlists(self, playlists): - self._playlists = playlists + def refresh(self): + pass def create(self, name): playlist = Playlist(name=name, uri='dummy:%s' % name) @@ -129,14 +133,6 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): if playlist: self._playlists.remove(playlist) - def lookup(self, uri): - for playlist in self._playlists: - if playlist.uri == uri: - return playlist - - def refresh(self): - pass - def save(self, playlist): old_playlist = self.lookup(playlist.uri) diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 613467ed..37cbfce0 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -277,8 +277,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_without_path_returns_same_as_for_root(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response1 = self.send_request('lsinfo') response2 = self.send_request('lsinfo "/"') @@ -286,8 +286,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_with_empty_path_returns_same_as_for_root(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response1 = self.send_request('lsinfo ""') response2 = self.send_request('lsinfo "/"') @@ -295,8 +295,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_root_includes_playlists(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) self.send_request('lsinfo "/"') self.assertInResponse('playlist: a') @@ -384,8 +384,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response = self.send_request('lsinfo "/"') self.assertLess(response.index('directory: dummy'), diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index a9190aa1..39d0d1b0 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -7,18 +7,18 @@ from tests.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylist "name"') self.assertInResponse('file: dummy:a') self.assertInResponse('OK') def test_listplaylist_without_quotes(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylist name') self.assertInResponse('file: dummy:a') @@ -31,16 +31,16 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) - self.backend.playlists.playlists = [playlist1, playlist2] + self.backend.playlists.set_playlists([playlist1, playlist2]) self.send_request('listplaylist "a [2]"') self.assertInResponse('file: c') self.assertInResponse('OK') def test_listplaylistinfo(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylistinfo "name"') self.assertInResponse('file: dummy:a') @@ -49,9 +49,9 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylistinfo_without_quotes(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylistinfo name') self.assertInResponse('file: dummy:a') @@ -67,7 +67,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylistinfo_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) - self.backend.playlists.playlists = [playlist1, playlist2] + self.backend.playlists.set_playlists([playlist1, playlist2]) self.send_request('listplaylistinfo "a [2]"') self.assertInResponse('file: c') @@ -77,8 +77,8 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:a', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='a', uri='dummy:a', last_modified=last_modified)]) self.send_request('listplaylists') self.assertInResponse('playlist: a') @@ -89,7 +89,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1') playlist2 = Playlist(name='a', uri='dummy:a2') - self.backend.playlists.playlists = [playlist1, playlist2] + self.backend.playlists.set_playlists([playlist1, playlist2]) self.send_request('listplaylists') self.assertInResponse('playlist: a') @@ -98,32 +98,32 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_ignores_playlists_without_name(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='', uri='dummy:', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='', uri='dummy:', last_modified=last_modified)]) self.send_request('listplaylists') self.assertNotInResponse('playlist: ') self.assertInResponse('OK') def test_listplaylists_replaces_newline_with_space(self): - self.backend.playlists.playlists = [ - Playlist(name='a\n', uri='dummy:')] + self.backend.playlists.set_playlists([ + Playlist(name='a\n', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\n') self.assertInResponse('OK') def test_listplaylists_replaces_carriage_return_with_space(self): - self.backend.playlists.playlists = [ - Playlist(name='a\r', uri='dummy:')] + self.backend.playlists.set_playlists([ + Playlist(name='a\r', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\r') self.assertInResponse('OK') def test_listplaylists_replaces_forward_slash_with_pipe(self): - self.backend.playlists.playlists = [ - Playlist(name='a/b', uri='dummy:')] + self.backend.playlists.set_playlists([ + Playlist(name='a/b', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a|b') self.assertNotInResponse('playlist: a/b') @@ -132,9 +132,9 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_appends_to_tracklist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + Track(uri='c'), Track(uri='d'), Track(uri='e')])]) self.send_request('load "A-list"') @@ -150,9 +150,9 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_with_range_loads_part_of_playlist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + Track(uri='c'), Track(uri='d'), Track(uri='e')])]) self.send_request('load "A-list" "1:2"') @@ -166,9 +166,9 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_with_range_without_end_loads_rest_of_playlist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + Track(uri='c'), Track(uri='d'), Track(uri='e')])]) self.send_request('load "A-list" "1:"') From 394081ae273d0628748c113fea1745b6dfe93d2e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 Mar 2015 00:40:59 +0100 Subject: [PATCH 490/495] core: Add quotes around 'exact' in warning --- mopidy/core/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 16e33d33..89a2037a 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -254,7 +254,7 @@ class LibraryController(object): except TypeError: backend_name = backend.actor_ref.actor_class.__name__ logger.warning( - '%s does not implement library.search() with exact ' + '%s does not implement library.search() with "exact" ' 'support. Please upgrade it.', backend_name) return [r for r in results if r] From a9393c38509840ab8431b9a34d741429413de468 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Wed, 25 Mar 2015 05:36:03 +0100 Subject: [PATCH 491/495] m3u: Replace slashes in playlist names with pipes. --- mopidy/m3u/playlists.py | 9 ++++++++- tests/m3u/test_playlists.py | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index 1fc5b4c3..c09eccdf 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -4,6 +4,7 @@ import glob import logging import operator import os +import re import sys from mopidy import backend @@ -15,6 +16,10 @@ logger = logging.getLogger(__name__) class M3UPlaylistsProvider(backend.PlaylistsProvider): + + # TODO: currently this only handles UNIX file systems + _invalid_filename_chars = re.compile(r'[/]') + def __init__(self, *args, **kwargs): super(M3UPlaylistsProvider, self).__init__(*args, **kwargs) @@ -89,8 +94,10 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n') def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()): + name = self._invalid_filename_chars.sub('|', name.strip()) + # make sure we end up with a valid path segment name = name.encode(encoding, errors='replace') - name = os.path.basename(name) + name = os.path.basename(name) # paranoia? name = name.decode(encoding) return name diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 07ffc0a3..355aabf5 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -50,8 +50,8 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertTrue(os.path.exists(path)) def test_create_sanitizes_playlist_name(self): - playlist = self.core.playlists.create('../../test FOO baR') - self.assertEqual('test FOO baR', playlist.name) + playlist = self.core.playlists.create(' ../../test FOO baR ') + self.assertEqual('..|..|test FOO baR', playlist.name) path = playlist_uri_to_path(playlist.uri, self.playlists_dir) self.assertEqual(self.playlists_dir, os.path.dirname(path)) self.assertTrue(os.path.exists(path)) From 75020c91ec17943069f9322a39617cdd27effd2b Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Wed, 25 Mar 2015 05:46:55 +0100 Subject: [PATCH 492/495] docs: Add PR #1084 to changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1f07203b..1e3e2d66 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -323,6 +323,9 @@ M3U backend migrate your local playlists to work with the M3U backend. (Fixes: :issue:`1054`, PR: :issue:`1066`) +- In playlist names, replace "/", which are illegal in M3U file names, + with "|". (PR: :issue:`1084`) + MPD frontend ------------ From 36fba3d67dd9b09bfa80adffe1d5f3c91504a367 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 Mar 2015 09:48:24 +0100 Subject: [PATCH 493/495] flake8: Fix unussed import --- tests/dummy_backend.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index acd081a0..c3c88c87 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -6,8 +6,6 @@ used in tests of the frontends. from __future__ import absolute_import, unicode_literals -import copy - import pykka from mopidy import backend From 2c11344434a8e5969867738d8d545d94f47e543c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 Mar 2015 13:14:51 +0100 Subject: [PATCH 494/495] dummy: Make it obvious that method is test-only --- tests/dummy_backend.py | 2 +- tests/mpd/protocol/test_music_db.py | 8 +++--- tests/mpd/protocol/test_stored_playlists.py | 30 ++++++++++----------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index c3c88c87..61c26c5f 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -97,7 +97,7 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): super(DummyPlaylistsProvider, self).__init__(backend) self._playlists = [] - def set_playlists(self, playlists): + def set_dummy_playlists(self, playlists): """For tests using the dummy provider through an actor proxy.""" self._playlists = playlists diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 37cbfce0..b9fbcdf6 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -277,7 +277,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_without_path_returns_same_as_for_root(self): last_modified = 1390942873222 - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response1 = self.send_request('lsinfo') @@ -286,7 +286,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_with_empty_path_returns_same_as_for_root(self): last_modified = 1390942873222 - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response1 = self.send_request('lsinfo ""') @@ -295,7 +295,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_root_includes_playlists(self): last_modified = 1390942873222 - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) self.send_request('lsinfo "/"') @@ -384,7 +384,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response = self.send_request('lsinfo "/"') diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index 39d0d1b0..cca32b0d 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -7,7 +7,7 @@ from tests.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) @@ -16,7 +16,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylist_without_quotes(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) @@ -31,14 +31,14 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) - self.backend.playlists.set_playlists([playlist1, playlist2]) + self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylist "a [2]"') self.assertInResponse('file: c') self.assertInResponse('OK') def test_listplaylistinfo(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) @@ -49,7 +49,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylistinfo_without_quotes(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) @@ -67,7 +67,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylistinfo_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) - self.backend.playlists.set_playlists([playlist1, playlist2]) + self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylistinfo "a [2]"') self.assertInResponse('file: c') @@ -77,7 +77,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = 1390942873222 - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:a', last_modified=last_modified)]) self.send_request('listplaylists') @@ -89,7 +89,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1') playlist2 = Playlist(name='a', uri='dummy:a2') - self.backend.playlists.set_playlists([playlist1, playlist2]) + self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylists') self.assertInResponse('playlist: a') @@ -98,7 +98,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_ignores_playlists_without_name(self): last_modified = 1390942873222 - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='', uri='dummy:', last_modified=last_modified)]) self.send_request('listplaylists') @@ -106,7 +106,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylists_replaces_newline_with_space(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a\n', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a ') @@ -114,7 +114,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylists_replaces_carriage_return_with_space(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a\r', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a ') @@ -122,7 +122,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylists_replaces_forward_slash_with_pipe(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a/b', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a|b') @@ -132,7 +132,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_appends_to_tracklist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])]) @@ -150,7 +150,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_with_range_loads_part_of_playlist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])]) @@ -166,7 +166,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_with_range_without_end_loads_rest_of_playlist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])]) From 4176557efcd83438397bf41b1db4c6e171a73b75 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 Mar 2015 22:24:28 +0100 Subject: [PATCH 495/495] docs: Add release date for v1.0.0 --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1e3e2d66..b976c169 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,7 +5,7 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.0 (UNRELEASED) +v1.0.0 (2015-03-25) =================== Three months after our fifth anniversary, Mopidy 1.0 is finally here!