diff --git a/docs/changes.rst b/docs/changes.rst index 0a9ab925..19c65ee9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -25,6 +25,9 @@ v0.6.0 (in development) - The local client now tries to lookup where your music is via XDG, it will fall-back to ``~/music`` or use whatever setting you set manually. +- The idle command is now supported by mopidy for the following subsystems: + player, playlist, options and mixer (Fixes: :issue:`32`). + **Changes** - Replace :attr:`mopidy.backends.base.Backend.uri_handlers` with diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/backends/base/current_playlist.py index 2633f166..e89c23d5 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/backends/base/current_playlist.py @@ -2,6 +2,7 @@ from copy import copy import logging import random +from mopidy.listeners import BackendListener from mopidy.models import CpTrack logger = logging.getLogger('mopidy.backends.base') @@ -16,6 +17,7 @@ class CurrentPlaylistController(object): def __init__(self, backend): self.backend = backend + self.cp_id = 0 self._cp_tracks = [] self._version = 0 @@ -53,8 +55,9 @@ class CurrentPlaylistController(object): def version(self, version): self._version = version self.backend.playback.on_current_playlist_change() + self._trigger_playlist_changed() - def add(self, track, at_position=None): + def add(self, track, at_position=None, increase_version=True): """ Add the track to the end of, or at the given position in the current playlist. @@ -68,12 +71,14 @@ class CurrentPlaylistController(object): """ assert at_position <= len(self._cp_tracks), \ u'at_position can not be greater than playlist length' - cp_track = CpTrack(self.version, track) + cp_track = CpTrack(self.cp_id, track) if at_position is not None: self._cp_tracks.insert(at_position, cp_track) else: self._cp_tracks.append(cp_track) - self.version += 1 + if increase_version: + self.version += 1 + self.cp_id += 1 return cp_track def append(self, tracks): @@ -84,7 +89,10 @@ class CurrentPlaylistController(object): :type tracks: list of :class:`mopidy.models.Track` """ for track in tracks: - self.add(track) + self.add(track, increase_version=False) + + if tracks: + self.version += 1 def clear(self): """Clear the current playlist.""" @@ -199,3 +207,7 @@ class CurrentPlaylistController(object): random.shuffle(shuffled) self._cp_tracks = before + shuffled + after self.version += 1 + + def _trigger_playlist_changed(self): + logger.debug(u'Triggering playlist changed event') + BackendListener.send('playlist_changed') diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 088a5ad4..5cab8229 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -8,6 +8,17 @@ from mopidy.listeners import BackendListener logger = logging.getLogger('mopidy.backends.base') + +def option_wrapper(name, default): + def get_option(self): + return getattr(self, name, default) + def set_option(self, value): + if getattr(self, name, default) != value: + self._trigger_options_changed() + return setattr(self, name, value) + return property(get_option, set_option) + + class PlaybackController(object): """ :param backend: the backend @@ -34,7 +45,7 @@ class PlaybackController(object): #: Tracks are removed from the playlist when they have been played. #: :class:`False` #: Tracks are not removed from the playlist. - consume = False + consume = option_wrapper('_consume', False) #: The currently playing or selected track. #: @@ -46,21 +57,21 @@ class PlaybackController(object): #: Tracks are selected at random from the playlist. #: :class:`False` #: Tracks are played in the order of the playlist. - random = False + random = option_wrapper('_random', False) #: :class:`True` #: The current playlist is played repeatedly. To repeat a single track, #: select both :attr:`repeat` and :attr:`single`. #: :class:`False` #: The current playlist is played once. - repeat = False + repeat = option_wrapper('_repeat', False) #: :class:`True` #: Playback is stopped after current song, unless in :attr:`repeat` #: mode. #: :class:`False` #: Playback continues after current song. - single = False + single = option_wrapper('_single', False) def __init__(self, backend, provider): self.backend = backend @@ -276,6 +287,9 @@ class PlaybackController(object): def state(self, new_state): (old_state, self._state) = (self.state, new_state) logger.debug(u'Changing state: %s -> %s', old_state, new_state) + + self._trigger_playback_state_changed() + # FIXME play_time stuff assumes backend does not have a better way of # handeling this stuff :/ if (old_state in (self.PLAYING, self.STOPPED) @@ -326,7 +340,7 @@ class PlaybackController(object): original_cp_track = self.current_cp_track if self.cp_track_at_eot: - self._trigger_stopped_playing_event() + self._trigger_track_playback_ended() self.play(self.cp_track_at_eot) else: self.stop(clear_current_track=True) @@ -354,7 +368,7 @@ class PlaybackController(object): return if self.cp_track_at_next: - self._trigger_stopped_playing_event() + self._trigger_track_playback_ended() self.play(self.cp_track_at_next) else: self.stop(clear_current_track=True) @@ -387,7 +401,6 @@ class PlaybackController(object): self.resume() if cp_track is not None: - self.state = self.STOPPED self.current_cp_track = cp_track self.state = self.PLAYING if not self.provider.play(cp_track.track): @@ -402,7 +415,7 @@ class PlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) - self._trigger_started_playing_event() + self._trigger_track_playback_started() def previous(self): """Play the previous track.""" @@ -410,7 +423,7 @@ class PlaybackController(object): return if self.state == self.STOPPED: return - self._trigger_stopped_playing_event() + self._trigger_track_playback_ended() self.play(self.cp_track_at_previous, on_error_step=-1) def resume(self): @@ -454,37 +467,34 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != self.STOPPED: - self._trigger_stopped_playing_event() if self.provider.stop(): + self._trigger_track_playback_ended() self.state = self.STOPPED if clear_current_track: self.current_cp_track = None - def _trigger_started_playing_event(self): - logger.debug(u'Triggering started playing event') + def _trigger_track_playback_started(self): + logger.debug(u'Triggering track playback started event') if self.current_track is None: return - ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': ('started_playing',), - 'args': [], - 'kwargs': {'track': self.current_track}, - }, target_class=BackendListener) + BackendListener.send('track_playback_started', + track=self.current_track) - def _trigger_stopped_playing_event(self): - # TODO Test that this is called on next/prev/end-of-track - logger.debug(u'Triggering stopped playing event') + def _trigger_track_playback_ended(self): + logger.debug(u'Triggering track playback ended event') if self.current_track is None: return - ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': ('stopped_playing',), - 'args': [], - 'kwargs': { - 'track': self.current_track, - 'time_position': self.time_position, - }, - }, target_class=BackendListener) + BackendListener.send('track_playback_ended', + track=self.current_track, + time_position=self.time_position) + + def _trigger_playback_state_changed(self): + logger.debug(u'Triggering playback state change event') + BackendListener.send('playback_state_changed') + + def _trigger_options_changed(self): + logger.debug(u'Triggering options changed event') + BackendListener.send('options_changed') class BasePlaybackProvider(object): diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index d50f8dd8..125457cd 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -57,7 +57,7 @@ class LastfmFrontend(ThreadingActor, BackendListener): logger.error(u'Error during Last.fm setup: %s', e) self.stop() - def started_playing(self, track): + def track_playback_started(self, track): artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 self.last_start_time = int(time.time()) @@ -74,7 +74,7 @@ class LastfmFrontend(ThreadingActor, BackendListener): pylast.MalformedResponseError, pylast.WSError) as e: logger.warning(u'Error submitting playing track to Last.fm: %s', e) - def stopped_playing(self, track, time_position): + def track_playback_ended(self, track, time_position): artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 time_position = time_position // 1000 diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 78742ed5..57b41fc0 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,15 +1,15 @@ import logging import sys -from pykka.actor import ThreadingActor +from pykka import registry, actor -from mopidy import settings +from mopidy import listeners, settings from mopidy.frontends.mpd import dispatcher, protocol from mopidy.utils import network, process, log logger = logging.getLogger('mopidy.frontends.mpd') -class MpdFrontend(ThreadingActor): +class MpdFrontend(actor.ThreadingActor, listeners.BackendListener): """ The MPD frontend. @@ -39,6 +39,28 @@ class MpdFrontend(ThreadingActor): def on_stop(self): process.stop_actors_by_class(MpdSession) + def send_idle(self, subsystem): + # FIXME this should be updated once pykka supports non-blocking calls + # on proxies or some similar solution + registry.ActorRegistry.broadcast({ + 'command': 'pykka_call', + 'attr_path': ('on_idle',), + 'args': [subsystem], + 'kwargs': {}, + }, target_class=MpdSession) + + def playback_state_changed(self): + self.send_idle('player') + + def playlist_changed(self): + self.send_idle('playlist') + + def options_changed(self): + self.send_idle('options') + + def volume_changed(self): + self.send_idle('mixer') + class MpdSession(network.LineProtocol): """ @@ -71,5 +93,8 @@ class MpdSession(network.LineProtocol): self.send_lines(response) + def on_idle(self, subsystem): + self.dispatcher.handle_idle(subsystem) + def close(self): self.stop() diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 0f0f0299..cab014a8 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -27,6 +27,8 @@ class MpdDispatcher(object): back to the MPD session. """ + _noidle = re.compile(r'^noidle$') + def __init__(self, session=None): self.authenticated = False self.command_list = False @@ -42,11 +44,28 @@ class MpdDispatcher(object): self._catch_mpd_ack_errors_filter, self._authenticate_filter, self._command_list_filter, + self._idle_filter, self._add_ok_filter, self._call_handler_filter, ] return self._call_next_filter(request, response, filter_chain) + def handle_idle(self, subsystem): + self.context.events.add(subsystem) + + subsystems = self.context.subscriptions.intersection( + self.context.events) + if not subsystems: + return + + response = [] + for subsystem in subsystems: + response.append(u'changed: %s' % subsystem) + response.append(u'OK') + self.context.subscriptions = set() + self.context.events = set() + self.context.session.send_lines(response) + def _call_next_filter(self, request, response, filter_chain): if filter_chain: next_filter = filter_chain.pop(0) @@ -108,6 +127,29 @@ class MpdDispatcher(object): and request != u'command_list_end') + ### Filter: idle + + def _idle_filter(self, request, response, filter_chain): + if self._is_currently_idle() and not self._noidle.match(request): + logger.debug(u'Client sent us %s, only %s is allowed while in ' + 'the idle state', repr(request), repr(u'noidle')) + self.context.session.close() + return [] + + if not self._is_currently_idle() and self._noidle.match(request): + return [] # noidle was called before idle + + response = self._call_next_filter(request, response, filter_chain) + + if self._is_currently_idle(): + return [] + else: + return response + + def _is_currently_idle(self): + return bool(self.context.subscriptions) + + ### Filter: add OK def _add_ok_filter(self, request, response, filter_chain): @@ -119,7 +161,6 @@ class MpdDispatcher(object): def _has_error(self, response): return response and response[-1].startswith(u'ACK') - ### Filter: call handler def _call_handler_filter(self, request, response, filter_chain): @@ -181,9 +222,17 @@ class MpdContext(object): #: The current :class:`mopidy.frontends.mpd.MpdSession`. session = None + #: The active subsystems that have pending events. + events = None + + #: The subsytems that we want to be notified about in idle mode. + subscriptions = None + def __init__(self, dispatcher, session=None): self.dispatcher = dispatcher self.session = session + self.events = set() + self.subscriptions = set() self._backend = None self._mixer = None diff --git a/mopidy/frontends/mpd/protocol/empty.py b/mopidy/frontends/mpd/protocol/empty.py index 33b3bd9f..4cdafd87 100644 --- a/mopidy/frontends/mpd/protocol/empty.py +++ b/mopidy/frontends/mpd/protocol/empty.py @@ -1,6 +1,6 @@ from mopidy.frontends.mpd.protocol import handle_request -@handle_request(r'^\s*$') +@handle_request(r'^[ ]*$') def empty(context): """The original MPD server returns ``OK`` on an empty request.""" pass diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 3618f5e1..df13b4b4 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -11,28 +11,16 @@ def commands(context): Shows which commands the current user has access to. """ if context.dispatcher.authenticated: - command_names = [command.name for command in mpd_commands] + command_names = set([command.name for command in mpd_commands]) else: - command_names = [command.name for command in mpd_commands - if not command.auth_required] + command_names = set([command.name for command in mpd_commands + if not command.auth_required]) - # No permission to use - if 'kill' in command_names: - command_names.remove('kill') - - # Not shown by MPD in its command list - if 'command_list_begin' in command_names: - command_names.remove('command_list_begin') - if 'command_list_ok_begin' in command_names: - command_names.remove('command_list_ok_begin') - if 'command_list_end' in command_names: - command_names.remove('command_list_end') - if 'idle' in command_names: - command_names.remove('idle') - if 'noidle' in command_names: - command_names.remove('noidle') - if 'sticker' in command_names: - command_names.remove('sticker') + # No one is permited to use kill, rest of commands are not listed by MPD, + # so we shouldn't either. + command_names = command_names - set(['kill', 'command_list_begin', + 'command_list_ok_begin', 'command_list_ok_begin', 'command_list_end', + 'idle', 'noidle', 'sticker']) return [('command', command_name) for command_name in sorted(command_names)] diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index abbb8d7f..5ac99dfe 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -4,6 +4,10 @@ from mopidy.backends.base import PlaybackController from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented +#: Subsystems that can be registered with idle command. +SUBSYSTEMS = ['database', 'mixer', 'options', 'output', + 'player', 'playlist', 'stored_playlist', 'update', ] + @handle_request(r'^clearerror$') def clearerror(context): """ @@ -67,12 +71,36 @@ def idle(context, subsystems=None): notifications when something changed in one of the specified subsystems. """ - pass # TODO + + if subsystems: + subsystems = subsystems.split() + else: + subsystems = SUBSYSTEMS + + for subsystem in subsystems: + context.subscriptions.add(subsystem) + + active = context.subscriptions.intersection(context.events) + if not active: + context.session.prevent_timeout = True + return + + response = [] + context.events = set() + context.subscriptions = set() + + for subsystem in active: + response.append(u'changed: %s' % subsystem) + return response @handle_request(r'^noidle$') def noidle(context): """See :meth:`_status_idle`.""" - pass # TODO + if not context.subscriptions: + return + context.subscriptions = set() + context.events = set() + context.session.prevent_timeout = False @handle_request(r'^stats$') def stats(context): diff --git a/mopidy/listeners.py b/mopidy/listeners.py index dfc5c60b..590f0ad0 100644 --- a/mopidy/listeners.py +++ b/mopidy/listeners.py @@ -1,3 +1,5 @@ +from pykka import registry + class BackendListener(object): """ Marker interface for recipients of events sent by the backend. @@ -9,7 +11,19 @@ class BackendListener(object): interested in all events. """ - def started_playing(self, track): + @staticmethod + def send(event, **kwargs): + """Helper to allow calling of backend listener events""" + # FIXME this should be updated once pykka supports non-blocking calls + # on proxies or some similar solution + registry.ActorRegistry.broadcast({ + 'command': 'pykka_call', + 'attr_path': (event,), + 'args': [], + 'kwargs': kwargs, + }, target_class=BackendListener) + + def track_playback_started(self, track): """ Called whenever a new track starts playing. @@ -20,9 +34,9 @@ class BackendListener(object): """ pass - def stopped_playing(self, track, time_position): + def track_playback_ended(self, track, time_position): """ - Called whenever playback is stopped. + Called whenever playback of a track ends. *MAY* be implemented by actor. @@ -32,3 +46,35 @@ class BackendListener(object): :type time_position: int """ pass + + def playback_state_changed(self): + """ + Called whenever playback state is changed. + + *MAY* be implemented by actor. + """ + pass + + def playlist_changed(self): + """ + Called whenever a playlist is changed. + + *MAY* be implemented by actor. + """ + pass + + def options_changed(self): + """ + Called whenever an option is changed. + + *MAY* be implemented by actor. + """ + pass + + def volume_changed(self): + """ + Called whenever the volume is changed. + + *MAY* be implemented by actor. + """ + pass diff --git a/mopidy/mixers/base.py b/mopidy/mixers/base.py index ec3d8ae5..8798076a 100644 --- a/mopidy/mixers/base.py +++ b/mopidy/mixers/base.py @@ -1,4 +1,8 @@ -from mopidy import settings +import logging + +from mopidy import listeners, settings + +logger = logging.getLogger('mopdy.mixers') class BaseMixer(object): """ @@ -30,6 +34,7 @@ class BaseMixer(object): elif volume > 100: volume = 100 self.set_volume(volume) + self._trigger_volume_changed() def get_volume(self): """ @@ -46,3 +51,7 @@ class BaseMixer(object): *MUST be implemented by subclass.* """ raise NotImplementedError + + def _trigger_volume_changed(self): + logger.debug(u'Triggering volume changed event') + listeners.BackendListener.send('volume_changed') diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 6b2f69e5..a1ddeb82 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -290,6 +290,7 @@ class LineProtocol(ThreadingActor): def __init__(self, connection): self.connection = connection + self.prevent_timeout = False self.recv_buffer = '' if self.delimeter: @@ -326,7 +327,8 @@ class LineProtocol(ThreadingActor): if line is not None: self.on_line_received(line) - self.connection.enable_timeout() + if not self.prevent_timeout: + self.connection.enable_timeout() def on_stop(self): """Ensure that cleanup when actor stops.""" diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index 44529e90..bc39ac00 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -11,8 +11,8 @@ from mopidy.models import Track class BackendEventsTest(unittest.TestCase): def setUp(self): self.events = { - 'started_playing': threading.Event(), - 'stopped_playing': threading.Event(), + 'track_playback_started': threading.Event(), + 'track_playback_ended': threading.Event(), } self.backend = DummyBackend.start().proxy() self.listener = DummyBackendListener.start(self.events).proxy() @@ -20,26 +20,26 @@ class BackendEventsTest(unittest.TestCase): def tearDown(self): ActorRegistry.stop_all() - def test_play_sends_started_playing_event(self): + def test_play_sends_track_playback_started_event(self): self.backend.current_playlist.add([Track(uri='a')]) self.backend.playback.play() - self.events['started_playing'].wait(timeout=1) - self.assertTrue(self.events['started_playing'].is_set()) + self.events['track_playback_started'].wait(timeout=1) + self.assertTrue(self.events['track_playback_started'].is_set()) - def test_stop_sends_stopped_playing_event(self): + def test_stop_sends_track_playback_ended_event(self): self.backend.current_playlist.add([Track(uri='a')]) self.backend.playback.play() self.backend.playback.stop() - self.events['stopped_playing'].wait(timeout=1) - self.assertTrue(self.events['stopped_playing'].is_set()) + self.events['track_playback_ended'].wait(timeout=1) + self.assertTrue(self.events['track_playback_ended'].is_set()) class DummyBackendListener(ThreadingActor, BackendListener): def __init__(self, events): self.events = events - def started_playing(self, track): - self.events['started_playing'].set() + def track_playback_started(self, track): + self.events['track_playback_started'].set() - def stopped_playing(self, track, time_position): - self.events['stopped_playing'].set() + def track_playback_ended(self, track, time_position): + self.events['track_playback_ended'].set() diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 77825a6e..8cd91d60 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -27,21 +27,31 @@ class BaseTestCase(unittest.TestCase): self.connection = MockConnetion() self.session = mpd.MpdSession(self.connection) self.dispatcher = self.session.dispatcher + self.context = self.dispatcher.context def tearDown(self): self.backend.stop().get() self.mixer.stop().get() settings.runtime.clear() - def sendRequest(self, request, clear=False): + def sendRequest(self, request): self.connection.response = [] - self.session.on_line_received(request) + request = '%s\n' % request.encode('utf-8') + self.session.on_receive({'received': request}) return self.connection.response + def assertNoResponse(self): + self.assertEqual([], self.connection.response) + def assertInResponse(self, value): self.assert_(value in self.connection.response, u'Did not find %s ' 'in %s' % (repr(value), repr(self.connection.response))) + def assertOnceInResponse(self, value): + 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): self.assert_(value not in self.connection.response, u'Found %s in %s' % (repr(value), repr(self.connection.response))) diff --git a/tests/frontends/mpd/protocol/idle_test.py b/tests/frontends/mpd/protocol/idle_test.py new file mode 100644 index 00000000..da16bf33 --- /dev/null +++ b/tests/frontends/mpd/protocol/idle_test.py @@ -0,0 +1,206 @@ +from mock import patch + +from mopidy.frontends.mpd.protocol.status import SUBSYSTEMS +from mopidy.models import Track + +from tests.frontends.mpd import protocol + +class IdleHandlerTest(protocol.BaseTestCase): + def idleEvent(self, subsystem): + self.session.on_idle(subsystem) + + def assertEqualEvents(self, events): + self.assertEqual(set(events), self.context.events) + + def assertEqualSubscriptions(self, events): + self.assertEqual(set(events), self.context.subscriptions) + + def assertNoEvents(self): + self.assertEqualEvents([]) + + def assertNoSubscriptions(self): + self.assertEqualSubscriptions([]) + + def test_base_state(self): + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertNoResponse() + + def test_idle(self): + self.sendRequest(u'idle') + self.assertEqualSubscriptions(SUBSYSTEMS) + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_disables_timeout(self): + self.sendRequest(u'idle') + self.connection.disable_timeout.assert_called_once_with() + + def test_noidle(self): + self.sendRequest(u'noidle') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_player(self): + self.sendRequest(u'idle player') + self.assertEqualSubscriptions(['player']) + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_player_playlist(self): + self.sendRequest(u'idle player playlist') + self.assertEqualSubscriptions(['player', 'playlist']) + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_then_noidle(self): + self.sendRequest(u'idle') + self.sendRequest(u'noidle') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'OK') + + def test_idle_then_noidle_enables_timeout(self): + self.sendRequest(u'idle') + self.sendRequest(u'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(u'idle') + self.sendRequest(u'play') + stop_mock.assert_called_once_with() + + def test_idle_then_idle(self): + with patch.object(self.session, 'stop') as stop_mock: + self.sendRequest(u'idle') + self.sendRequest(u'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(u'idle player') + self.sendRequest(u'play') + stop_mock.assert_called_once_with() + + def test_idle_then_player(self): + self.sendRequest(u'idle') + self.idleEvent(u'player') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'OK') + + def test_idle_player_then_event_player(self): + self.sendRequest(u'idle player') + self.idleEvent(u'player') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'OK') + + def test_idle_player_then_noidle(self): + self.sendRequest(u'idle player') + self.sendRequest(u'noidle') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'OK') + + def test_idle_player_playlist_then_noidle(self): + self.sendRequest(u'idle player playlist') + self.sendRequest(u'noidle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'OK') + + def test_idle_player_playlist_then_player(self): + self.sendRequest(u'idle player playlist') + self.idleEvent(u'player') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertNotInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_idle_playlist_then_player(self): + self.sendRequest(u'idle playlist') + self.idleEvent(u'player') + self.assertEqualEvents(['player']) + self.assertEqualSubscriptions(['playlist']) + self.assertNoResponse() + + def test_idle_playlist_then_player_then_playlist(self): + self.sendRequest(u'idle playlist') + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertNotInResponse(u'changed: player') + self.assertOnceInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_player(self): + self.idleEvent(u'player') + self.assertEqualEvents(['player']) + self.assertNoSubscriptions() + self.assertNoResponse() + + def test_player_then_idle_player(self): + self.idleEvent(u'player') + self.sendRequest(u'idle player') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertNotInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_player_then_playlist(self): + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.assertEqualEvents(['player', 'playlist']) + self.assertNoSubscriptions() + self.assertNoResponse() + + def test_player_then_idle(self): + self.idleEvent(u'player') + self.sendRequest(u'idle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'OK') + + def test_player_then_playlist_then_idle(self): + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.sendRequest(u'idle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_player_then_idle_playlist(self): + self.idleEvent(u'player') + self.sendRequest(u'idle playlist') + self.assertEqualEvents(['player']) + self.assertEqualSubscriptions(['playlist']) + self.assertNoResponse() + + def test_player_then_idle_playlist_then_noidle(self): + self.idleEvent(u'player') + self.sendRequest(u'idle playlist') + self.sendRequest(u'noidle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'OK') + + def test_player_then_playlist_then_idle_playlist(self): + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.sendRequest(u'idle playlist') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertNotInResponse(u'changed: player') + self.assertOnceInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/status_test.py b/tests/frontends/mpd/protocol/status_test.py index 6762a4fb..f50ecd24 100644 --- a/tests/frontends/mpd/protocol/status_test.py +++ b/tests/frontends/mpd/protocol/status_test.py @@ -27,21 +27,6 @@ class StatusHandlerTest(protocol.BaseTestCase): self.sendRequest(u'currentsong') self.assertInResponse(u'OK') - def test_idle_without_subsystems(self): - # FIXME this is not the correct behaviour for idle... - self.sendRequest(u'idle') - self.assertInResponse(u'OK') - - def test_idle_with_subsystems(self): - # FIXME this is not the correct behaviour for idle... - self.sendRequest(u'idle database playlist') - self.assertInResponse(u'OK') - - def test_noidle(self): - # FIXME this is not the correct behaviour for idle... - self.sendRequest(u'noidle') - self.assertInResponse(u'OK') - def test_stats_command(self): self.sendRequest(u'stats') self.assertInResponse(u'OK') diff --git a/tests/listeners_test.py b/tests/listeners_test.py index 761aff4f..2c31efdb 100644 --- a/tests/listeners_test.py +++ b/tests/listeners_test.py @@ -7,8 +7,8 @@ class BackendListenerTest(unittest.TestCase): def setUp(self): self.listener = BackendListener() - def test_listener_has_default_impl_for_the_started_playing_event(self): - self.listener.started_playing(Track()) + def test_listener_has_default_impl_for_the_track_playback_started(self): + self.listener.track_playback_started(Track()) - def test_listener_has_default_impl_for_the_stopped_playing_event(self): - self.listener.stopped_playing(Track(), 0) + def test_listener_has_default_impl_for_the_track_playback_ended(self): + self.listener.track_playback_ended(Track(), 0) diff --git a/tests/utils/network/lineprotocol_test.py b/tests/utils/network/lineprotocol_test.py index f3877126..3d16f81c 100644 --- a/tests/utils/network/lineprotocol_test.py +++ b/tests/utils/network/lineprotocol_test.py @@ -10,9 +10,11 @@ from mock import sentinel, Mock class LineProtocolTest(unittest.TestCase): def setUp(self): self.mock = Mock(spec=network.LineProtocol) + self.mock.terminator = network.LineProtocol.terminator self.mock.encoding = network.LineProtocol.encoding self.mock.delimeter = network.LineProtocol.delimeter + self.mock.prevent_timeout = False def test_init_stores_values_in_attributes(self): delimeter = re.compile(network.LineProtocol.terminator) @@ -20,6 +22,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual(sentinel.connection, self.mock.connection) self.assertEqual('', self.mock.recv_buffer) self.assertEqual(delimeter, self.mock.delimeter) + self.assertFalse(self.mock.prevent_timeout) def test_init_compiles_delimeter(self): self.mock.delimeter = '\r?\n' @@ -47,6 +50,16 @@ class LineProtocolTest(unittest.TestCase): self.mock.connection.disable_timeout.assert_called_once_with() self.mock.connection.enable_timeout.assert_called_once_with() + def test_on_receive_toggles_unless_prevent_timeout_is_set(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [] + self.mock.prevent_timeout = True + + network.LineProtocol.on_receive(self.mock, {'received': 'data'}) + self.mock.connection.disable_timeout.assert_called_once_with() + self.assertEqual(0, self.mock.connection.enable_timeout.call_count) + def test_on_receive_no_new_lines_calls_parse_lines(self): self.mock.connection = Mock(spec=network.Connection) self.mock.recv_buffer = '' diff --git a/tools/idle.py b/tools/idle.py new file mode 100644 index 00000000..aa56dce2 --- /dev/null +++ b/tools/idle.py @@ -0,0 +1,201 @@ +#! /usr/bin/env python + +# This script is helper to systematicly test the behaviour of MPD's idle +# command. It is simply provided as a quick hack, expect nothing more. + +import logging +import pprint +import socket + +host = '' +port = 6601 + +url = "13 - a-ha - White Canvas.mp3" +artist = "a-ha" + +data = {'id': None, 'id2': None, 'url': url, 'artist': artist} + +# Commands to run before test requests to coerce MPD into right state +setup_requests = [ + 'clear', + 'add "%(url)s"', + 'add "%(url)s"', + 'add "%(url)s"', + 'play', +# 'pause', # Uncomment to test paused idle behaviour +# 'stop', # Uncomment to test stopped idle behaviour +] + +# List of commands to test for idle behaviour. Ordering of list is important in +# order to keep MPD state as intended. Commands that are obviously +# informational only or "harmfull" have been excluded. +test_requests = [ + 'add "%(url)s"', + 'addid "%(url)s" "1"', + 'clear', +# 'clearerror', +# 'close', +# 'commands', + 'consume "1"', + 'consume "0"', +# 'count', + 'crossfade "1"', + 'crossfade "0"', +# 'currentsong', +# 'delete "1:2"', + 'delete "0"', + 'deleteid "%(id)s"', + 'disableoutput "0"', + 'enableoutput "0"', +# 'find', +# 'findadd "artist" "%(artist)s"', +# 'idle', +# 'kill', +# 'list', +# 'listall', +# 'listallinfo', +# 'listplaylist', +# 'listplaylistinfo', +# 'listplaylists', +# 'lsinfo', + 'move "0:1" "2"', + 'move "0" "1"', + 'moveid "%(id)s" "1"', + 'next', +# 'notcommands', +# 'outputs', +# 'password', + 'pause', +# 'ping', + 'play', + 'playid "%(id)s"', +# 'playlist', + 'playlistadd "foo" "%(url)s"', + 'playlistclear "foo"', + 'playlistadd "foo" "%(url)s"', + 'playlistdelete "foo" "0"', +# 'playlistfind', +# 'playlistid', +# 'playlistinfo', + 'playlistadd "foo" "%(url)s"', + 'playlistadd "foo" "%(url)s"', + 'playlistmove "foo" "0" "1"', +# 'playlistsearch', +# 'plchanges', +# 'plchangesposid', + 'previous', + 'random "1"', + 'random "0"', + 'rm "bar"', + 'rename "foo" "bar"', + 'repeat "0"', + 'rm "bar"', + 'save "bar"', + 'load "bar"', +# 'search', + 'seek "1" "10"', + 'seekid "%(id)s" "10"', +# 'setvol "10"', + 'shuffle', + 'shuffle "0:1"', + 'single "1"', + 'single "0"', +# 'stats', +# 'status', + 'stop', + 'swap "1" "2"', + 'swapid "%(id)s" "%(id2)s"', +# 'tagtypes', +# 'update', +# 'urlhandlers', +# 'volume', +] + + +def create_socketfile(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, port)) + sock.settimeout(0.5) + fd = sock.makefile('rw', 1) # 1 = line buffered + fd.readline() # Read banner + return fd + + +def wait(fd, prefix=None, collect=None): + while True: + line = fd.readline().rstrip() + if prefix: + logging.debug('%s: %s', prefix, repr(line)) + if line.split()[0] in ('OK', 'ACK'): + break + + +def collect_ids(fd): + fd.write('playlistinfo\n') + + ids = [] + while True: + line = fd.readline() + if line.split()[0] == 'OK': + break + if line.split()[0] == 'Id:': + ids.append(line.split()[1]) + return ids + + +def main(): + subsystems = {} + + command = create_socketfile() + + for test in test_requests: + # Remove any old ids + del data['id'] + del data['id2'] + + # Run setup code to force MPD into known state + for setup in setup_requests: + command.write(setup % data + '\n') + wait(command) + + data['id'], data['id2'] = collect_ids(command)[:2] + + # This connection needs to be make after setup commands are done or + # else they will cause idle events. + idle = create_socketfile() + + # Wait for new idle events + idle.write('idle\n') + + test = test % data + + logging.debug('idle: %s', repr('idle')) + logging.debug('command: %s', repr(test)) + + command.write(test + '\n') + wait(command, prefix='command') + + while True: + try: + line = idle.readline().rstrip() + except socket.timeout: + # Abort try if we time out. + idle.write('noidle\n') + break + + logging.debug('idle: %s', repr(line)) + + if line == 'OK': + break + + request_type = test.split()[0] + subsystem = line.split()[1] + subsystems.setdefault(request_type, set()).add(subsystem) + + logging.debug('---') + + pprint.pprint(subsystems) + + +if __name__ == '__main__': + main()