switch to the new MPD interaction layer
[nephilim.git] / nephilim / mpclient.py
blobb3cd0274229422807615af02280c575299a09fa7
2 # Copyright (C) 2010 Anton Khirnov <wyskas@gmail.com>
4 # Nephilim 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 # Nephilim 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 Nephilim. If not, see <http://www.gnu.org/licenses/>.
18 from PyQt4 import QtCore, QtNetwork
19 from PyQt4.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
20 import logging
22 from song import Song
23 from mpdsocket import MPDSocket
25 class AudioOutput(QtCore.QObject):
26 """This class represents an MPD audio output."""
28 #### PUBLIC ####
29 # constants
30 name = None
32 # read-only
33 state = None
35 # SIGNALS
36 state_changed = QtCore.pyqtSignal(bool)
38 #### public ####
39 def __init__(self, data, set_state, parent = None):
40 QtCore.QObject.__init__(self, parent)
42 self.name = data['outputname']
43 self.state = int(data['outputenabled'])
45 self.set_state = set_state
47 @Slot(bool)
48 def set_state(self, state):
49 pass
51 def update(self, data):
52 """
53 This is called by mpclient to inform about output state change.
54 """
55 if int(data['outputenabled']) != self.state:
56 self.state = not self.state
57 self.state_changed.emit(self.state)
59 class MPDStatus(dict):
60 _status = {'volume' : 0, 'repeat' : 0, 'single' : 0,
61 'consume' : 0, 'playlist' : '-1', 'playlistlength' : 0,
62 'state' : 'stop', 'song' : -1, 'songid' : '-1',
63 'nextsong' : -1, 'nextsongid' : '-1', 'time' : '0:0',
64 'elapsed' : .0, 'bitrate' : 0, 'xfade' : 0,
65 'mixrampdb' : .0, 'mixrampdelay' : .0, 'audio' : '0:0:0',
66 'updatings_db' : -1, 'error' : '', 'random' : 0 }
68 def __init__(self, data = {}):
69 dict.__init__(self, MPDStatus._status)
70 for key in data:
71 if key in self._status:
72 self[key] = type(self._status[key])(data[key])
73 else:
74 self[key] = data[key]
75 try:
76 self['time'] = map(int, self['time'].split(':'))
77 except ValueError:
78 self['time'] = [0, 0]
79 try:
80 self['audio'] = tuple(map(int, self['audio'].split(':')))
81 except ValueError:
82 self['audio'] = (0, 0, 0)
84 class MPClient(QtCore.QObject):
85 """
86 A high-level MPD interface. It is mostly asynchronous -- all responses from
87 MPD are read via callbacks. A callback may be None, in which case the data
88 is silently discarded. Callbacks that take iterators must ensure that the
89 iterator is exhausted.
90 """
92 #### PUBLIC ####
93 # these don't change while we are connected
94 """A list of AudioOutputs available."""
95 outputs = None
96 """A list of supported tags (valid indices for Song)."""
97 tagtypes = None
98 """A list of supported URL handlers."""
99 urlhandlers = None
101 # read-only
102 """An MPDStatus object representing current status."""
103 status = None
104 """A Song object representing current song."""
105 cur_song = None
107 # SIGNALS
108 connect_changed = Signal(bool)
109 db_updated = Signal()
110 time_changed = Signal(int)
111 song_changed = Signal(object)
112 state_changed = Signal(str)
113 volume_changed = Signal(int)
114 repeat_changed = Signal(bool)
115 random_changed = Signal(bool)
116 single_changed = Signal(bool)
117 consume_changed = Signal(bool)
118 playlist_changed = Signal()
121 #### PRIVATE ####
122 # const
123 _sup_ver = (0, 16, 0)
124 _logger = None
125 _timer = None
127 # these don't change while we are connected
128 _commands = None
129 _socket = None
130 _password = None
132 #### PUBLIC ####
133 def __init__(self, parent = None):
134 QtCore.QObject.__init__(self, parent)
135 self._logger = logging.getLogger('%smpclient'%(unicode(parent) + "." if parent else ""))
136 self._timer = QtCore.QTimer(self)
137 self._timer.setInterval(1000)
138 self._timer.timeout.connect(self._update_timer)
139 self._socket = MPDSocket(self)
140 self._commands = []
141 self.status = MPDStatus()
142 self.cur_song = Song()
144 self.outputs = []
145 self.urlhandlers = []
146 self.tagtypes = []
147 def __str__(self):
148 return self._logger.name
150 def connect_mpd(self, host = "localhost", port = 6600, password = None):
152 Connect to MPD at host:port optionally using a password. A Unix domain
153 socket is used if port is omitted.
155 self._logger.info('Connecting to MPD...')
157 if self.is_connected():
158 self._logger.warning('Already connected.')
160 self._socket.connect_changed.connect(lambda val: self._handle_connected() if val else self._handle_disconnected())
161 self._socket.connect_mpd(host, port)
162 self._password = password
164 def disconnect_mpd(self):
166 Disconnect from MPD.
168 self._logger.info('Disconnecting from MPD.')
169 if self.is_connected():
170 self._socket.write_command('close')
172 def is_connected(self):
174 Returns True if connected to MPD, False otherwise.
176 return self._socket.state() == QtNetwork.QAbstractSocket.ConnectedState
178 def playlist(self, callback):
180 Request current playlist from MPD. Callback will be called with an
181 iterator over Songs in current playlist as the argument.
183 self._command('playlistinfo', callback = lambda data: callback(self._parse_songs(data)))
184 def database(self, callback):
186 Request database information from MPD. Callback will be called with an
187 iterator over all Songs in the database as the argument.
189 self._command('listallinfo', callback = lambda data: callback(self._parse_songs(data)))
191 def find(self, callback, *args):
193 Request a search on MPD. Callback will be called with an iterator over
194 all found songs. For allowed values of args, see MPD protocol documentation.
196 self._command('find', args, callback = lambda data: callback(self._parse_songs(data)))
197 def find_sync(self, *args):
199 Search for songs on MPD synchronously. Returns an iterator over all
200 found songs. For allowed values of args, see MPD protocol documentation.
202 return self._command_sync('find', args, parse = lambda data: self._parse_songs(data))
203 def findadd(self, *args):
205 Request a search on MPD and add found songs to current playlist. Allowed values
206 of args are same as for find.
208 self._command('findadd', *args)
210 def get_plist_song(self, plid):
212 Get a song with a given playlist id synchronously.
214 return self._command_sync('playlistid', plid, parse = lambda data: Song(list(self._parse_objects(data, []))[0]))
215 def set_volume(self, volume):
217 Set MPD volume level.
219 volume = min(100, max(0, volume))
220 self._command('setvol', volume)
221 def repeat(self, val):
223 Enable/disable repeat.
225 val = '1' if val else '0'
226 self._command('repeat', val)
227 def random(self, val):
229 Enable/disable random.
231 val = '1' if val else '0'
232 self._command('random', val)
233 def consume(self, val):
235 Enable/disable consume.
237 val = '1' if val else '0'
238 self._command('consume', val)
239 def single(self, val):
241 Enable/disable single.
243 val = '1' if val else '0'
244 self._command('single', val)
245 def crossfade(self, time):
247 Set crossfade to specified time.
249 self._command('crossfade', val)
251 def play(self, id = None):
253 Start playback of song with a specified id. If no id is given, then
254 start on current song/beginning.
256 args = ['playid']
257 if id:
258 args.append(id)
259 self._command(*args)
260 def pause(self):
262 Pause playback.
264 self._command('pause', 1)
265 def resume(self):
267 Resume paused playback.
269 self._command('pause', 0)
270 def next(self):
272 Move on to next song.
274 self._command('next')
275 def previous(self):
277 Move back to previous song.
279 self._command('previous')
280 def stop(self):
282 Stop playback.
284 self._command('stop')
285 def seek(self, time):
287 Seek to specified time in current song.
289 self._command('seekid', self.status['songid'], time)
291 def delete(self, ids):
293 Delete songs with specified ids from playlist.
295 for id in ids:
296 self._command('deleteid', id)
297 def clear(self):
299 Clear current playlist.
301 self._command('clear')
302 def add(self, paths, pos = -1):
304 Add specified songs to specified position in current playlist.
306 # start playback of the first added song if MPD is stopped
307 if self.status['state'] == 'stop':
308 cb = lambda data: [self.play(sid) for sid in self._parse_list(data) ]
309 else:
310 cb = None
312 args = ['addid', '']
313 if pos >= 0:
314 args.append(pos)
315 for path in paths:
316 args[1] = path
317 if cb:
318 self._command(*args, callback = cb)
319 cb = None
320 else:
321 self._command(*args)
322 if pos >= 0:
323 args[2] += 1
324 def move(self, src, dst):
326 Move a song with given src id to position dst.
328 self._command('moveid', src, dst)
330 #### PRIVATE ####
332 ## connection functions ##
333 # these functions are called during connection process #
334 # XXX: maybe use a generator?
335 @Slot()
336 def _handle_connected(self):
338 Called when a connection is established. Send a password and
339 start getting locally stored values.
341 self._logger.debug('Connection established.')
343 # check if protocol version is supported
344 v = self._socket.version
345 if v[0] != self._sup_ver[0]:
346 self._logger.error('Server reported unsupported major protocol version %d, disconnecting.'%v[0])
347 return self.disconnect_mpd()
348 if v[1] < self._sup_ver[1]:
349 self._logger.warning('Server reported too low minor protocol version %d. Continuing, but things might break.'%v[1])
351 if self._password:
352 self._socket.write_command('password', self._password)
354 self._socket.write_command('commands', callback = self._parse_commands)
356 def _parse_commands(self, data):
358 Receive a list of available commands and update
359 the other locally stored values.
361 self._logger.debug('Receiving command list.')
362 self._commands = list(self._parse_list(data))
364 if not 'listallinfo' in self._commands:
365 self._logger.error('Don\'t have MPD read permission, diconnecting.')
366 return self.disconnect_mpd()
368 # update cached values
369 self._command('outputs', callback = self._parse_outputs)
370 self._command('tagtypes', callback = self._parse_tagtypes)
371 self._command('urlhandlers', callback = self._parse_urlhandlers)
373 def _parse_outputs(self, data):
375 Update a list of outputs.
377 self._logger.debug('Receiving outputs.')
378 self.outputs = []
379 for output in self._parse_objects(data, ['outputid']):
380 self.outputs.append(AudioOutput(output, lambda val, outid = output['outputid']: self._set_output(outid, val), self))
382 def _parse_tagtypes(self, data):
384 Update a list of tag types.
386 self._logger.debug('Receiving tag types.')
387 self.tagtypes = list(self._parse_list(data)) + ['file']
388 def _parse_urlhandlers(self, data):
390 Update a list of URL handlers and finish connection.
392 self._logger.debug('Receiving URL handlers.')
393 self.urlhandlers = list(self._parse_list(data))
395 # done initializing data, finish connecting
396 return self._finish_connect()
398 def _finish_connect(self):
400 Called when connecting is completely done. Emit all signals.
402 self._logger.info('Successfully connected to MPD.')
404 self._socket.subsystems_changed.connect(self._mpd_changed)
405 self.connect_changed.emit(True)
406 self._mpd_changed()
408 @Slot()
409 def _handle_disconnected(self):
411 Called when connection is closed. Clear all cached data and emit
412 corresponding signals.
414 self._logger.info('Disconnected from MPD.')
415 self._commands = []
416 self.outputs = {}
417 self.tagtypes = []
418 self.urlhandlers = []
420 self._mpd_changed()
421 self.connect_changed.emit(False)
423 ################################
425 @Slot(list)
426 def _mpd_changed(self, subsystems = None):
428 Called when MPD signals a change in some subsystems.
430 if not subsystems:
431 subsystems = ['database', 'update', 'stored_playlist', 'playlist', 'output',
432 'player', 'mixer', 'options']
434 if ('player' in subsystems or
435 'mixer' in subsystems or
436 'options' in subsystems):
437 self._command('status', callback = self._update_status)
438 if 'database' in subsystems:
439 self.db_updated.emit()
440 if 'update' in subsystems:
441 pass # just list for completeness
442 if 'stored_playlist' in subsystems:
443 pass
444 if 'playlist' in subsystems:
445 self.playlist_changed.emit()
446 if 'output' in subsystems:
447 self._command('outputs', callback = self._update_outputs)
449 def _update_outputs(self, data):
451 Update outputs states.
453 for output in self._parse_objects(data, ['outputid']):
454 self.outputs[int(output['outputid'])].update(output)
456 def _update_status(self, data):
458 Called when something in status has changed. Check what was it and emit
459 corresponding signals.
461 status = self.status
462 try:
463 self.status = MPDStatus(list(self._parse_objects(data, ''))[0])
464 except IndexError:
465 self.status = MPDStatus()
467 if self.status['state'] == 'play':
468 self._timer.start()
469 else:
470 self._timer.stop()
472 if status['state'] != self.status['state']:
473 self.state_changed.emit(self.status['state'])
474 if status['time'][0] != self.status['time'][0]:
475 self.time_changed.emit(self.status['time'][0])
476 if status['volume'] != self.status['volume']:
477 self.volume_changed.emit(self.status['volume'])
478 if status['repeat'] != self.status['repeat']:
479 self.repeat_changed.emit(self.status['repeat'])
480 if status['random'] != self.status['random']:
481 self.random_changed.emit(self.status['random'])
482 if status['single'] != self.status['single']:
483 self.single_changed.emit(self.status['single'])
484 if status['consume'] != self.status['consume']:
485 self.consume_changed.emit(self.status['consume'])
486 if status['playlist'] != self.status['playlist']:
487 self.playlist_changed.emit()
488 if status['songid'] != self.status['songid']:
489 self._command('currentsong', callback = self._update_cur_song)
491 def _update_cur_song(self, data):
492 try:
493 self.cur_song = Song(list(self._parse_objects(data, ''))[0])
494 except IndexError:
495 self.cur_song = Song()
496 self.song_changed.emit(self.cur_song)
498 def _command(self, *cmd, **kwargs):
500 Send specified command to MPD asynchronously. kwargs must contain
501 a callable 'callback' if the caller want to read a response. Otherwise
502 any reponse from MPD is silently discarded.
504 if not self.is_connected():
505 self._logger.debug('Not connected -- not running command: %s'%cmd[0])
506 if 'callback' in kwargs:
507 kwargs['callback']([])
508 elif not cmd[0] in self._commands:
509 self._logger.error('Command %s not allowed.'%cmd[0])
510 if 'callback' in kwargs:
511 kwargs['callback']([])
512 else:
513 self._socket.write_command(*cmd, **kwargs)
514 def _command_sync(self, *cmd, **kwargs):
516 Send specified command to MPD synchronously. kwargs must contain
517 a callable 'parse' used for parsing the reponse.
519 parse = kwargs['parse']
521 if not self.is_connected():
522 self._logger.debug('Not connected -- not running command: %s'%cmd[0])
523 return parse([])
524 elif not cmd[0] in self._commands:
525 self._logger.error('Command %s not allowed.'%cmd[0])
526 return parse([])
527 else:
528 return parse(self._socket.write_command_sync(*cmd))
530 def _set_output(self, out_id, val):
532 Enable/disable speciffied output. Called only by AudioOutput.
534 cmd = 'enableoutput' if val else 'disableoutput'
535 self._command(cmd, out_id)
537 def _update_timer(self):
538 self.status['time'][0] += 1
539 self.time_changed.emit(self.status['time'][0])
541 ## MPD output parsing functions ##
543 def _parse_list(self, data):
545 Parse a list of 'id_we_dont_care_about: useful_data'.
547 for line in data:
548 parts = line.partition(': ')
549 if not parts[1]:
550 self._logger.error('Malformed line: %s.'%line)
551 continue
552 yield parts[2]
554 def _parse_objects(self, data, delimiters):
556 Parse a list of object separated by specified delimiters.
558 cur = {}
559 for line in data:
560 parts = line.partition(': ')
561 if not parts[1]:
562 self._logger.error('Malformed line: %s.'%line)
563 continue
565 if parts[0] in delimiters and cur:
566 yield cur
567 cur = {}
569 if parts[0] in cur:
570 cur[parts[0]] += ',' + parts[2]
571 else:
572 cur[parts[0]] = parts[2]
573 if cur:
574 yield cur
576 def _parse_songs(self, data):
578 Parse a list of songs -- output of playlistinfo/listallinfo.
580 for song in self._parse_objects(data, ['file', 'directory']):
581 if 'file' in song:
582 yield Song(song)