PlayControl: add a menu for controlling outputs.
[nephilim.git] / nephilim / mpclient.py
blob33b50b17be89371b73176381b493bcaf3197e8a6
2 # Copyright (C) 2008 jerous <jerous@gmail.com>
3 # Copyright (C) 2009 Anton Khirnov <wyskas@gmail.com>
5 # Nephilim is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
10 # Nephilim is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with Nephilim. If not, see <http://www.gnu.org/licenses/>.
19 from PyQt4 import QtCore
20 import mpd
21 import socket
22 import logging
24 from song import Song, SongRef, PlaylistEntryRef
26 class MPClient(QtCore.QObject):
27 """This class offers another layer above pympd, with usefull events."""
28 # public, read-only
29 "a list of audio outputs"
30 outputs = None
32 # private
33 _client = None
34 _cur_song = None
35 _status = {'volume' : 0, 'repeat' : 0, 'random' : 0,
36 'songid' : 0, 'playlist' : 0, 'playlistlength' : 0,
37 'time' : 0, 'length' : 0, 'xfade' : 0,
38 'state' : 'stop', 'single' : 0,
39 'consume' : 0}
40 _commands = None
42 _timer_id = None #for querying status changes
43 _db_timer_id = None #for querying db updates
44 _db_update = None #time of last db update
46 _retr_mutex = QtCore.QMutex()
48 logger = None
50 # SIGNALS
51 connect_changed = QtCore.pyqtSignal(bool)
52 db_updated = QtCore.pyqtSignal()
53 song_changed = QtCore.pyqtSignal(object)
54 time_changed = QtCore.pyqtSignal(int)
55 state_changed = QtCore.pyqtSignal(str)
56 volume_changed = QtCore.pyqtSignal(int)
57 repeat_changed = QtCore.pyqtSignal(bool)
58 random_changed = QtCore.pyqtSignal(bool)
59 single_changed = QtCore.pyqtSignal(bool)
60 consume_changed = QtCore.pyqtSignal(bool)
61 playlist_changed = QtCore.pyqtSignal()
63 class Output(QtCore.QObject):
64 """This class represents an MPD audio output."""
65 # public, const
66 mpclient = None
67 name = None
68 id = None
69 state = None
71 # SIGNALS
72 state_changed = QtCore.pyqtSignal(bool)
74 #### public ####
75 def __init__(self, mpclient, name, id, state):
76 QtCore.QObject.__init__(self, mpclient)
78 self.mpclient = mpclient
79 self.name = name
80 self.id = id
81 self.state = state
83 @QtCore.pyqtSlot(bool)
84 def set_state(self, state):
85 self.mpclient.set_output(self.id, state)
87 #### private ####
88 def mpd_toggle_state(self):
89 """This is called by mpclient to inform about output state change."""
90 self.state = not self.state
91 self.state_changed.emit(self.state)
93 def __init__(self):
94 QtCore.QObject.__init__(self)
96 self.outputs = []
97 self._commands = []
98 self._status = dict(MPClient._status)
99 self.logger = logging.getLogger('mpclient')
101 def connect_mpd(self, host, port, password = None):
102 """Connect to MPD@host:port, optionally using password.
103 Returns True at success, False otherwise."""
105 self.logger.info('Connecting to MPD...')
106 if self._client:
107 self.logger.warning('Attempted to connect when already connected.')
108 return True
110 try:
111 self._client = mpd.MPDClient()
112 self._client.connect(host, port)
113 except socket.error, e:
114 self.logger.error('Socket error: %s.'%e)
115 self.disconnect_mpd()
116 return False
118 if password:
119 self.password(password)
120 else:
121 self._commands = self._retrieve(self._client.commands)
123 if not self.__check_command_ok('listallinfo'):
124 self.logger.error('Don\'t have MPD read permission, diconnecting.')
125 return self.disconnect_mpd()
127 self.__update_current_song()
128 self.__update_outputs()
129 self._db_update = self.stats()['db_update']
131 self.emit(QtCore.SIGNAL('connected')) #should be removed
132 self.connect_changed.emit(True)
133 self.logger.info('Successfully connected to MPD.')
134 self._timer_id = self.startTimer(500)
135 self._db_timer_id = self.startTimer(10000)
136 return True
137 def disconnect_mpd(self):
138 """Disconnect from MPD."""
139 self.logger.info('Disconnecting from MPD...')
140 if self._client:
141 try:
142 self._client.close()
143 self._client.disconnect()
144 except (mpd.ConnectionError, socket.error):
145 pass
146 self._client = None
147 else:
148 self.logger.warning('Attempted to disconnect when not connected.')
150 if self._timer_id:
151 self.killTimer(self._timer_id)
152 self._timer_id = None
153 if self._db_timer_id:
154 self.killTimer(self._db_timer_id)
155 self._db_timer_id = None
156 self._status = dict(MPClient._status)
157 self._cur_song = None
158 self._commands = []
159 self.outputs = []
160 self.emit(QtCore.SIGNAL('disconnected')) #should be removed
161 self.connect_changed.emit(False)
162 self.logger.info('Disconnected from MPD.')
163 def password(self, password):
164 """Use the password to authenticate with MPD."""
165 self.logger.info('Authenticating with MPD.')
166 if not self.__check_command_ok('password'):
167 return
168 try:
169 self._client.password(password)
170 self.logger.info('Successfully authenticated')
171 self._commands = self._retrieve(self._client.commands)
172 except mpd.CommandError:
173 self.logger.error('Incorrect MPD password.')
174 def is_connected(self):
175 """Returns True if connected to MPD, False otherwise."""
176 return self._client != None
178 def status(self):
179 """Get current MPD status."""
180 return self._status
181 def playlistinfo(self):
182 """Returns a list of songs in current playlist."""
183 self.logger.info('Listing current playlist.')
184 if not self.__check_command_ok('playlistinfo'):
185 return []
186 return self._array_to_song_array(self._retrieve(self._client.playlistinfo))
187 def library(self):
188 """Returns a list of all songs in library."""
189 self.logger.info('Listing library.')
190 if not self.__check_command_ok('listallinfo'):
191 return []
192 return self._array_to_song_array(self._retrieve(self._client.listallinfo))
193 def current_song(self):
194 """Returns the current playing song."""
195 return self._cur_song
196 def is_playing(self):
197 """Returns True if MPD is playing, False otherwise."""
198 return self._status['state'] == 'play'
199 def find(self, *args):
200 if not self.__check_command_ok('find'):
201 return []
202 return self._array_to_song_array(self._client.find(*args))
203 def findadd(self, *args):
204 if not self.__check_command_ok('findadd'):
205 return
206 return self._client.findadd(*args)
208 def update_db(self, paths = None):
209 """Starts MPD database update."""
210 self.logger.info('Updating database %s'%(paths if paths else '.'))
211 if not self.__check_command_ok('update'):
212 return
213 if not paths:
214 return self._client.update()
215 self._client.command_list_ok_begin()
216 for path in paths:
217 self._client.update(path)
218 self._client.command_list_end()
221 def volume(self):
222 """Get current volume."""
223 return int(self._status['volume'])
224 def set_volume(self, volume):
225 """Set volume to volume."""
226 self.logger.info('Setting volume to %d.'%volume)
227 if not self.__check_command_ok('setvol'):
228 return
229 volume = min(100, max(0, volume))
230 try:
231 self._client.setvol(volume)
232 except mpd.CommandError, e:
233 self.logger.warning('Error setting volume (probably no outputs enabled): %s.'%e)
235 def urlhandlers(self):
236 """Returns an array of available url handlers."""
237 if not self._client:
238 return []
239 else:
240 return self._client.urlhandlers()
241 def tagtypes(self):
242 """Returns a list of supported tags."""
243 if not self.__check_command_ok('tagtypes'):
244 return []
246 return map(str.lower, self._retrieve(self._client.tagtypes) + ['file'])
247 def commands(self):
248 """List all currently available MPD commands."""
249 return self._commands
250 def stats(self):
251 """Get MPD statistics."""
252 return self._retrieve(self._client.stats)
254 def repeat(self, val):
255 """Set repeat playlist to val (True/False)."""
256 self.logger.info('Setting repeat to %d.'%val)
257 if not self.__check_command_ok('repeat'):
258 return
259 if isinstance(val, bool):
260 val = 1 if val else 0
261 self._client.repeat(val)
262 def random(self, val):
263 """Set random playback to val (True, False)."""
264 self.logger.info('Setting random to %d.'%val)
265 if not self.__check_command_ok('random'):
266 return
267 if isinstance(val, bool):
268 val = 1 if val else 0
269 self._client.random(val)
270 def crossfade(self, time):
271 """Set crossfading between songs."""
272 self.logger.info('Setting crossfade to %d'%time)
273 if not self.__check_command_ok('crossfade'):
274 return
275 self._client.crossfade(time)
276 def single(self, val):
277 """Set single playback to val (True, False)"""
278 self.logger.info('Setting single to %d.'%val)
279 if not self.__check_command_ok('single'):
280 return
281 if isinstance(val, bool):
282 val = 1 if val else 0
283 self._client.single(val)
284 def consume(self, val):
285 """Set consume mode to val (True, False)"""
286 self.logger.info('Setting consume to %d.'%val)
287 if not self.__check_command_ok('consume'):
288 return
289 if isinstance(val, bool):
290 val = 1 if val else 0
291 self._client.consume(val)
293 def play(self, id = None):
294 """Play song with ID id or next song if id is None."""
295 self.logger.info('Starting playback %s.'%('of id %s'%(id) if id else ''))
296 if not self.__check_command_ok('play'):
297 return
298 if id:
299 self._client.playid(id)
300 else:
301 self._client.playid()
302 def pause(self):
303 """Pause playing."""
304 self.logger.info('Pausing playback.')
305 if not self.__check_command_ok('pause'):
306 return
307 self._client.pause(1)
308 def resume(self):
309 """Resume playing."""
310 self.logger.info('Resuming playback.')
311 if not self.__check_command_ok('pause'):
312 return
313 self._client.pause(0)
314 def next(self):
315 """Move on to the next song in the playlist."""
316 self.logger.info('Skipping to next song.')
317 if not self.__check_command_ok('next'):
318 return
319 self._client.next()
320 def previous(self):
321 """Move back to the previous song in the playlist."""
322 self.logger.info('Moving to previous song.')
323 if not self.__check_command_ok('previous'):
324 return
325 self._client.previous()
326 def stop(self):
327 """Stop playing."""
328 self.logger.info('Stopping playback.')
329 if not self.__check_command_ok('stop'):
330 return
331 self._client.stop()
332 def seek(self, time):
333 """Seek to time (in seconds)."""
334 self.logger.info('Seeking to %d.'%time)
335 if not self.__check_command_ok('seekid'):
336 return
337 if self._status['songid'] > 0:
338 self._client.seekid(self._status['songid'], time)
340 def delete(self, list):
341 """Remove all song IDs in list from the playlist."""
342 if not self.__check_command_ok('deleteid'):
343 return
344 self._client.command_list_ok_begin()
345 try:
346 for id in list:
347 self.logger.info('Deleting id %s from playlist.'%id)
348 self._client.deleteid(id)
349 self._client.command_list_end()
350 except mpd.CommandError, e:
351 self.logger.error('Error deleting files: %s.'%e)
352 def clear(self):
353 """Clear current playlist."""
354 self.logger.info('Clearing playlist.')
355 if not self.__check_command_ok('clear'):
356 return
357 self._client.clear()
358 def add(self, paths):
359 """Add all files in paths to the current playlist."""
360 if not self.__check_command_ok('addid'):
361 return
362 ret = None
363 self._client.command_list_ok_begin()
364 try:
365 for path in paths:
366 self.logger.info('Adding %s to playlist'%path)
367 self._client.addid(path.encode('utf-8'))
368 ret = self._client.command_list_end()
369 except mpd.CommandError, e:
370 self.logger.error('Error adding files: %s.'%e)
371 if self._status['state'] == 'stop' and ret:
372 self.play(ret[0])
373 def move(self, source, target):
374 """Move the songs in playlist. Takes a list of source ids and one target position."""
375 self.logger.info('Moving %d to %d.'%(source, target))
376 if not self.__check_command_ok('moveid'):
377 return
378 self._client.command_list_ok_begin()
379 i = 0
380 for id in source:
381 self._client.moveid(id, target + i)
382 i += 1
383 self._client.command_list_end()
385 #### private ####
386 def _retrieve(self, method):
387 """Makes sure only one call is made at a time to MPD."""
388 self._retr_mutex.lock()
389 try:
390 ret = method()
391 except socket.error:
392 self.logger.error('Connection to MPD broken.')
393 self._retr_mutex.unlock()
394 self.disconnect_mpd()
395 return None
397 self._retr_mutex.unlock()
398 return ret
399 def _array_to_song_array(self, array):
400 """Convert an array to an array of Songs."""
401 return map(lambda entry: Song(entry)
402 , filter(lambda entry: not('directory' in entry), array)
404 def __update_current_song(self):
405 """Update the current song."""
406 song = self._retrieve(self._client.currentsong)
407 if not song:
408 self._cur_song = None
409 else:
410 self._cur_song = Song(song)
411 def _update_status(self):
412 """Get current status"""
413 if not self._client:
414 return None
415 ret = self._retrieve(self._client.status)
416 if not ret:
417 return None
419 ret['repeat'] = int(ret['repeat'])
420 ret['random'] = int(ret['random'])
421 ret['single'] = int(ret['single'])
422 ret['consume'] = int(ret['consume'])
423 if 'time' in ret:
424 cur, len = ret['time'].split(':')
425 ret['length'] = int(len)
426 ret['time'] = int(cur)
427 else:
428 ret['length'] = 0
429 ret['time'] = 0
431 if not 'songid' in ret:
432 ret['songid'] = '-1'
434 return ret
435 def __check_command_ok(self, cmd):
436 if not self._client:
437 return self.logger.info('Not connected.')
438 if not cmd in self._commands:
439 return self.logger.error('Command %s not accessible'%cmd)
440 return True
442 def __update_outputs(self):
443 """Update the list of MPD audio outputs."""
444 if self.__check_command_ok('outputs'):
445 outputs = []
446 for output in self._retrieve(self._client.outputs):
447 outputs.append(MPClient.Output(self, output['outputname'], output['outputid'],
448 bool(output['outputenabled'])))
449 self.outputs = outputs
450 else:
451 self.outputs = []
452 def set_output(self, output_id, state):
453 """Set audio output output_id to state (0/1)."""
454 if not self.__check_command_ok('enableoutput'):
455 return
456 if state:
457 self._client.enableoutput(output_id)
458 else:
459 self._client.disableoutput(output_id)
461 def timerEvent(self, event):
462 """Check for changes since last check."""
463 if event.timerId() == self._db_timer_id:
464 #timer for monitoring db changes
465 db_update = self.stats()['db_update']
466 if db_update > self._db_update:
467 self.logger.info('Database updated.')
468 self._db_update = db_update
469 self.db_updated.emit()
470 return
473 old_status = self._status
474 self._status = self._update_status()
476 if not self._status:
477 return self.disconnect_mpd()
479 self.__update_current_song()
481 if self._status['songid'] != old_status['songid']:
482 self.song_changed.emit(PlaylistEntryRef(self, self._status['songid']))
484 if self._status['time'] != old_status['time']:
485 self.time_changed.emit(self._status['time'])
487 if self._status['state'] != old_status['state']:
488 self.state_changed.emit(self._status['state'])
490 if self._status['volume'] != old_status['volume']:
491 self.volume_changed.emit( int(self._status['volume']))
493 if self._status['repeat'] != old_status['repeat']:
494 self.repeat_changed.emit(bool(self._status['repeat']))
496 if self._status['random'] != old_status['random']:
497 self.random_changed.emit(bool(self._status['random']))
499 if self._status['single'] != old_status['single']:
500 self.single_changed.emit(bool(self._status['single']))
502 if self._status['consume'] != old_status['consume']:
503 self.consume_changed.emit(bool(self._status['consume']))
505 if self._status['playlist'] != old_status['playlist']:
506 self.playlist_changed.emit()
508 outputs = self._retrieve(self._client.outputs)
509 for i in range(len(outputs)):
510 if int(outputs[i]['outputenabled']) != int(self.outputs[i].state):
511 self.outputs[i].mpd_toggle_state()