-#! /usr/bin/env python
-
-# TODO: return {} if no object read (?)
-# TODO: implement argument checking/parsing (?)
-# TODO: check for EOF when reading and benchmark it
-# TODO: command_list support
-# TODO: converter support
-# TODO: global for parsing MPD_HOST/MPD_PORT
-# TODO: global for parsing MPD error messages
-# TODO: IPv6 support (AF_INET6)
+# python-mpd: Python MPD client library
+# Copyright (C) 2008-2010 J. Alexander Treuman <jat@spatialrift.net>
+# Copyright (C) 2012 Kaliko Jack <kaliko@azylum.org>
+#
+# python-mpd is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# python-mpd is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with python-mpd. If not, see <http://www.gnu.org/licenses/>.
import socket
HELLO_PREFIX = "OK MPD "
ERROR_PREFIX = "ACK "
SUCCESS = "OK"
+NEXT = "list_OK"
class MPDError(Exception):
pass
+class ConnectionError(MPDError):
+ pass
+
class ProtocolError(MPDError):
pass
class CommandError(MPDError):
pass
+class CommandListError(MPDError):
+ pass
+
+class PendingCommandError(MPDError):
+ pass
+
+class IteratingError(MPDError):
+ pass
+
+
+class _NotConnected(object):
+ def __getattr__(self, attr):
+ return self._dummy
+
+ def _dummy(*args):
+ raise ConnectionError("Not connected")
class MPDClient(object):
def __init__(self):
self.iterate = False
self._reset()
self._commands = {
- # Admin Commands
- "disableoutput": self._getnone,
- "enableoutput": self._getnone,
- "kill": None,
- "update": self._getitem,
- # Informational Commands
- "status": self._getobject,
- "stats": self._getobject,
- "outputs": self._getoutputs,
- "commands": self._getlist,
- "notcommands": self._getlist,
- "tagtypes": self._getlist,
- "urlhandlers": self._getlist,
- # Database Commands
- "find": self._getsongs,
- "list": self._getlist,
- "listall": self._getdatabase,
- "listallinfo": self._getdatabase,
- "lsinfo": self._getdatabase,
- "search": self._getsongs,
- "count": self._getobject,
+ # Status Commands
+ "clearerror": self._fetch_nothing,
+ "currentsong": self._fetch_object,
+ "idle": self._fetch_list,
+ "noidle": None,
+ "status": self._fetch_object,
+ "stats": self._fetch_object,
+ # Playback Option Commands
+ "consume": self._fetch_nothing,
+ "crossfade": self._fetch_nothing,
+ "mixrampdb": self._fetch_nothing,
+ "mixrampdelay": self._fetch_nothing,
+ "random": self._fetch_nothing,
+ "repeat": self._fetch_nothing,
+ "setvol": self._fetch_nothing,
+ "single": self._fetch_nothing,
+ "replay_gain_mode": self._fetch_nothing,
+ "replay_gain_status": self._fetch_item,
+ "volume": self._fetch_nothing,
+ # Playback Control Commands
+ "next": self._fetch_nothing,
+ "pause": self._fetch_nothing,
+ "play": self._fetch_nothing,
+ "playid": self._fetch_nothing,
+ "previous": self._fetch_nothing,
+ "seek": self._fetch_nothing,
+ "seekid": self._fetch_nothing,
+ "seekcur": self._fetch_nothing,
+ "stop": self._fetch_nothing,
# Playlist Commands
- "add": self._getnone,
- "addid": self._getitem,
- "clear": self._getnone,
- "currentsong": self._getobject,
- "delete": self._getnone,
- "deleteid": self._getnone,
- "load": self._getnone,
- "rename": self._getnone,
- "move": self._getnone,
- "moveid": self._getnone,
- "playlist": self._getplaylist,
- "playlistinfo": self._getsongs,
- "playlistid": self._getsongs,
- "plchanges": self._getsongs,
- "plchangesposid": self._getchanges,
- "rm": self._getnone,
- "save": self._getnone,
- "shuffle": self._getnone,
- "swap": self._getnone,
- "swapid": self._getnone,
- "listplaylist": self._getlist,
- "listplaylistinfo": self._getsongs,
- "playlistadd": self._getnone,
- "playlistclear": self._getnone,
- "playlistdelete": self._getnone,
- "playlistmove": self._getnone,
- "playlistfind": self._getsongs,
- "playlistsearch": self._getsongs,
- # Playback Commands
- "crossfade": self._getnone,
- "next": self._getnone,
- "pause": self._getnone,
- "play": self._getnone,
- "playid": self._getnone,
- "previous": self._getnone,
- "random": self._getnone,
- "repeat": self._getnone,
- "seek": self._getnone,
- "seekid": self._getnone,
- "setvol": self._getnone,
- "stop": self._getnone,
- "volume": self._getnone,
- # Miscellaneous Commands
- "clearerror": self._getnone,
- "close": None,
- "password": self._getnone,
- "ping": self._getnone,
+ "add": self._fetch_nothing,
+ "addid": self._fetch_item,
+ "clear": self._fetch_nothing,
+ "delete": self._fetch_nothing,
+ "deleteid": self._fetch_nothing,
+ "move": self._fetch_nothing,
+ "moveid": self._fetch_nothing,
+ "playlist": self._fetch_playlist,
+ "playlistfind": self._fetch_songs,
+ "playlistid": self._fetch_songs,
+ "playlistinfo": self._fetch_songs,
+ "playlistsearch": self._fetch_songs,
+ "plchanges": self._fetch_songs,
+ "plchangesposid": self._fetch_changes,
+ "shuffle": self._fetch_nothing,
+ "swap": self._fetch_nothing,
+ "swapid": self._fetch_nothing,
+ # Stored Playlist Commands
+ "listplaylist": self._fetch_list,
+ "listplaylistinfo": self._fetch_songs,
+ "listplaylists": self._fetch_playlists,
+ "load": self._fetch_nothing,
+ "playlistadd": self._fetch_nothing,
+ "playlistclear": self._fetch_nothing,
+ "playlistdelete": self._fetch_nothing,
+ "playlistmove": self._fetch_nothing,
+ "rename": self._fetch_nothing,
+ "rm": self._fetch_nothing,
+ "save": self._fetch_nothing,
+ # Database Commands
+ "count": self._fetch_object,
+ "find": self._fetch_songs,
+ "findadd": self._fetch_nothing,
+ "list": self._fetch_list,
+ "listall": self._fetch_database,
+ "listallinfo": self._fetch_database,
+ "lsinfo": self._fetch_database,
+ "search": self._fetch_songs,
+ "searchadd": self._fetch_nothing,
+ "searchaddpl": self._fetch_nothing,
+ "update": self._fetch_item,
+ "rescan": self._fetch_item,
+ # Sticker Commands
+ "sticker get": self._fetch_item,
+ "sticker set": self._fetch_nothing,
+ "sticker delete": self._fetch_nothing,
+ "sticker list": self._fetch_list,
+ "sticker find": self._fetch_songs,
+ # Connection Commands
+ "close": None,
+ "kill": None,
+ "password": self._fetch_nothing,
+ "ping": self._fetch_nothing,
+ # Audio Output Commands
+ "disableoutput": self._fetch_nothing,
+ "enableoutput": self._fetch_nothing,
+ "outputs": self._fetch_outputs,
+ # Reflection Commands
+ "commands": self._fetch_list,
+ "notcommands": self._fetch_list,
+ "tagtypes": self._fetch_list,
+ "urlhandlers": self._fetch_list,
+ "decoders": self._fetch_plugins,
+ # Client to Client
+ "subscribe": self._fetch_nothing,
+ "unsubscribe": self._fetch_nothing,
+ "channels": self._fetch_list,
+ "readmessages": self._fetch_messages,
+ "sendmessage": self._fetch_nothing,
}
def __getattr__(self, attr):
- try:
- retval = self._commands[attr]
- except KeyError:
- raise AttributeError, "'%s' object has no attribute '%s'" % \
- (self.__class__.__name__, attr)
- return lambda *args: self._docommand(attr, args, retval)
-
- def _docommand(self, command, args, retval):
- self._writecommand(command, args)
+ if attr.startswith("send_"):
+ command = attr.replace("send_", "", 1)
+ wrapper = self._send
+ elif attr.startswith("fetch_"):
+ command = attr.replace("fetch_", "", 1)
+ wrapper = self._fetch
+ else:
+ command = attr
+ wrapper = self._execute
+ if command not in self._commands:
+ command = command.replace("_", " ")
+ if command not in self._commands:
+ raise AttributeError("'%s' object has no attribute '%s'" %
+ (self.__class__.__name__, attr))
+ return lambda *args: wrapper(command, args)
+
+ def _send(self, command, args):
+ if self._command_list is not None:
+ raise CommandListError("Cannot use send_%s in a command list" %
+ command.replace(" ", "_"))
+ self._write_command(command, args)
+ retval = self._commands[command]
+ if retval is not None:
+ self._pending.append(command)
+
+ def _fetch(self, command, args=None):
+ if self._command_list is not None:
+ raise CommandListError("Cannot use fetch_%s in a command list" %
+ command.replace(" ", "_"))
+ if self._iterating:
+ raise IteratingError("Cannot use fetch_%s while iterating" %
+ command.replace(" ", "_"))
+ if not self._pending:
+ raise PendingCommandError("No pending commands to fetch")
+ if self._pending[0] != command:
+ raise PendingCommandError("'%s' is not the currently "
+ "pending command" % command)
+ del self._pending[0]
+ retval = self._commands[command]
if callable(retval):
return retval()
return retval
- def _writeline(self, line):
- self._sockfile.write("%s\n" % line)
- self._sockfile.flush()
-
- def _writecommand(self, command, args):
+ def _execute(self, command, args):
+ if self._iterating:
+ raise IteratingError("Cannot execute '%s' while iterating" %
+ command)
+ if self._pending:
+ raise PendingCommandError("Cannot execute '%s' with "
+ "pending commands" % command)
+ retval = self._commands[command]
+ if self._command_list is not None:
+ if not callable(retval):
+ raise CommandListError("'%s' not allowed in command list" %
+ command)
+ self._write_command(command, args)
+ self._command_list.append(retval)
+ else:
+ self._write_command(command, args)
+ if callable(retval):
+ return retval()
+ return retval
+
+ def _write_line(self, line):
+ self._wfile.write("%s\n" % line)
+ self._wfile.flush()
+
+ def _write_command(self, command, args=[]):
parts = [command]
for arg in args:
parts.append('"%s"' % escape(str(arg)))
- self._writeline(" ".join(parts))
-
- def _readline(self):
- line = self._sockfile.readline().rstrip("\n")
+ self._write_line(" ".join(parts))
+
+ def _read_line(self):
+ line = self._rfile.readline()
+ if not line.endswith("\n"):
+ self.disconnect()
+ raise ConnectionError("Connection lost while reading line")
+ line = line.rstrip("\n")
if line.startswith(ERROR_PREFIX):
error = line[len(ERROR_PREFIX):].strip()
- raise CommandError, error
- if line == SUCCESS:
+ raise CommandError(error)
+ if self._command_list is not None:
+ if line == NEXT:
+ return
+ if line == SUCCESS:
+ raise ProtocolError("Got unexpected '%s'" % SUCCESS)
+ elif line == SUCCESS:
return
return line
- def _readitem(self, separator):
- line = self._readline()
+ def _read_pair(self, separator):
+ line = self._read_line()
if line is None:
return
- item = line.split(separator, 1)
- if len(item) < 2:
- raise ProtocolError, "Could not parse item: '%s'" % line
- return item
-
- def _readitems(self, separator=": "):
- item = self._readitem(separator)
- while item:
- yield item
- item = self._readitem(separator)
- raise StopIteration
-
- def _readlist(self):
+ pair = line.split(separator, 1)
+ if len(pair) < 2:
+ raise ProtocolError("Could not parse pair: '%s'" % line)
+ return pair
+
+ def _read_pairs(self, separator=": "):
+ pair = self._read_pair(separator)
+ while pair:
+ yield pair
+ pair = self._read_pair(separator)
+
+ def _read_list(self):
seen = None
- for key, value in self._readitems():
+ for key, value in self._read_pairs():
if key != seen:
if seen is not None:
- raise ProtocolError, "Expected key '%s', got '%s'" % \
- (seen, key)
+ raise ProtocolError("Expected key '%s', got '%s'" %
+ (seen, key))
seen = key
yield value
- raise StopIteration
- def _readplaylist(self):
- for key, value in self._readitems(":"):
+ def _read_playlist(self):
+ for key, value in self._read_pairs(":"):
yield value
- raise StopIteration
- def _readobjects(self, delimiters=[]):
+ def _read_objects(self, delimiters=[]):
obj = {}
- for key, value in self._readitems():
+ for key, value in self._read_pairs():
key = key.lower()
if obj:
if key in delimiters:
yield obj
obj = {}
- elif obj.has_key(key):
+ elif key in obj:
if not isinstance(obj[key], list):
obj[key] = [obj[key], value]
else:
obj[key] = value
if obj:
yield obj
- raise StopIteration
- def _wrapiterator(self, iterator):
+ def _read_command_list(self):
+ try:
+ for retval in self._command_list:
+ yield retval()
+ finally:
+ self._command_list = None
+ self._fetch_nothing()
+
+ def _iterator_wrapper(self, iterator):
+ try:
+ for item in iterator:
+ yield item
+ finally:
+ self._iterating = False
+
+ def _wrap_iterator(self, iterator):
if not self.iterate:
return list(iterator)
- return iterator
+ self._iterating = True
+ return self._iterator_wrapper(iterator)
- def _getnone(self):
- line = self._readline()
+ def _fetch_nothing(self):
+ line = self._read_line()
if line is not None:
- raise ProtocolError, "Got unexpected return value: '%s'" % line
+ raise ProtocolError("Got unexpected return value: '%s'" % line)
- def _getitem(self):
- items = list(self._readitems())
- if len(items) != 1:
- raise ProtocolError, "Expected 1 item, got %i" % len(items)
- return items[0][1]
+ def _fetch_item(self):
+ pairs = list(self._read_pairs())
+ if len(pairs) != 1:
+ return
+ return pairs[0][1]
- def _getlist(self):
- return self._wrapiterator(self._readlist())
+ def _fetch_list(self):
+ return self._wrap_iterator(self._read_list())
- def _getplaylist(self):
- return self._wrapiterator(self._readplaylist())
+ def _fetch_playlist(self):
+ return self._wrap_iterator(self._read_playlist())
- def _getobject(self):
- objs = list(self._readobjects())
+ def _fetch_object(self):
+ objs = list(self._read_objects())
if not objs:
- return
+ return {}
return objs[0]
- def _getobjects(self, delimiters):
- return self._wrapiterator(self._readobjects(delimiters))
+ def _fetch_objects(self, delimiters):
+ return self._wrap_iterator(self._read_objects(delimiters))
- def _getsongs(self):
- return self._getobjects(["file"])
+ def _fetch_changes(self):
+ return self._fetch_objects(["cpos"])
- def _getdatabase(self):
- return self._getobjects(["file", "directory", "playlist"])
+ def _fetch_songs(self):
+ return self._fetch_objects(["file"])
- def _getoutputs(self):
- return self._getobjects(["outputid"])
+ def _fetch_playlists(self):
+ return self._fetch_objects(["playlist"])
- def _getchanges(self):
- return self._getobjects(["cpos"])
+ def _fetch_database(self):
+ return self._fetch_objects(["file", "directory", "playlist"])
+
+ def _fetch_outputs(self):
+ return self._fetch_objects(["outputid"])
+
+ def _fetch_plugins(self):
+ return self._fetch_objects(["plugin"])
+
+ def _fetch_messages(self):
+ return self._fetch_objects(["channel"])
+
+ def _fetch_command_list(self):
+ return self._wrap_iterator(self._read_command_list())
def _hello(self):
- line = self._sockfile.readline().rstrip("\n")
+ line = self._rfile.readline()
+ if not line.endswith("\n"):
+ raise ConnectionError("Connection lost while reading MPD hello")
+ line = line.rstrip("\n")
if not line.startswith(HELLO_PREFIX):
- raise ProtocolError, "Got invalid MPD hello: '%s'" % line
+ raise ProtocolError("Got invalid MPD hello: '%s'" % line)
self.mpd_version = line[len(HELLO_PREFIX):].strip()
def _reset(self):
self.mpd_version = None
- self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- self._sockfile = self._sock.makefile("rb+")
+ self._iterating = False
+ self._pending = []
+ self._command_list = None
+ self._sock = None
+ self._rfile = _NotConnected()
+ self._wfile = _NotConnected()
+
+ def _connect_unix(self, path):
+ if not hasattr(socket, "AF_UNIX"):
+ raise ConnectionError("Unix domain sockets not supported "
+ "on this platform")
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ sock.connect(path)
+ return sock
+
+ def _connect_tcp(self, host, port):
+ try:
+ flags = socket.AI_ADDRCONFIG
+ except AttributeError:
+ flags = 0
+ err = None
+ for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC,
+ socket.SOCK_STREAM, socket.IPPROTO_TCP,
+ flags):
+ af, socktype, proto, canonname, sa = res
+ sock = None
+ try:
+ sock = socket.socket(af, socktype, proto)
+ sock.connect(sa)
+ return sock
+ except socket.error as socket_err:
+ err = socket_err
+ if sock is not None:
+ sock.close()
+ if err is not None:
+ raise ConnectionError(str(err))
+ else:
+ raise ConnectionError("getaddrinfo returns an empty list")
def connect(self, host, port):
- self.disconnect()
- self._sock.connect((host, port))
- self._hello()
+ if self._sock is not None:
+ raise ConnectionError("Already connected")
+ if host.startswith("/"):
+ self._sock = self._connect_unix(host)
+ else:
+ self._sock = self._connect_tcp(host, port)
+ self._rfile = self._sock.makefile("r", encoding='utf-8')
+ self._wfile = self._sock.makefile("w", encoding='utf-8')
+ try:
+ self._hello()
+ except:
+ self.disconnect()
+ raise
def disconnect(self):
- self._sockfile.close()
- self._sock.close()
+ if isinstance(self._rfile, socket._fileobject):
+ print('closing r socket')
+ self._rfile.close()
+ if isinstance(self._wfile, socket._fileobject):
+ print('closing w socket')
+ self._wfile.close()
+ if isinstance(self._sock, socket.socket):
+ print('closing socket')
+ self._sock.close()
self._reset()
+ def fileno(self):
+ if self._sock is None:
+ raise ConnectionError("Not connected")
+ return self._sock.fileno()
+
+ def command_list_ok_begin(self):
+ if self._command_list is not None:
+ raise CommandListError("Already in command list")
+ if self._iterating:
+ raise IteratingError("Cannot begin command list while iterating")
+ if self._pending:
+ raise PendingCommandError("Cannot begin command list "
+ "with pending commands")
+ self._write_command("command_list_ok_begin")
+ self._command_list = []
+
+ def command_list_end(self):
+ if self._command_list is None:
+ raise CommandListError("Not in command list")
+ if self._iterating:
+ raise IteratingError("Already iterating over a command list")
+ self._write_command("command_list_end")
+ return self._fetch_command_list()
+
def escape(text):
return text.replace("\\", "\\\\").replace('"', '\\"')