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
23 from mpdsocket
import MPDSocket
25 class AudioOutput(QtCore
.QObject
):
27 This class represents an MPD audio output.
28 Instances of this class are generated by MPClient, do not
40 state_changed
= Signal(bool)
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
52 def set_state(self
, state
):
55 def update(self
, data
):
57 This is called by mpclient to inform about output state change.
59 if int(data
['outputenabled']) != self
.state
:
60 self
.state
= not self
.state
61 self
.state_changed
.emit(self
.state
)
63 class MPDStatus(dict):
65 This class represent MPD status with a dict-like interface.
66 Instances of this class are generated by MPClient, do not
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' : u
'', 'random' : 0 }
80 def __init__(self
, data
= {}):
81 dict.__init
__(self
, MPDStatus
._status
)
83 if key
in self
._status
:
84 self
[key
] = type(self
._status
[key
])(data
[key
])
88 self
['time'] = map(int, self
['time'].split(':'))
92 self
['audio'] = tuple(map(int, self
['audio'].split(':')))
94 self
['audio'] = (0, 0, 0)
96 class MPClient(QtCore
.QObject
):
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.
105 # these don't change while we are connected
106 """A list of AudioOutputs available."""
108 """A list of supported tags (valid indices for Song)."""
110 """A list of supported URL handlers."""
114 """An MPDStatus object representing current status."""
116 """A Song object representing current song."""
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()
137 _sup_ver
= (0, 16, 0)
141 # these don't change while we are connected
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
)
157 self
.status
= MPDStatus()
158 self
.cur_song
= Song()
161 self
.urlhandlers
= []
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
):
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
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.
210 self
._command
('deleteid', id)
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
) ]
232 self
._command
(*args
, callback
= cb
)
238 def move(self
, src
, dst
):
240 Move a song with given src id to position dst.
242 self
._command
('moveid', src
, dst
)
245 Shuffle the current playlist.
247 self
._command
('shuffle')
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')
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.
338 self
._command
('pause', 1)
341 Resume paused playback.
343 self
._command
('pause', 0)
346 Move on to next song.
348 self
._command
('next')
351 Move back to previous song.
353 self
._command
('previous')
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
)
367 ## connection functions ##
368 # these functions are called during connection process #
369 # XXX: maybe use a generator?
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])
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.')
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)
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.')
453 self
.urlhandlers
= []
456 self
.connect_changed
.emit(False)
458 ################################
461 def _mpd_changed(self
, subsystems
= None):
463 Called when MPD signals a change in some 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
:
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.
499 self
.status
= MPDStatus(list(self
._parse
_objects
(data
, ''))[0])
501 self
.status
= MPDStatus()
503 if self
.status
['state'] == 'play':
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
):
532 self
.cur_song
= Song(list(self
._parse
_objects
(data
, ''))[0])
534 self
.cur_song
= Song()
535 if song
['id'] != self
.cur_song
['id']:
536 self
.song_changed
.emit(self
.cur_song
)
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']([])
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])
566 elif not cmd
[0] in self
._commands
:
567 self
._logger
.error('Command %s not allowed.'%cmd
[0])
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'.
590 parts
= line
.partition(': ')
592 self
._logger
.error('Malformed line: %s.'%line
)
596 def _parse_objects(self
, data
, delimiters
):
598 Parse a list of object separated by specified delimiters.
602 parts
= line
.partition(': ')
604 self
._logger
.error('Malformed line: %s.'%line
)
607 if parts
[0] in delimiters
and cur
:
612 cur
[parts
[0]] += ',' + parts
[2]
614 cur
[parts
[0]] = parts
[2]
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']):