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 math
import inspect
import socket
import beets
from beets.plugins import BeetsPlugin
@ -72,6 +73,10 @@ SAFE_COMMANDS = (
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())
@ -147,6 +152,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.
@ -211,6 +226,11 @@ class BaseServer(object):
bluelet.run(bluelet.server(self.host, self.port,
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):
"""An abstract method that should response lines containing a
single song's metadata.
@ -271,6 +291,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 to sleep
def cmd_kill(self, conn):
"""Exits the server process."""
exit(0)
@ -657,6 +685,7 @@ class Connection(object):
self.sock = sock
self.authenticated = False
self.address = u'{}:{}'.format(*sock.sock.getpeername())
self.notifications = set()
def send(self, lines):
"""Send lines, which which is either a single string or an
@ -692,6 +721,50 @@ class Connection(object):
self.server.disconnect(self)
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):
"""Send a greeting to the client and begin processing commands
as they arrive.
@ -738,6 +811,8 @@ class Connection(object):
self.sock.close()
self.disconnect() # Client explicitly closed.
return
except BPDIdle as e:
yield bluelet.call(self.poll_notifications(e.subsystems))
@classmethod
def handler(cls, server):
@ -832,6 +907,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())