diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 598e2971f..11fec0890 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -27,6 +27,7 @@ import random import time import math import inspect +import socket import beets from beets.plugins import BeetsPlugin @@ -38,7 +39,7 @@ from beets import dbcore from beets.mediafile import MediaFile import six -PROTOCOL_VERSION = '0.13.0' +PROTOCOL_VERSION = '0.14.0' BUFSIZE = 1024 HELLO = u'OK MPD %s' % PROTOCOL_VERSION @@ -72,6 +73,14 @@ SAFE_COMMANDS = ( u'close', u'commands', u'notcommands', u'password', u'ping', ) +# List of subsystems/events used by the `idle` command. +SUBSYSTEMS = [ + u'update', u'player', u'mixer', u'options', u'playlist', u'database', + # Related to unsupported commands: + # u'stored_playlist', u'output', u'subscription', u'sticker', u'message', + # u'partition', +] + ITEM_KEYS_WRITABLE = set(MediaFile.fields()).intersection(Item._fields.keys()) @@ -147,6 +156,16 @@ class BPDClose(Exception): should be closed. """ + +class BPDIdle(Exception): + """Raised by a command to indicate the client wants to enter the idle state + and should be notified when a relevant event happens. + """ + def __init__(self, subsystems): + super(BPDIdle, self).__init__() + self.subsystems = set(subsystems) + + # Generic server infrastructure, implementing the basic protocol. @@ -163,12 +182,17 @@ class BaseServer(object): This is a generic superclass and doesn't support many commands. """ - def __init__(self, host, port, password, log): + def __init__(self, host, port, password, ctrl_port, log, ctrl_host=None): """Create a new server bound to address `host` and listening on port `port`. If `password` is given, it is required to do anything significant on the server. + A separate control socket is established listening to `ctrl_host` on + port `ctrl_port` which is used to forward notifications from the player + and can be sent debug commands (e.g. using netcat). """ self.host, self.port, self.password = host, port, password + self.ctrl_host, self.ctrl_port = ctrl_host or host, ctrl_port + self.ctrl_sock = None self._log = log # Default server values. @@ -187,16 +211,58 @@ class BaseServer(object): self.paused = False self.error = None + # Current connections + self.connections = set() + # Object for random numbers generation self.random_obj = random.Random() + def connect(self, conn): + """A new client has connected. + """ + self.connections.add(conn) + + def disconnect(self, conn): + """Client has disconnected; clean up residual state. + """ + self.connections.remove(conn) + def run(self): """Block and start listening for connections from clients. An interrupt (^C) closes the server. """ self.startup_time = time.time() - bluelet.run(bluelet.server(self.host, self.port, - Connection.handler(self))) + + def start(): + yield bluelet.spawn( + bluelet.server(self.ctrl_host, self.ctrl_port, + ControlConnection.handler(self))) + yield bluelet.server(self.host, self.port, + MPDConnection.handler(self)) + bluelet.run(start()) + + def dispatch_events(self): + """If any clients have idle events ready, send them. + """ + # We need a copy of `self.connections` here since clients might + # disconnect once we try and send to them, changing `self.connections`. + for conn in list(self.connections): + yield bluelet.spawn(conn.send_notifications()) + + def _ctrl_send(self, message): + """Send some data over the control socket. + If it's our first time, open the socket. The message should be a + string without a terminal newline. + """ + if not self.ctrl_sock: + self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.ctrl_sock.connect((self.ctrl_host, self.ctrl_port)) + self.ctrl_sock.sendall((message + u'\n').encode('utf-8')) + + def _send_event(self, event): + """Notify subscribed connections of an event.""" + for conn in self.connections: + conn.notify(event) def _item_info(self, item): """An abstract method that should response lines containing a @@ -258,6 +324,14 @@ class BaseServer(object): """Succeeds.""" pass + def cmd_idle(self, conn, *subsystems): + subsystems = subsystems or SUBSYSTEMS + for system in subsystems: + if system not in SUBSYSTEMS: + raise BPDError(ERROR_ARG, + u'Unrecognised idle event: {}'.format(system)) + raise BPDIdle(subsystems) # put the connection into idle mode + def cmd_kill(self, conn): """Exits the server process.""" exit(0) @@ -309,7 +383,6 @@ class BaseServer(object): playlist, playlistlength, and xfade. """ yield ( - u'volume: ' + six.text_type(self.volume), u'repeat: ' + six.text_type(int(self.repeat)), u'random: ' + six.text_type(int(self.random)), u'consume: ' + six.text_type(int(self.consume)), @@ -319,6 +392,9 @@ class BaseServer(object): u'mixrampdb: ' + six.text_type(self.mixrampdb), ) + if self.volume > 0: + yield u'volume: ' + six.text_type(self.volume) + if not math.isnan(self.mixrampdelay): yield u'mixrampdelay: ' + six.text_type(self.mixrampdelay) if self.crossfade > 0: @@ -350,19 +426,23 @@ class BaseServer(object): def cmd_random(self, conn, state): """Set or unset random (shuffle) mode.""" self.random = cast_arg('intbool', state) + self._send_event('options') def cmd_repeat(self, conn, state): """Set or unset repeat mode.""" self.repeat = cast_arg('intbool', state) + self._send_event('options') def cmd_consume(self, conn, state): """Set or unset consume mode.""" self.consume = cast_arg('intbool', state) + self._send_event('options') def cmd_single(self, conn, state): """Set or unset single mode.""" # TODO support oneshot in addition to 0 and 1 [MPD 0.20] self.single = cast_arg('intbool', state) + self._send_event('options') def cmd_setvol(self, conn, vol): """Set the player's volume level (0-100).""" @@ -370,6 +450,7 @@ class BaseServer(object): if vol < VOLUME_MIN or vol > VOLUME_MAX: raise BPDError(ERROR_ARG, u'volume out of range') self.volume = vol + self._send_event('mixer') def cmd_volume(self, conn, vol_delta): """Deprecated command to change the volume by a relative amount.""" @@ -382,6 +463,7 @@ class BaseServer(object): raise BPDError(ERROR_ARG, u'crossfade time must be nonnegative') self._log.warning(u'crossfade is not implemented in bpd') self.crossfade = crossfade + self._send_event('options') def cmd_mixrampdb(self, conn, db): """Set the mixramp normalised max volume in dB.""" @@ -390,6 +472,7 @@ class BaseServer(object): raise BPDError(ERROR_ARG, u'mixrampdb time must be negative') self._log.warning('mixramp is not implemented in bpd') self.mixrampdb = db + self._send_event('options') def cmd_mixrampdelay(self, conn, delay): """Set the mixramp delay in seconds.""" @@ -398,6 +481,7 @@ class BaseServer(object): raise BPDError(ERROR_ARG, u'mixrampdelay time must be nonnegative') self._log.warning('mixramp is not implemented in bpd') self.mixrampdelay = delay + self._send_event('options') def cmd_replay_gain_mode(self, conn, mode): """Set the replay gain mode.""" @@ -405,6 +489,7 @@ class BaseServer(object): raise BPDError(ERROR_ARG, u'Unrecognised replay gain mode') self._log.warning('replay gain is not implemented in bpd') self.replay_gain_mode = mode + self._send_event('options') def cmd_replay_gain_status(self, conn): """Get the replaygain mode.""" @@ -415,6 +500,7 @@ class BaseServer(object): self.playlist = [] self.playlist_version += 1 self.cmd_stop(conn) + self._send_event('playlist') def cmd_delete(self, conn, index): """Remove the song at index from the playlist.""" @@ -430,6 +516,7 @@ class BaseServer(object): elif index < self.current_index: # Deleted before playing. # Shift playing index down. self.current_index -= 1 + self._send_event('playlist') def cmd_deleteid(self, conn, track_id): self.cmd_delete(conn, self._id_to_index(track_id)) @@ -453,6 +540,7 @@ class BaseServer(object): self.current_index += 1 self.playlist_version += 1 + self._send_event('playlist') def cmd_moveid(self, conn, idx_from, idx_to): idx_from = self._id_to_index(idx_from) @@ -478,6 +566,7 @@ class BaseServer(object): self.current_index = i self.playlist_version += 1 + self._send_event('playlist') def cmd_swapid(self, conn, i_id, j_id): i = self._id_to_index(i_id) @@ -535,6 +624,7 @@ class BaseServer(object): """Advance to the next song in the playlist.""" old_index = self.current_index self.current_index = self._succ_idx() + self._send_event('playlist') if self.consume: # TODO how does consume interact with single+repeat? self.playlist.pop(old_index) @@ -555,6 +645,7 @@ class BaseServer(object): """Step back to the last song.""" old_index = self.current_index self.current_index = self._prev_idx() + self._send_event('playlist') if self.consume: self.playlist.pop(old_index) if self.current_index < 0: @@ -570,6 +661,7 @@ class BaseServer(object): self.paused = not self.paused # Toggle. else: self.paused = cast_arg('intbool', state) + self._send_event('player') def cmd_play(self, conn, index=-1): """Begin playback, possibly at a specified playlist index.""" @@ -589,6 +681,7 @@ class BaseServer(object): self.current_index = index self.paused = False + self._send_event('player') def cmd_playid(self, conn, track_id=0): track_id = cast_arg(int, track_id) @@ -602,6 +695,7 @@ class BaseServer(object): """Stop playback.""" self.current_index = -1 self.paused = False + self._send_event('player') def cmd_seek(self, conn, index, pos): """Seek to a specified point in a specified song.""" @@ -609,18 +703,13 @@ class BaseServer(object): if index < 0 or index >= len(self.playlist): raise ArgumentIndexError() self.current_index = index + self._send_event('player') def cmd_seekid(self, conn, track_id, pos): index = self._id_to_index(track_id) return self.cmd_seek(conn, index, pos) - # Debugging/testing commands that are not part of the MPD protocol. - - def cmd_profile(self, conn): - """Memory profiling for debugging.""" - from guppy import hpy - heap = hpy().heap() - print(heap) + # Additions to the MPD protocol. def cmd_crash_TypeError(self, conn): # noqa: N802 """Deliberately trigger a TypeError for testing purposes. @@ -632,15 +721,22 @@ class BaseServer(object): class Connection(object): - """A connection between a client and the server. Handles input and - output from and to the client. + """A connection between a client and the server. """ def __init__(self, server, sock): """Create a new connection for the accepted socket `client`. """ self.server = server self.sock = sock - self.authenticated = False + self.address = u'{}:{}'.format(*sock.sock.getpeername()) + + def debug(self, message, kind=' '): + """Log a debug message about this connection. + """ + self.server._log.debug(u'{}[{}]: {}', kind, self.address, message) + + def run(self): + pass def send(self, lines): """Send lines, which which is either a single string or an @@ -651,13 +747,32 @@ class Connection(object): if isinstance(lines, six.string_types): lines = [lines] out = NEWLINE.join(lines) + NEWLINE - # Don't log trailing newline: - message = out[:-1].replace(u'\n', u'\n' + u' ' * 13) - self.server._log.debug('server: {}', message) + for l in out.split(NEWLINE)[:-1]: + self.debug(l, kind='>') if isinstance(out, six.text_type): out = out.encode('utf-8') return self.sock.sendall(out) + @classmethod + def handler(cls, server): + def _handle(sock): + """Creates a new `Connection` and runs it. + """ + return cls(server, sock).run() + return _handle + + +class MPDConnection(Connection): + """A connection that receives commands from an MPD-compatible client. + """ + def __init__(self, server, sock): + """Create a new connection for the accepted socket `client`. + """ + super(MPDConnection, self).__init__(server, sock) + self.authenticated = False + self.notifications = set() + self.idle_subscriptions = set() + def do_command(self, command): """A coroutine that runs the given command and sends an appropriate response.""" @@ -670,30 +785,72 @@ class Connection(object): # Send success code. yield self.send(RESP_OK) + def disconnect(self): + """The connection has closed for any reason. + """ + self.server.disconnect(self) + self.debug('disconnected', kind='*') + + def notify(self, event): + """Queue up an event for sending to this client. + """ + self.notifications.add(event) + + def send_notifications(self, force_close_idle=False): + """Send the client any queued events now. + """ + pending = self.notifications.intersection(self.idle_subscriptions) + try: + for event in pending: + yield self.send(u'changed: {}'.format(event)) + if pending or force_close_idle: + self.idle_subscriptions = set() + self.notifications = self.notifications.difference(pending) + yield self.send(RESP_OK) + except bluelet.SocketClosedError: + self.disconnect() # Client disappeared. + def run(self): """Send a greeting to the client and begin processing commands as they arrive. """ - self.server._log.debug('New client connected') + self.debug('connected', kind='*') + self.server.connect(self) yield self.send(HELLO) clist = None # Initially, no command list is being constructed. while True: line = yield self.sock.readline() if not line: + self.disconnect() # Client disappeared. break line = line.strip() if not line: + err = BPDError(ERROR_UNKNOWN, u'No command given') + yield self.send(err.response()) + self.disconnect() # Client sent a blank line. break line = line.decode('utf8') # MPD protocol uses UTF-8. - message = line.replace(u'\n', u'\n' + u' ' * 13) - self.server._log.debug(u'client: {}', message) + for l in line.split(NEWLINE): + self.debug(l, kind='<') + + if self.idle_subscriptions: + # The connection is in idle mode. + if line == u'noidle': + yield bluelet.call(self.send_notifications(True)) + else: + err = BPDError(ERROR_UNKNOWN, + u'Got command while idle: {}'.format(line)) + yield self.send(err.response()) + break + continue if clist is not None: # Command list already opened. if line == CLIST_END: yield bluelet.call(self.do_command(clist)) clist = None # Clear the command list. + yield bluelet.call(self.server.dispatch_events()) else: clist.append(Command(line)) @@ -708,15 +865,71 @@ class Connection(object): except BPDClose: # Command indicates that the conn should close. self.sock.close() + self.disconnect() # Client explicitly closed. return + except BPDIdle as e: + self.idle_subscriptions = e.subsystems + self.debug('awaiting: {}'.format(' '.join(e.subsystems)), + kind='z') + yield bluelet.call(self.server.dispatch_events()) - @classmethod - def handler(cls, server): - def _handle(sock): - """Creates a new `Connection` and runs it. - """ - return cls(server, sock).run() - return _handle + +class ControlConnection(Connection): + """A connection used to control BPD for debugging and internal events. + """ + def __init__(self, server, sock): + """Create a new connection for the accepted socket `client`. + """ + super(ControlConnection, self).__init__(server, sock) + + def debug(self, message, kind=' '): + self.server._log.debug(u'CTRL {}[{}]: {}', kind, self.address, message) + + def run(self): + """Listen for control commands and delegate to `ctrl_*` methods. + """ + self.debug('connected', kind='*') + while True: + line = yield self.sock.readline() + if not line: + break # Client disappeared. + line = line.strip() + if not line: + break # Client sent a blank line. + line = line.decode('utf8') # Protocol uses UTF-8. + for l in line.split(NEWLINE): + self.debug(l, kind='<') + command = Command(line) + try: + func = command.delegate('ctrl_', self) + yield bluelet.call(func(*command.args)) + except (AttributeError, TypeError) as e: + yield self.send('ERROR: {}'.format(e.args[0])) + except Exception: + yield self.send(['ERROR: server error', + traceback.format_exc().rstrip()]) + + def ctrl_play_finished(self): + """Callback from the player signalling a song finished playing. + """ + yield bluelet.call(self.server.dispatch_events()) + + def ctrl_profile(self): + """Memory profiling for debugging. + """ + from guppy import hpy + heap = hpy().heap() + yield self.send(heap) + + def ctrl_nickname(self, oldlabel, newlabel): + """Rename a client in the log messages. + """ + for c in self.server.connections: + if c.address == oldlabel: + c.address = newlabel + break + else: + yield self.send(u'ERROR: no such client: {}'.format(oldlabel)) class Command(object): @@ -745,16 +958,17 @@ class Command(object): arg = match[1] self.args.append(arg) - def run(self, conn): - """A coroutine that executes the command on the given - connection. + def delegate(self, prefix, target, extra_args=0): + """Get the target method that corresponds to this command. + The `prefix` is prepended to the command name and then the resulting + name is used to search `target` for a method with a compatible number + of arguments. """ # Attempt to get correct command function. - func_name = 'cmd_' + self.name - if not hasattr(conn.server, func_name): - raise BPDError(ERROR_UNKNOWN, - u'unknown command "{}"'.format(self.name)) - func = getattr(conn.server, func_name) + func_name = prefix + self.name + if not hasattr(target, func_name): + raise AttributeError(u'unknown command "{}"'.format(self.name)) + func = getattr(target, func_name) if six.PY2: # caution: the fields of the namedtuple are slightly different @@ -765,19 +979,31 @@ class Command(object): # Check that `func` is able to handle the number of arguments sent # by the client (so we can raise ERROR_ARG instead of ERROR_SYSTEM). - # Maximum accepted arguments: argspec includes "self" and "conn". - max_args = len(argspec.args) - 2 - # Minimum accepted arguments: some arguments might be optional/ + # Maximum accepted arguments: argspec includes "self". + max_args = len(argspec.args) - 1 - extra_args + # Minimum accepted arguments: some arguments might be optional. min_args = max_args if argspec.defaults: min_args -= len(argspec.defaults) wrong_num = (len(self.args) > max_args) or (len(self.args) < min_args) # If the command accepts a variable number of arguments skip the check. if wrong_num and not argspec.varargs: - raise BPDError(ERROR_ARG, - u'wrong number of arguments for "{}"' - .format(self.name), - self.name) + raise TypeError(u'wrong number of arguments for "{}"' + .format(self.name), self.name) + + return func + + def run(self, conn): + """A coroutine that executes the command on the given + connection. + """ + try: + # `conn` is an extra argument to all cmd handlers. + func = self.delegate('cmd_', conn.server, extra_args=1) + except AttributeError as e: + raise BPDError(ERROR_UNKNOWN, e.args[0]) + except TypeError as e: + raise BPDError(ERROR_ARG, e.args[0], self.name) # Ensure we have permission for this command. if conn.server.password and \ @@ -803,6 +1029,9 @@ class Command(object): # it on the Connection. raise + except BPDIdle: + raise + except Exception: # An "unintentional" error. Hide it from the client. conn.server._log.error('{}', traceback.format_exc()) @@ -849,7 +1078,7 @@ class Server(BaseServer): to store its library. """ - def __init__(self, library, host, port, password, log): + def __init__(self, library, host, port, password, ctrl_port, log): try: from beetsplug.bpd import gstplayer except ImportError as e: @@ -859,7 +1088,7 @@ class Server(BaseServer): else: raise log.info(u'Starting server...') - super(Server, self).__init__(host, port, password, log) + super(Server, self).__init__(host, port, password, ctrl_port, log) self.lib = library self.player = gstplayer.GstPlayer(self.play_finished) self.cmd_update(None) @@ -871,10 +1100,10 @@ class Server(BaseServer): super(Server, self).run() def play_finished(self): - """A callback invoked every time our player finishes a - track. + """A callback invoked every time our player finishes a track. """ self.cmd_next(None) + self._ctrl_send(u'play_finished') # Metadata helper functions. @@ -920,6 +1149,8 @@ class Server(BaseServer): self.tree = vfs.libtree(self.lib) self._log.debug(u'Finished building directory tree.') self.updated_time = time.time() + self._send_event('update') + self._send_event('database') # Path (directory tree) browsing. @@ -1029,6 +1260,7 @@ class Server(BaseServer): if send_id: yield u'Id: ' + six.text_type(item.id) self.playlist_version += 1 + self._send_event('playlist') def cmd_add(self, conn, path): """Adds a track or directory to the playlist, specified by a @@ -1309,15 +1541,16 @@ class BPDPlugin(BeetsPlugin): self.config.add({ 'host': u'', 'port': 6600, + 'control_port': 6601, 'password': u'', 'volume': VOLUME_MAX, }) self.config['password'].redact = True - def start_bpd(self, lib, host, port, password, volume): + def start_bpd(self, lib, host, port, password, volume, ctrl_port): """Starts a BPD server.""" try: - server = Server(lib, host, port, password, self._log) + server = Server(lib, host, port, password, ctrl_port, self._log) server.cmd_setvol(None, volume) server.run() except NoGstreamerError: @@ -1334,11 +1567,16 @@ class BPDPlugin(BeetsPlugin): host = self.config['host'].as_str() host = args.pop(0) if args else host port = args.pop(0) if args else self.config['port'].get(int) + if args: + ctrl_port = args.pop(0) + else: + ctrl_port = self.config['control_port'].get(int) if args: raise beets.ui.UserError(u'too many arguments') password = self.config['password'].as_str() volume = self.config['volume'].get(int) - self.start_bpd(lib, host, int(port), password, volume) + self.start_bpd(lib, host, int(port), password, volume, + int(ctrl_port)) cmd.func = func return [cmd] diff --git a/docs/changelog.rst b/docs/changelog.rst index 43b6b20f6..e41d71bd2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -86,6 +86,9 @@ New features: new fields for ``status``. The bpd server now understands and ignores some additional commands. :bug:`3200` :bug:`800` +* :doc:`/plugins/bpd`: MPD protocol command ``idle`` is now supported, allowing + the MPD version to be bumped to 0.14. + :bug:`3205` :bug:`800` Changes: diff --git a/docs/plugins/bpd.rst b/docs/plugins/bpd.rst index fc22846de..ee36c040c 100644 --- a/docs/plugins/bpd.rst +++ b/docs/plugins/bpd.rst @@ -75,6 +75,8 @@ The available options are: Default: No password. - **volume**: Initial volume, as a percentage. Default: 100 +- **control_port**: Port for the internal control socket. + Default: 6601 Here's an example:: @@ -95,40 +97,42 @@ on-disk directory structure can. (Note that an obvious solution to this is just string matching on items' destination, but this requires examining the entire library Python-side for every query.) -We don't currently support versioned playlists. Many clients, however, use -plchanges instead of playlistinfo to get the current playlist, so plchanges -contains a dummy implementation that just calls playlistinfo. +BPD plays music using GStreamer's ``playbin`` player, which has a simple API +but doesn't support many advanced playback features. -The ``stats`` command always send zero for ``playtime``, which is supposed to -indicate the amount of time the server has spent playing music. BPD doesn't -currently keep track of this. +Differences from the real MPD +----------------------------- -The ``update`` command regenerates the directory tree from the beets database. - -Unimplemented Commands ----------------------- - -These are the commands from `the MPD protocol`_ that have not yet been -implemented in BPD. +BPD currently supports version 0.14 of `the MPD protocol`_, but several of the +commands and features are "pretend" implementations or have slightly different +behaviour to their MPD equivalents. BPD aims to look enough like MPD that it +can interact with the ecosystem of clients, but doesn't try to be +a fully-fledged MPD replacement in terms of its playback capabilities. .. _the MPD protocol: http://www.musicpd.org/doc/protocol/ -Saved playlists: +These are some of the known differences between BPD and MPD: -* playlistclear -* playlistdelete -* playlistmove -* playlistadd -* playlistsearch -* listplaylist -* listplaylistinfo -* playlistfind -* rm -* save -* load -* rename - -Deprecated: - -* playlist -* volume +* BPD doesn't currently support versioned playlists. Many clients, however, use + plchanges instead of playlistinfo to get the current playlist, so plchanges + contains a dummy implementation that just calls playlistinfo. +* Stored playlists aren't supported (BPD understands the commands though). +* The ``stats`` command always send zero for ``playtime``, which is supposed to + indicate the amount of time the server has spent playing music. BPD doesn't + currently keep track of this. +* The ``update`` command regenerates the directory tree from the beets database + synchronously, whereas MPD does this in the background. +* Advanced playback features like cross-fade, ReplayGain and MixRamp are not + supported due to BPD's simple audio player backend. +* Advanced query syntax is not currently supported. +* Not all tags (fields) are currently exposed to BPD. Clients also can't use + the ``tagtypes`` mask to hide fields. +* BPD's ``random`` mode is not deterministic and doesn't support priorities. +* Mounts and streams are not supported. BPD can only play files from disk. +* Stickers are not supported (although this is basically a flexattr in beets + nomenclature so this is feasible to add). +* There is only a single password, and is enabled it grants access to all + features rather than having permissions-based granularity. +* Partitions and alternative outputs are not supported; BPD can only play one + song at a time. +* Client channels are not implemented. diff --git a/test/test_player.py b/test/test_player.py index 98fd13f63..6cc0869a7 100644 --- a/test/test_player.py +++ b/test/test_player.py @@ -23,6 +23,7 @@ from test.helper import TestHelper import os import sys import multiprocessing as mp +import threading import socket import time import yaml @@ -37,18 +38,19 @@ from beetsplug import bpd import mock import imp gstplayer = imp.new_module("beetsplug.bpd.gstplayer") -def _gstplayer_play(_): # noqa: 42 +def _gstplayer_play(*_): # noqa: 42 bpd.gstplayer._GstPlayer.playing = True return mock.DEFAULT gstplayer._GstPlayer = mock.MagicMock( spec_set=[ "time", "volume", "playing", "run", "play_file", "pause", "stop", - "seek" + "seek", "play" ], **{ 'playing': False, 'volume': 0, 'time.return_value': (0, 0), 'play_file.side_effect': _gstplayer_play, + 'play.side_effect': _gstplayer_play, }) gstplayer.GstPlayer = lambda _: gstplayer._GstPlayer sys.modules["beetsplug.bpd.gstplayer"] = gstplayer @@ -259,7 +261,7 @@ class BPDTestHelper(unittest.TestCase, TestHelper): @contextmanager def run_bpd(self, host='localhost', port=9876, password=None, - do_hello=True): + do_hello=True, second_client=False): """ Runs BPD in another process, configured with the same library database as we created in the setUp method. Exposes a client that is connected to the server, and kills the server at the end. @@ -268,7 +270,7 @@ class BPDTestHelper(unittest.TestCase, TestHelper): config = { 'pluginpath': [py3_path(self.temp_dir)], 'plugins': 'bpd', - 'bpd': {'host': host, 'port': port}, + 'bpd': {'host': host, 'port': port, 'control_port': port + 1}, } if password: config['bpd']['password'] = password @@ -290,7 +292,7 @@ class BPDTestHelper(unittest.TestCase, TestHelper): server.start() # Wait until the socket is connected: - sock = None + sock, sock2 = None, None for _ in range(20): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) if sock.connect_ex((host, port)) == 0: @@ -302,9 +304,16 @@ class BPDTestHelper(unittest.TestCase, TestHelper): raise RuntimeError('Timed out waiting for the BPD server') try: - yield MPCClient(sock, do_hello) + if second_client: + sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock2.connect((host, port)) + yield MPCClient(sock, do_hello), MPCClient(sock2, do_hello) + else: + yield MPCClient(sock, do_hello) finally: sock.close() + if sock2: + sock2.close() server.terminate() server.join(timeout=0.2) @@ -345,7 +354,7 @@ class BPDTestHelper(unittest.TestCase, TestHelper): class BPDTest(BPDTestHelper): def test_server_hello(self): with self.run_bpd(do_hello=False) as client: - self.assertEqual(client.readline(), b'OK MPD 0.13.0\n') + self.assertEqual(client.readline(), b'OK MPD 0.14.0\n') def test_unknown_cmd(self): with self.run_bpd() as client: @@ -367,11 +376,16 @@ class BPDTest(BPDTestHelper): response = client.send_command('crash_TypeError') self._assert_failed(response, bpd.ERROR_SYSTEM) + def test_empty_request(self): + with self.run_bpd() as client: + response = client.send_command('') + self._assert_failed(response, bpd.ERROR_UNKNOWN) + class BPDQueryTest(BPDTestHelper): test_implements_query = implements({ - 'clearerror', 'currentsong', 'idle', 'stats', - }, expectedFailure=True) + 'clearerror', 'currentsong', 'stats', + }) def test_cmd_status(self): with self.run_bpd() as client: @@ -384,7 +398,7 @@ class BPDQueryTest(BPDTestHelper): fields_not_playing = { 'repeat', 'random', 'single', 'consume', 'playlist', 'playlistlength', 'mixrampdb', 'state', - 'volume' # not (always?) returned by MPD + 'volume' } self.assertEqual(fields_not_playing, set(responses[0].data.keys())) fields_playing = fields_not_playing | { @@ -392,6 +406,41 @@ class BPDQueryTest(BPDTestHelper): } self.assertEqual(fields_playing, set(responses[2].data.keys())) + def test_cmd_idle(self): + def _toggle(c): + for _ in range(3): + rs = c.send_commands(('play',), ('pause',)) + # time.sleep(0.05) # uncomment if test is flaky + if any(not r.ok for r in rs): + raise RuntimeError('Toggler failed') + with self.run_bpd(second_client=True) as (client, client2): + self._bpd_add(client, self.item1, self.item2) + toggler = threading.Thread(target=_toggle, args=(client2,)) + toggler.start() + # Idling will hang until the toggler thread changes the play state. + # Since the client sockets have a 1s timeout set at worst this will + # raise a socket.timeout and fail the test if the toggler thread + # manages to finish before the idle command is sent here. + response = client.send_command('idle', 'player') + toggler.join() + self._assert_ok(response) + + def test_cmd_idle_with_pending(self): + with self.run_bpd(second_client=True) as (client, client2): + response1 = client.send_command('random', '1') + response2 = client2.send_command('idle') + self._assert_ok(response1, response2) + self.assertEqual('options', response2.data['changed']) + + def test_cmd_noidle(self): + with self.run_bpd() as client: + # Manually send a command without reading a response. + request = client.serialise_command('idle') + client.sock.sendall(request) + time.sleep(0.01) + response = client.send_command('noidle') + self._assert_ok(response) + class BPDPlaybackTest(BPDTestHelper): test_implements_playback = implements({