bpd: implement the idle command

Getting this command puts the connection into a special mode where it
awaits MPD events (like the player changing state or the playlist
changing due to other clients interacting with the server.

The MPD specification states that events should queue while a client is
connected, and when it issues the `idle` command any matching events
should be sent immediately if there are any, or as soon as they happen
otherwise.
This commit is contained in:
Carl Suster 2019-04-08 11:36:32 +10:00
parent ee0c31ba6a
commit 7105c800aa

View file

@ -27,6 +27,7 @@ import random
import time import time
import math import math
import inspect import inspect
import socket
import beets import beets
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
@ -72,6 +73,10 @@ SAFE_COMMANDS = (
u'close', u'commands', u'notcommands', u'password', u'ping', u'close', u'commands', u'notcommands', u'password', u'ping',
) )
# List of subsystems/events used by the `idle` command.
SUBSYSTEMS = [
]
ITEM_KEYS_WRITABLE = set(MediaFile.fields()).intersection(Item._fields.keys()) ITEM_KEYS_WRITABLE = set(MediaFile.fields()).intersection(Item._fields.keys())
@ -147,6 +152,16 @@ class BPDClose(Exception):
should be closed. 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. # Generic server infrastructure, implementing the basic protocol.
@ -211,6 +226,11 @@ class BaseServer(object):
bluelet.run(bluelet.server(self.host, self.port, bluelet.run(bluelet.server(self.host, self.port,
Connection.handler(self))) Connection.handler(self)))
def _send_event(self, event):
"""Notify subscribed connections of an event."""
for conn in self.connections:
conn.notify(event)
def _item_info(self, item): def _item_info(self, item):
"""An abstract method that should response lines containing a """An abstract method that should response lines containing a
single song's metadata. single song's metadata.
@ -271,6 +291,14 @@ class BaseServer(object):
"""Succeeds.""" """Succeeds."""
pass 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 to sleep
def cmd_kill(self, conn): def cmd_kill(self, conn):
"""Exits the server process.""" """Exits the server process."""
exit(0) exit(0)
@ -657,6 +685,7 @@ class Connection(object):
self.sock = sock self.sock = sock
self.authenticated = False self.authenticated = False
self.address = u'{}:{}'.format(*sock.sock.getpeername()) self.address = u'{}:{}'.format(*sock.sock.getpeername())
self.notifications = set()
def send(self, lines): def send(self, lines):
"""Send lines, which which is either a single string or an """Send lines, which which is either a single string or an
@ -692,6 +721,50 @@ class Connection(object):
self.server.disconnect(self) self.server.disconnect(self)
self.server._log.debug(u'*[{}]: disconnected', self.address) self.server._log.debug(u'*[{}]: disconnected', self.address)
def poll_notifications(self, subsystems):
"""Sleep until we have some notifications from the subsystems given.
In order to promptly detect if the client has disconnected while
idling, try reading a single byte from the socket. According to the MPD
protocol the client can send the special command `noidle` to cancel
idle mode, otherwise we're expecting either a timeout or a zero-byte
reply. When we have notifications, send them to the client.
"""
while True:
mpd_events = self.notifications.intersection(subsystems)
if mpd_events:
break
current_timeout = self.sock.sock.gettimeout()
try:
self.sock.sock.settimeout(0.01)
data = self.sock.sock.recv(1)
if data: # Client sent data when it was meant to by idle.
line = yield self.sock.readline()
command = (data + line).rstrip()
if command == b'noidle':
self.server._log.debug(
u'<[{}]: noidle'.format(self.address))
break
err = BPDError(
ERROR_UNKNOWN,
u'Got command while idle: {}'.format(
command.decode('utf-8')))
yield self.send(err.response())
return
else: # The socket has been closed.
return
except socket.timeout: # The socket is still alive.
pass
finally:
self.sock.sock.settimeout(current_timeout)
yield bluelet.sleep(0.02)
self.notifications = self.notifications.difference(subsystems)
for event in mpd_events:
yield self.send(u'changed: {}'.format(event))
yield self.send(RESP_OK)
def notify(self, event):
self.notifications.add(event)
def run(self): def run(self):
"""Send a greeting to the client and begin processing commands """Send a greeting to the client and begin processing commands
as they arrive. as they arrive.
@ -738,6 +811,8 @@ class Connection(object):
self.sock.close() self.sock.close()
self.disconnect() # Client explicitly closed. self.disconnect() # Client explicitly closed.
return return
except BPDIdle as e:
yield bluelet.call(self.poll_notifications(e.subsystems))
@classmethod @classmethod
def handler(cls, server): def handler(cls, server):
@ -832,6 +907,9 @@ class Command(object):
# it on the Connection. # it on the Connection.
raise raise
except BPDIdle:
raise
except Exception: except Exception:
# An "unintentional" error. Hide it from the client. # An "unintentional" error. Hide it from the client.
conn.server._log.error('{}', traceback.format_exc()) conn.server._log.error('{}', traceback.format_exc())