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
):
26 """This class represents an MPD audio output."""
36 state_changed
= QtCore
.pyqtSignal(bool)
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
48 def set_state(self
, state
):
51 def update(self
, data
):
53 This is called by mpclient to inform about output state change.
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
)
71 if key
in self
._status
:
72 self
[key
] = type(self
._status
[key
])(data
[key
])
76 self
['time'] = map(int, self
['time'].split(':'))
80 self
['audio'] = tuple(map(int, self
['audio'].split(':')))
82 self
['audio'] = (0, 0, 0)
84 class MPClient(QtCore
.QObject
):
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.
93 # these don't change while we are connected
94 """A list of AudioOutputs available."""
96 """A list of supported tags (valid indices for Song)."""
98 """A list of supported URL handlers."""
102 """An MPDStatus object representing current status."""
104 """A Song object representing current song."""
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()
123 _sup_ver
= (0, 16, 0)
127 # these don't change while we are connected
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
)
141 self
.status
= MPDStatus()
142 self
.cur_song
= Song()
145 self
.urlhandlers
= []
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
):
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.
264 self
._command
('pause', 1)
267 Resume paused playback.
269 self
._command
('pause', 0)
272 Move on to next song.
274 self
._command
('next')
277 Move back to previous song.
279 self
._command
('previous')
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.
296 self
._command
('deleteid', id)
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
) ]
318 self
._command
(*args
, callback
= cb
)
324 def move(self
, src
, dst
):
326 Move a song with given src id to position dst.
328 self
._command
('moveid', src
, dst
)
332 ## connection functions ##
333 # these functions are called during connection process #
334 # XXX: maybe use a generator?
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])
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.')
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)
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.')
418 self
.urlhandlers
= []
421 self
.connect_changed
.emit(False)
423 ################################
426 def _mpd_changed(self
, subsystems
= None):
428 Called when MPD signals a change in some 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
:
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.
463 self
.status
= MPDStatus(list(self
._parse
_objects
(data
, ''))[0])
465 self
.status
= MPDStatus()
467 if self
.status
['state'] == 'play':
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
):
493 self
.cur_song
= Song(list(self
._parse
_objects
(data
, ''))[0])
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']([])
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])
524 elif not cmd
[0] in self
._commands
:
525 self
._logger
.error('Command %s not allowed.'%cmd
[0])
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'.
548 parts
= line
.partition(': ')
550 self
._logger
.error('Malformed line: %s.'%line
)
554 def _parse_objects(self
, data
, delimiters
):
556 Parse a list of object separated by specified delimiters.
560 parts
= line
.partition(': ')
562 self
._logger
.error('Malformed line: %s.'%line
)
565 if parts
[0] in delimiters
and cur
:
570 cur
[parts
[0]] += ',' + parts
[2]
572 cur
[parts
[0]] = parts
[2]
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']):