Merge branch 'develop' into feature/support-telnet
Conflicts: tests/utils/network/lineprotocol_test.py
This commit is contained in:
commit
035af38b02
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)))
|
||||
|
||||
206
tests/frontends/mpd/protocol/idle_test.py
Normal file
206
tests/frontends/mpd/protocol/idle_test.py
Normal file
@ -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')
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = ''
|
||||
|
||||
201
tools/idle.py
Normal file
201
tools/idle.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user