mirror of
https://github.com/beetbox/beets.git
synced 2026-01-07 00:15:20 +01:00
Merge pull request #3205 from arcresu/bpd-idle
bpd: support idle command and with it MPD 0.14
This commit is contained in:
commit
818f5bd07b
4 changed files with 385 additions and 91 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue