mpd: connect to MPD asynchronously.
[nephilim.git] / nephilim / mpd.py
bloba9671c32e414ec55f99e9e52d0394e1580457a08
1 # Python MPD client library
2 # Copyright (C) 2008 J. Alexander Treuman <jat@spatialrift.net>
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
17 import socket
18 import logging
19 from PyQt4 import QtCore, QtNetwork
22 HELLO_PREFIX = "OK MPD "
23 ERROR_PREFIX = "ACK "
24 SUCCESS = "OK"
25 NEXT = "list_OK"
28 class MPDError(Exception):
29 pass
31 class ConnectionError(MPDError):
32 pass
34 class ProtocolError(MPDError):
35 pass
37 class CommandError(MPDError):
38 pass
40 class CommandListError(MPDError):
41 pass
44 class _NotConnected(object):
45 def __getattr__(self, attr):
46 return self._dummy
48 def _dummy(*args):
49 raise ConnectionError("Not connected")
51 class MPDClient(QtCore.QObject):
52 # public
53 logger = None
54 mpd_version = None
56 # private
57 __sock = None
58 _commandlist = None
60 # SIGNALS
61 connect_changed = QtCore.pyqtSignal(bool)
62 def __init__(self):
63 QtCore.QObject.__init__(self)
64 self.logger = logging.getLogger('mpclient.mpdsocket')
65 self._commands = {
66 # Admin Commands
67 "disableoutput": self._getnone,
68 "enableoutput": self._getnone,
69 "kill": None,
70 "update": self._getitem,
71 # Informational Commands
72 "status": self._getobject,
73 "stats": self._getobject,
74 "outputs": self._getoutputs,
75 "commands": self._getlist,
76 "notcommands": self._getlist,
77 "tagtypes": self._getlist,
78 "urlhandlers": self._getlist,
79 # Database Commands
80 "find": self._getsongs,
81 "findadd": self._getnone,
82 "list": self._getlist,
83 "listall": self._getdatabase,
84 "listallinfo": self._getdatabase,
85 "lsinfo": self._getdatabase,
86 "search": self._getsongs,
87 "count": self._getobject,
88 # Playlist Commands
89 "add": self._getnone,
90 "addid": self._getitem,
91 "clear": self._getnone,
92 "currentsong": self._getobject,
93 "delete": self._getnone,
94 "deleteid": self._getnone,
95 "load": self._getnone,
96 "rename": self._getnone,
97 "move": self._getnone,
98 "moveid": self._getnone,
99 "playlist": self._getplaylist,
100 "playlistinfo": self._getsongs,
101 "playlistid": self._getsongs,
102 "plchanges": self._getsongs,
103 "plchangesposid": self._getchanges,
104 "rm": self._getnone,
105 "save": self._getnone,
106 "shuffle": self._getnone,
107 "swap": self._getnone,
108 "swapid": self._getnone,
109 "listplaylist": self._getlist,
110 "listplaylistinfo": self._getsongs,
111 "playlistadd": self._getnone,
112 "playlistclear": self._getnone,
113 "playlistdelete": self._getnone,
114 "playlistmove": self._getnone,
115 "playlistfind": self._getsongs,
116 "playlistsearch": self._getsongs,
117 # Playback Commands
118 "consume": self._getnone,
119 "crossfade": self._getnone,
120 "next": self._getnone,
121 "pause": self._getnone,
122 "play": self._getnone,
123 "playid": self._getnone,
124 "previous": self._getnone,
125 "random": self._getnone,
126 "repeat": self._getnone,
127 "seek": self._getnone,
128 "seekid": self._getnone,
129 "setvol": self._getnone,
130 "single": self._getnone,
131 "stop": self._getnone,
132 "volume": self._getnone,
133 # Miscellaneous Commands
134 "clearerror": self._getnone,
135 "close": None,
136 "password": self._getnone,
137 "ping": self._getnone,
140 def __getattr__(self, attr):
141 try:
142 retval = self._commands[attr]
143 except KeyError:
144 raise AttributeError("'%s' object has no attribute '%s'" %
145 (self.__class__.__name__, attr))
146 return lambda *args: self._docommand(attr, args, retval)
148 def _docommand(self, command, args, retval):
149 if not self.__sock:
150 self.logger.error('Cannot send command: not connected.')
151 return None
152 if self._commandlist is not None and not callable(retval):
153 raise CommandListError("%s not allowed in command list" % command)
155 self._writecommand(command, args)
157 if self._commandlist is None:
158 if callable(retval):
159 return retval()
160 return retval
161 self._commandlist.append(retval)
163 def _writecommand(self, command, args=[]):
164 parts = [command]
165 for arg in args:
166 parts.append('"%s"' % escape(unicode(arg)))
167 self.__sock.write(' '.join(parts).encode('utf-8') + '\n')
168 self.__sock.waitForBytesWritten()
170 def _readline(self):
171 while not self.__sock.canReadLine():
172 self.__sock.waitForReadyRead()
173 line = str(self.__sock.readLine()).decode('utf-8')
174 line = line.rstrip("\n")
175 if line.startswith(ERROR_PREFIX):
176 error = line[len(ERROR_PREFIX):].strip()
177 raise CommandError(error)
178 if self._commandlist is not None:
179 if line == NEXT:
180 return
181 if line == SUCCESS:
182 raise ProtocolError("Got unexpected '%s'" % SUCCESS)
183 elif line == SUCCESS:
184 return
185 return line
187 def _readitem(self, separator):
188 line = self._readline()
189 if line is None:
190 return
191 item = line.split(separator, 1)
192 if len(item) < 2:
193 raise ProtocolError("Could not parse item: '%s'" % line)
194 return item
196 def _readitems(self, separator=": "):
197 item = self._readitem(separator)
198 while item:
199 yield item
200 item = self._readitem(separator)
201 raise StopIteration
203 def _readlist(self):
204 seen = None
205 for key, value in self._readitems():
206 if key != seen:
207 if seen is not None:
208 raise ProtocolError("Expected key '%s', got '%s'" %
209 (seen, key))
210 seen = key
211 yield value
212 raise StopIteration
214 def _readplaylist(self):
215 for key, value in self._readitems(":"):
216 yield value
217 raise StopIteration
219 def _readobjects(self, delimiters=[]):
220 obj = {}
221 for key, value in self._readitems():
222 key = key.lower()
223 if obj:
224 if key in delimiters:
225 yield obj
226 obj = {}
227 elif obj.has_key(key):
228 if not isinstance(obj[key], list):
229 obj[key] = [obj[key], value]
230 else:
231 obj[key].append(value)
232 continue
233 obj[key] = value
234 if obj:
235 yield obj
236 raise StopIteration
238 def _readcommandlist(self):
239 for retval in self._commandlist:
240 yield retval()
241 self._commandlist = None
242 self._getnone()
243 raise StopIteration
245 def _getnone(self):
246 line = self._readline()
247 if line is not None:
248 raise ProtocolError("Got unexpected return value: '%s'" % line)
250 def _getitem(self):
251 items = list(self._readitems())
252 if len(items) != 1:
253 return
254 return items[0][1]
256 def _getlist(self):
257 return self._readlist()
259 def _getplaylist(self):
260 return self._readplaylist()
262 def _getobject(self):
263 objs = list(self._readobjects())
264 if not objs:
265 return {}
266 return objs[0]
268 def _getobjects(self, delimiters):
269 return self._readobjects(delimiters)
271 def _getsongs(self):
272 return self._getobjects(["file"])
274 def _getdatabase(self):
275 return self._getobjects(["file", "directory", "playlist"])
277 def _getoutputs(self):
278 return self._getobjects(["outputid"])
280 def _getchanges(self):
281 return self._getobjects(["cpos"])
283 def _getcommandlist(self):
284 try:
285 return self._readcommandlist()
286 except CommandError:
287 self._commandlist = None
288 raise
290 def __handle_error(self, error):
291 self.logger.error(self.__sock.errorString())
292 self.disconnect_mpd()
294 def __finish_connect(self):
295 # read MPD hello
296 while not self.__sock.canReadLine():
297 self.__sock.waitForReadyRead()
298 line = str(self.__sock.readLine())
299 if not line.startswith(HELLO_PREFIX):
300 self.logger.error('Got invalid MPD hello: %s' % line)
301 self.disconnect_mpd()
302 return
303 self.mpd_version = line[len(HELLO_PREFIX):].strip()
305 self.connect_changed.emit(True)
307 def connect_mpd(self, host, port):
308 if self.__sock:
309 return self.logger.error('Already connected.')
311 if not port:
312 #assume Unix domain socket
313 self.__sock = QtNetwork.QLocalSocket(self)
314 c = lambda host, port: self.__sock.connectToServer(host)
315 else:
316 self.__sock = QtNetwork.QTcpSocket(self)
317 c = self.__sock.connectToHost
319 self.__sock.error.connect( self.__handle_error)
320 self.__sock.connected.connect(self.__finish_connect)
321 c(host, port)
323 def disconnect_mpd(self):
324 if self.__sock:
325 try:
326 self.__sock.disconnectFromHost()
327 except AttributeError:
328 self.__sock.disconnectFromServer()
330 if self.__sock.state() != QtNetwork.QAbstractSocket.UnconnectedState\
331 and not self.__sock.waitForDisconnected(5000):
332 self.__sock.abort()
334 self.__sock = None
336 self.mpd_version = None
337 self._commandlist = None
338 self.connect_changed.emit(False)
340 def command_list_ok_begin(self):
341 if self._commandlist is not None:
342 raise CommandListError("Already in command list")
343 self._writecommand("command_list_ok_begin")
344 self._commandlist = []
346 def command_list_end(self):
347 if self._commandlist is None:
348 raise CommandListError("Not in command list")
349 self._writecommand("command_list_end")
350 return self._getcommandlist()
353 def escape(text):
354 return text.replace("\\", "\\\\").replace('"', '\\"')
357 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: