Playlist: add a shuffle button to the toolbar.
[nephilim.git] / nephilim / mpclient.py
blobf0cf1860f2af1c5541b448a1550f177419f2ef46
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 """
27 This class represents an MPD audio output.
28 Instances of this class are generated by MPClient, do not
29 instantiate directly.
30 """
32 #### PUBLIC ####
33 # constants
34 name = None
36 # read-only
37 state = None
39 # SIGNALS
40 state_changed = Signal(bool)
42 #### public ####
43 def __init__(self, data, set_state, parent = None):
44 QtCore.QObject.__init__(self, parent)
46 self.name = data['outputname']
47 self.state = int(data['outputenabled'])
49 self.set_state = set_state
51 @Slot(bool)
52 def set_state(self, state):
53 pass
55 def update(self, data):
56 """
57 This is called by mpclient to inform about output state change.
58 """
59 if int(data['outputenabled']) != self.state:
60 self.state = not self.state
61 self.state_changed.emit(self.state)
63 class MPDStatus(dict):
64 """
65 This class represent MPD status with a dict-like interface.
66 Instances of this class are generated by MPClient, do not
67 instantiate directly.
68 """
70 #### PRIVATE ####
71 """All standard status items."""
72 _status = {'volume' : 0, 'repeat' : 0, 'single' : 0,
73 'consume' : 0, 'playlist' : '-1', 'playlistlength' : 0,
74 'state' : 'stop', 'song' : -1, 'songid' : '-1',
75 'nextsong' : -1, 'nextsongid' : '-1', 'time' : '0:0',
76 'elapsed' : .0, 'bitrate' : 0, 'xfade' : 0,
77 'mixrampdb' : .0, 'mixrampdelay' : .0, 'audio' : '0:0:0',
78 'updatings_db' : -1, 'error' : '', 'random' : 0 }
80 def __init__(self, data = {}):
81 dict.__init__(self, MPDStatus._status)
82 for key in data:
83 if key in self._status:
84 self[key] = type(self._status[key])(data[key])
85 else:
86 self[key] = data[key]
87 try:
88 self['time'] = map(int, self['time'].split(':'))
89 except ValueError:
90 self['time'] = [0, 0]
91 try:
92 self['audio'] = tuple(map(int, self['audio'].split(':')))
93 except ValueError:
94 self['audio'] = (0, 0, 0)
96 class MPClient(QtCore.QObject):
97 """
98 A high-level MPD interface. It is mostly asynchronous -- all responses from
99 MPD are read via callbacks. A callback may be None, in which case the data
100 is silently discarded. Callbacks that take iterators must ensure that the
101 iterator is exhausted.
104 #### PUBLIC ####
105 # these don't change while we are connected
106 """A list of AudioOutputs available."""
107 outputs = None
108 """A list of supported tags (valid indices for Song)."""
109 tagtypes = None
110 """A list of supported URL handlers."""
111 urlhandlers = None
113 # read-only
114 """An MPDStatus object representing current status."""
115 status = None
116 """A Song object representing current song."""
117 cur_song = None
119 # SIGNALS
120 connect_changed = Signal(bool)
121 db_updated = Signal()
122 time_changed = Signal(int)
123 song_changed = Signal(object)
124 songpos_changed = Signal(object) # this one's emitted when only current song's position changed
125 state_changed = Signal(str)
126 volume_changed = Signal(int)
127 repeat_changed = Signal(bool)
128 random_changed = Signal(bool)
129 single_changed = Signal(bool)
130 consume_changed = Signal(bool)
131 crossfade_changed = Signal(int)
132 playlist_changed = Signal()
135 #### PRIVATE ####
136 # const
137 _sup_ver = (0, 16, 0)
138 _logger = None
139 _timer = None
141 # these don't change while we are connected
142 _commands = None
143 _socket = None
144 _password = None
146 #### PUBLIC ####
148 ## internals ##
149 def __init__(self, parent = None):
150 QtCore.QObject.__init__(self, parent)
151 self._logger = logging.getLogger('%smpclient'%(unicode(parent) + "." if parent else ""))
152 self._timer = QtCore.QTimer(self)
153 self._timer.setInterval(1000)
154 self._timer.timeout.connect(self._update_timer)
155 self._socket = MPDSocket(self)
156 self._commands = []
157 self.status = MPDStatus()
158 self.cur_song = Song()
160 self.outputs = []
161 self.urlhandlers = []
162 self.tagtypes = []
163 def __str__(self):
164 return self._logger.name
166 ## connection functions ##
167 def connect_mpd(self, host = "localhost", port = 6600, password = None):
169 Connect to MPD at host:port optionally using a password. A Unix domain
170 socket is used if port is omitted.
172 self._logger.info('Connecting to MPD...')
174 if self.is_connected():
175 self._logger.warning('Already connected.')
177 self._socket.connect_changed.connect(lambda val: self._handle_connected() if val else self._handle_disconnected())
178 self._socket.connect_mpd(host, port)
179 self._password = password
180 def disconnect_mpd(self):
182 Disconnect from MPD.
184 self._logger.info('Disconnecting from MPD.')
185 if self.is_connected():
186 self._socket.write_command('close')
187 def is_connected(self):
189 Returns True if connected to MPD, False otherwise.
191 return self._socket.state() == QtNetwork.QAbstractSocket.ConnectedState
193 ## playlists ##
194 def playlist(self, callback):
196 Request current playlist from MPD. Callback will be called with an
197 iterator over Songs in current playlist as the argument.
199 self._command('playlistinfo', callback = lambda data: callback(self._parse_songs(data)))
200 def get_plist_song(self, plid):
202 Get a song with a given playlist id synchronously.
204 return self._command_sync('playlistid', plid, parse = lambda data: Song(list(self._parse_objects(data, []))[0]))
205 def delete(self, ids):
207 Delete songs with specified ids from playlist.
209 for id in ids:
210 self._command('deleteid', id)
211 def clear(self):
213 Clear current playlist.
215 self._command('clear')
216 def add(self, paths, pos = -1):
218 Add specified songs to specified position in current playlist.
220 # start playback of the first added song if MPD is stopped
221 if self.status['state'] == 'stop':
222 cb = lambda data: [self.play(sid) for sid in self._parse_list(data) ]
223 else:
224 cb = None
226 args = ['addid', '']
227 if pos >= 0:
228 args.append(pos)
229 for path in paths:
230 args[1] = path
231 if cb:
232 self._command(*args, callback = cb)
233 cb = None
234 else:
235 self._command(*args)
236 if pos >= 0:
237 args[2] += 1
238 def move(self, src, dst):
240 Move a song with given src id to position dst.
242 self._command('moveid', src, dst)
243 def shuffle(self):
245 Shuffle the current playlist.
247 self._command('shuffle')
249 ## database ##
250 def database(self, callback):
252 Request database information from MPD. Callback will be called with an
253 iterator over all Songs in the database as the argument.
255 self._command('listallinfo', callback = lambda data: callback(self._parse_songs(data)))
256 def update_database(self):
258 Initiates a database update.
260 self._command('update')
261 def rescan_database(self):
263 Initiase a rebuild of database from scratch.
265 self._command('rescan')
267 ## searching ##
268 def find(self, callback, *args):
270 Request a search on MPD. Callback will be called with an iterator over
271 all found songs. For allowed values of args, see MPD protocol documentation.
273 self._command('find', args, callback = lambda data: callback(self._parse_songs(data)))
274 def find_sync(self, *args):
276 Search for songs on MPD synchronously. Returns an iterator over all
277 found songs. For allowed values of args, see MPD protocol documentation.
279 return self._command_sync('find', args, parse = lambda data: self._parse_songs(data))
280 def findadd(self, *args):
282 Request a search on MPD and add found songs to current playlist. Allowed values
283 of args are same as for find.
285 self._command('findadd', *args)
287 ## playback options ##
288 def set_volume(self, volume):
290 Set MPD volume level.
292 volume = min(100, max(0, volume))
293 self._command('setvol', volume)
294 def repeat(self, val):
296 Enable/disable repeat.
298 val = '1' if val else '0'
299 self._command('repeat', val)
300 def random(self, val):
302 Enable/disable random.
304 val = '1' if val else '0'
305 self._command('random', val)
306 def consume(self, val):
308 Enable/disable consume.
310 val = '1' if val else '0'
311 self._command('consume', val)
312 def single(self, val):
314 Enable/disable single.
316 val = '1' if val else '0'
317 self._command('single', val)
318 def crossfade(self, time):
320 Set crossfade to specified time.
322 self._command('crossfade', time)
324 ## controlling playback ##
325 def play(self, id = None):
327 Start playback of song with a specified id. If no id is given, then
328 start on current song/beginning.
330 args = ['playid']
331 if id:
332 args.append(id)
333 self._command(*args)
334 def pause(self):
336 Pause playback.
338 self._command('pause', 1)
339 def resume(self):
341 Resume paused playback.
343 self._command('pause', 0)
344 def next(self):
346 Move on to next song.
348 self._command('next')
349 def previous(self):
351 Move back to previous song.
353 self._command('previous')
354 def stop(self):
356 Stop playback.
358 self._command('stop')
359 def seek(self, time):
361 Seek to specified time in current song.
363 self._command('seekid', self.status['songid'], time)
365 #### PRIVATE ####
367 ## connection functions ##
368 # these functions are called during connection process #
369 # XXX: maybe use a generator?
370 @Slot()
371 def _handle_connected(self):
373 Called when a connection is established. Send a password and
374 start getting locally stored values.
376 self._logger.debug('Connection established.')
378 # check if protocol version is supported
379 v = self._socket.version
380 if v[0] != self._sup_ver[0]:
381 self._logger.error('Server reported unsupported major protocol version %d, disconnecting.'%v[0])
382 return self.disconnect_mpd()
383 if v[1] < self._sup_ver[1]:
384 self._logger.warning('Server reported too low minor protocol version %d. Continuing, but things might break.'%v[1])
386 if self._password:
387 self._socket.write_command('password', self._password)
389 self._socket.write_command('commands', callback = self._parse_commands)
391 def _parse_commands(self, data):
393 Receive a list of available commands and update
394 the other locally stored values.
396 self._logger.debug('Receiving command list.')
397 self._commands = list(self._parse_list(data))
399 if not 'listallinfo' in self._commands:
400 self._logger.error('Don\'t have MPD read permission, diconnecting.')
401 return self.disconnect_mpd()
403 # update cached values
404 self._command('outputs', callback = self._parse_outputs)
405 self._command('tagtypes', callback = self._parse_tagtypes)
406 self._command('urlhandlers', callback = self._parse_urlhandlers)
408 def _parse_outputs(self, data):
410 Update a list of outputs.
412 self._logger.debug('Receiving outputs.')
413 self.outputs = []
414 for output in self._parse_objects(data, ['outputid']):
415 self.outputs.append(AudioOutput(output, lambda val, outid = output['outputid']: self._set_output(outid, val), self))
417 def _parse_tagtypes(self, data):
419 Update a list of tag types.
421 self._logger.debug('Receiving tag types.')
422 self.tagtypes = list(self._parse_list(data)) + ['file']
423 def _parse_urlhandlers(self, data):
425 Update a list of URL handlers and finish connection.
427 self._logger.debug('Receiving URL handlers.')
428 self.urlhandlers = list(self._parse_list(data))
430 # done initializing data, finish connecting
431 return self._finish_connect()
433 def _finish_connect(self):
435 Called when connecting is completely done. Emit all signals.
437 self._logger.info('Successfully connected to MPD.')
439 self._socket.subsystems_changed.connect(self._mpd_changed)
440 self.connect_changed.emit(True)
441 self._mpd_changed()
443 @Slot()
444 def _handle_disconnected(self):
446 Called when connection is closed. Clear all cached data and emit
447 corresponding signals.
449 self._logger.info('Disconnected from MPD.')
450 self._commands = []
451 self.outputs = {}
452 self.tagtypes = []
453 self.urlhandlers = []
455 self._mpd_changed()
456 self.connect_changed.emit(False)
458 ################################
460 @Slot(list)
461 def _mpd_changed(self, subsystems = None):
463 Called when MPD signals a change in some subsystems.
465 if not subsystems:
466 subsystems = ['database', 'update', 'stored_playlist', 'playlist', 'output',
467 'player', 'mixer', 'options']
469 if ('player' in subsystems or
470 'mixer' in subsystems or
471 'options' in subsystems):
472 self._command('status', callback = self._update_status)
473 if 'database' in subsystems:
474 self.db_updated.emit()
475 if 'update' in subsystems:
476 pass # just list for completeness
477 if 'stored_playlist' in subsystems:
478 pass
479 if 'playlist' in subsystems:
480 self.playlist_changed.emit()
481 self._command('currentsong', callback = self._update_cur_song)
482 if 'output' in subsystems:
483 self._command('outputs', callback = self._update_outputs)
485 def _update_outputs(self, data):
487 Update outputs states.
489 for output in self._parse_objects(data, ['outputid']):
490 self.outputs[int(output['outputid'])].update(output)
492 def _update_status(self, data):
494 Called when something in status has changed. Check what was it and emit
495 corresponding signals.
497 status = self.status
498 try:
499 self.status = MPDStatus(list(self._parse_objects(data, ''))[0])
500 except IndexError:
501 self.status = MPDStatus()
503 if self.status['state'] == 'play':
504 self._timer.start()
505 else:
506 self._timer.stop()
508 if status['state'] != self.status['state']:
509 self.state_changed.emit(self.status['state'])
510 if status['time'][0] != self.status['time'][0]:
511 self.time_changed.emit(self.status['time'][0])
512 if status['volume'] != self.status['volume']:
513 self.volume_changed.emit(self.status['volume'])
514 if status['repeat'] != self.status['repeat']:
515 self.repeat_changed.emit(self.status['repeat'])
516 if status['random'] != self.status['random']:
517 self.random_changed.emit(self.status['random'])
518 if status['single'] != self.status['single']:
519 self.single_changed.emit(self.status['single'])
520 if status['consume'] != self.status['consume']:
521 self.consume_changed.emit(self.status['consume'])
522 if status['xfade'] != self.status['xfade']:
523 self.crossfade_changed.emit(self.status['xfade'])
524 if status['playlist'] != self.status['playlist']:
525 self.playlist_changed.emit()
526 if status['songid'] != self.status['songid']:
527 self._command('currentsong', callback = self._update_cur_song)
529 def _update_cur_song(self, data):
530 song = self.cur_song
531 try:
532 self.cur_song = Song(list(self._parse_objects(data, ''))[0])
533 except IndexError:
534 self.cur_song = Song()
535 if song['id'] != self.cur_song['id']:
536 self.song_changed.emit(self.cur_song)
537 else:
538 self.songpos_changed.emit(self.cur_song)
540 def _command(self, *cmd, **kwargs):
542 Send specified command to MPD asynchronously. kwargs must contain
543 a callable 'callback' if the caller want to read a response. Otherwise
544 any reponse from MPD is silently discarded.
546 if not self.is_connected():
547 self._logger.debug('Not connected -- not running command: %s'%cmd[0])
548 if 'callback' in kwargs:
549 kwargs['callback']([])
550 elif not cmd[0] in self._commands:
551 self._logger.error('Command %s not allowed.'%cmd[0])
552 if 'callback' in kwargs:
553 kwargs['callback']([])
554 else:
555 self._socket.write_command(*cmd, **kwargs)
556 def _command_sync(self, *cmd, **kwargs):
558 Send specified command to MPD synchronously. kwargs must contain
559 a callable 'parse' used for parsing the reponse.
561 parse = kwargs['parse']
563 if not self.is_connected():
564 self._logger.debug('Not connected -- not running command: %s'%cmd[0])
565 return parse([])
566 elif not cmd[0] in self._commands:
567 self._logger.error('Command %s not allowed.'%cmd[0])
568 return parse([])
569 else:
570 return parse(self._socket.write_command_sync(*cmd))
572 def _set_output(self, out_id, val):
574 Enable/disable speciffied output. Called only by AudioOutput.
576 cmd = 'enableoutput' if val else 'disableoutput'
577 self._command(cmd, out_id)
579 def _update_timer(self):
580 self.status['time'][0] += 1
581 self.time_changed.emit(self.status['time'][0])
583 ## MPD output parsing functions ##
585 def _parse_list(self, data):
587 Parse a list of 'id_we_dont_care_about: useful_data'.
589 for line in data:
590 parts = line.partition(': ')
591 if not parts[1]:
592 self._logger.error('Malformed line: %s.'%line)
593 continue
594 yield parts[2]
596 def _parse_objects(self, data, delimiters):
598 Parse a list of object separated by specified delimiters.
600 cur = {}
601 for line in data:
602 parts = line.partition(': ')
603 if not parts[1]:
604 self._logger.error('Malformed line: %s.'%line)
605 continue
607 if parts[0] in delimiters and cur:
608 yield cur
609 cur = {}
611 if parts[0] in cur:
612 cur[parts[0]] += ',' + parts[2]
613 else:
614 cur[parts[0]] = parts[2]
615 if cur:
616 yield cur
618 def _parse_songs(self, data):
620 Parse a list of songs -- output of playlistinfo/listallinfo.
622 for song in self._parse_objects(data, ['file', 'directory']):
623 if 'file' in song:
624 yield Song(song)