summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnton Khirnov <wyskas@gmail.com>2010-08-09 18:38:46 +0200
committerAnton Khirnov <wyskas@gmail.com>2010-08-12 20:50:11 +0200
commit1bd84eeb9026267d741764d01dbfb6acaeecc817 (patch)
tree1ab2f10441e95d05267a381539d7702a7bebb4b8
parentd1a10219c77e8000e2a82e40376d08225f4e92f4 (diff)
mpclient: add a new asynchronous high-level MPD layer
-rw-r--r--nephilim/mpclient.py565
1 files changed, 563 insertions, 2 deletions
diff --git a/nephilim/mpclient.py b/nephilim/mpclient.py
index ae59ebf..c118cdb 100644
--- a/nephilim/mpclient.py
+++ b/nephilim/mpclient.py
@@ -16,12 +16,14 @@
# along with Nephilim. If not, see <http://www.gnu.org/licenses/>.
#
-from PyQt4 import QtCore
+from PyQt4 import QtCore, QtNetwork
+from PyQt4.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
import mpd
import socket
import logging
-from song import Song, SongRef, PlaylistEntryRef
+from song import Song, PlaylistEntryRef
+from mpdsocket import MPDSocket
class MPClient(QtCore.QObject):
"""This class offers another layer above pympd, with usefull events."""
@@ -504,3 +506,562 @@ class AudioOutput(QtCore.QObject):
"""This is called by mpclient to inform about output state change."""
self.state = not self.state
self.state_changed.emit(self.state)
+
+class AudioOutput2(QtCore.QObject):
+ """This class represents an MPD audio output."""
+
+ #### PUBLIC ####
+ # constants
+ name = None
+
+ # read-only
+ state = None
+
+ # SIGNALS
+ state_changed = QtCore.pyqtSignal(bool)
+
+ #### public ####
+ def __init__(self, data, set_state, parent = None):
+ QtCore.QObject.__init__(self, parent)
+
+ self.name = data['outputname']
+ self.state = int(data['outputenabled'])
+
+ self.set_state = set_state
+
+ @Slot(bool)
+ def set_state(self, state):
+ pass
+
+ def update(self, data):
+ """
+ This is called by mpclient to inform about output state change.
+ """
+ if int(data['outputenabled']) != self.state:
+ self.state = not self.state
+ self.state_changed.emit(self.state)
+
+class MPDStatus(dict):
+ _status = {'volume' : 0, 'repeat' : 0, 'single' : 0,
+ 'consume' : 0, 'playlist' : '-1', 'playlistlength' : 0,
+ 'state' : 'stop', 'song' : -1, 'songid' : '-1',
+ 'nextsong' : -1, 'nextsongid' : '-1', 'time' : '0:0',
+ 'elapsed' : .0, 'bitrate' : 0, 'xfade' : 0,
+ 'mixrampdb' : .0, 'mixrampdelay' : .0, 'audio' : '0:0:0',
+ 'updatings_db' : -1, 'error' : '', 'random' : 0 }
+
+ def __init__(self, data = {}):
+ dict.__init__(self, MPDStatus._status)
+ for key in data:
+ if key in self._status:
+ self[key] = type(self._status[key])(data[key])
+ else:
+ self[key] = data[key]
+ try:
+ self['time'] = map(int, self['time'].split(':'))
+ except ValueError:
+ self['time'] = [0, 0]
+ try:
+ self['audio'] = tuple(map(int, self['audio'].split(':')))
+ except ValueError:
+ self['audio'] = (0, 0, 0)
+
+class MPClient2(QtCore.QObject):
+ """
+ A high-level MPD interface. It is mostly asynchronous -- all responses from
+ MPD are read via callbacks. A callback may be None, in which case the data
+ is silently discarded. Callbacks that take iterators must ensure that the
+ iterator is exhausted.
+ """
+
+ #### PUBLIC ####
+ # these don't change while we are connected
+ """A list of AudioOutputs available."""
+ outputs = None
+ """A list of supported tags (valid indices for Song)."""
+ tagtypes = None
+ """A list of supported URL handlers."""
+ urlhandlers = None
+
+ # read-only
+ """An MPDStatus object representing current status."""
+ status = None
+ """A Song object representing current song."""
+ cur_song = None
+
+ # SIGNALS
+ connect_changed = Signal(bool)
+ db_updated = Signal()
+ time_changed = Signal(int)
+ song_changed = Signal(object)
+ state_changed = Signal(str)
+ volume_changed = Signal(int)
+ repeat_changed = Signal(bool)
+ random_changed = Signal(bool)
+ single_changed = Signal(bool)
+ consume_changed = Signal(bool)
+ playlist_changed = Signal()
+
+
+ #### PRIVATE ####
+ # const
+ _sup_ver = (0, 16, 0)
+ _logger = None
+ _timer = None
+
+ # these don't change while we are connected
+ _commands = None
+ _socket = None
+ _password = None
+
+ #### PUBLIC ####
+ def __init__(self, parent = None):
+ QtCore.QObject.__init__(self, parent)
+ self._logger = logging.getLogger('%smpclient'%(unicode(parent) + "." if parent else ""))
+ self._timer = QtCore.QTimer(self)
+ self._timer.setInterval(1000)
+ self._timer.timeout.connect(self._update_timer)
+ self._socket = MPDSocket(self)
+ self._commands = []
+ self.status = MPDStatus()
+ self.cur_song = Song()
+
+ self.outputs = []
+ self.urlhandlers = []
+ self.tagtypes = []
+ def __str__(self):
+ return self._logger.name
+
+ def connect_mpd(self, host = "localhost", port = 6600, password = None):
+ """
+ Connect to MPD at host:port optionally using a password. A Unix domain
+ socket is used if port is omitted.
+ """
+ self._logger.info('Connecting to MPD...')
+
+ if self.is_connected():
+ self._logger.warning('Already connected.')
+
+ self._socket.connect_changed.connect(lambda val: self._handle_connected() if val else self._handle_disconnected())
+ self._socket.connect_mpd(host, port)
+ self._password = password
+
+ def disconnect_mpd(self):
+ """
+ Disconnect from MPD.
+ """
+ self._logger.info('Disconnecting from MPD.')
+ if self.is_connected():
+ self._socket.write_command('close')
+
+ def is_connected(self):
+ """
+ Returns True if connected to MPD, False otherwise.
+ """
+ return self._socket.state() == QtNetwork.QAbstractSocket.ConnectedState
+
+ def playlist(self, callback):
+ """
+ Request current playlist from MPD. Callback will be called with an
+ iterator over Songs in current playlist as the argument.
+ """
+ self._command('playlistinfo', callback = lambda data: callback(self._parse_songs(data)))
+ def database(self, callback):
+ """
+ Request database information from MPD. Callback will be called with an
+ iterator over all Songs in the database as the argument.
+ """
+ self._command('listallinfo', callback = lambda data: callback(self._parse_songs(data)))
+
+ def find(self, callback, *args):
+ """
+ Request a search on MPD. Callback will be called with an iterator over
+ all found songs. For allowed values of args, see MPD protocol documentation.
+ """
+ self._command('find', args, callback = lambda data: callback(self._parse_songs(data)))
+ def find_sync(self, *args):
+ """
+ Search for songs on MPD synchronously. Returns an iterator over all
+ found songs. For allowed values of args, see MPD protocol documentation.
+ """
+ return self._command_sync('find', args, parse = lambda data: self._parse_songs(data))
+ def findadd(self, *args):
+ """
+ Request a search on MPD and add found songs to current playlist. Allowed values
+ of args are same as for find.
+ """
+ self._command('findadd', *args)
+
+ def get_plist_song(self, plid):
+ """
+ Get a song with a given playlist id synchronously.
+ """
+ return self._command_sync('playlistid', plid, parse = lambda data: Song(list(self._parse_objects(data, []))[0]))
+ def set_volume(self, volume):
+ """
+ Set MPD volume level.
+ """
+ volume = min(100, max(0, volume))
+ self._command('setvol', volume)
+ def repeat(self, val):
+ """
+ Enable/disable repeat.
+ """
+ val = '1' if val else '0'
+ self._command('repeat', val)
+ def random(self, val):
+ """
+ Enable/disable random.
+ """
+ val = '1' if val else '0'
+ self._command('random', val)
+ def consume(self, val):
+ """
+ Enable/disable consume.
+ """
+ val = '1' if val else '0'
+ self._command('consume', val)
+ def single(self, val):
+ """
+ Enable/disable single.
+ """
+ val = '1' if val else '0'
+ self._command('single', val)
+ def crossfade(self, time):
+ """
+ Set crossfade to specified time.
+ """
+ self._command('crossfade', val)
+
+ def play(self, id = None):
+ """
+ Start playback of song with a specified id. If no id is given, then
+ start on current song/beginning.
+ """
+ args = ['playid']
+ if id:
+ args.append(id)
+ self._command(*args)
+ def pause(self):
+ """
+ Pause playback.
+ """
+ self._command('pause', 1)
+ def resume(self):
+ """
+ Resume paused playback.
+ """
+ self._command('pause', 0)
+ def next(self):
+ """
+ Move on to next song.
+ """
+ self._command('next')
+ def previous(self):
+ """
+ Move back to previous song.
+ """
+ self._command('previous')
+ def stop(self):
+ """
+ Stop playback.
+ """
+ self._command('stop')
+ def seek(self, time):
+ """
+ Seek to specified time in current song.
+ """
+ self._command('seekid', self.status['songid'], time)
+
+ def delete(self, ids):
+ """
+ Delete songs with specified ids from playlist.
+ """
+ for id in ids:
+ self._command('deleteid', id)
+ def clear(self):
+ """
+ Clear current playlist.
+ """
+ self._command('clear')
+ def add(self, paths, pos = -1):
+ """
+ Add specified songs to specified position in current playlist.
+ """
+ # start playback of the first added song if MPD is stopped
+ if self.status['state'] == 'stop':
+ cb = lambda data: [self.play(sid) for sid in self._parse_list(data) ]
+ else:
+ cb = None
+
+ args = ['addid', '']
+ if pos >= 0:
+ args.append(pos)
+ for path in paths:
+ args[1] = path
+ if cb:
+ self._command(*args, callback = cb)
+ cb = None
+ else:
+ self._command(*args)
+ if pos >= 0:
+ args[2] += 1
+ def move(self, src, dst):
+ """
+ Move a song with given src id to position dst.
+ """
+ self._command('moveid', src, dst)
+
+ #### PRIVATE ####
+
+ ## connection functions ##
+ # these functions are called during connection process #
+ # XXX: maybe use a generator?
+ @Slot()
+ def _handle_connected(self):
+ """
+ Called when a connection is established. Send a password and
+ start getting locally stored values.
+ """
+ self._logger.debug('Connection established.')
+
+ # check if protocol version is supported
+ v = self._socket.version
+ if v[0] != self._sup_ver[0]:
+ self._logger.error('Server reported unsupported major protocol version %d, disconnecting.'%v[0])
+ return self.disconnect_mpd()
+ if v[1] < self._sup_ver[1]:
+ self._logger.warning('Server reported too low minor protocol version %d. Continuing, but things might break.'%v[1])
+
+ if self._password:
+ self._socket.write_command('password', self._password)
+
+ self._socket.write_command('commands', callback = self._parse_commands)
+
+ def _parse_commands(self, data):
+ """
+ Receive a list of available commands and update
+ the other locally stored values.
+ """
+ self._logger.debug('Receiving command list.')
+ self._commands = list(self._parse_list(data))
+
+ if not 'listallinfo' in self._commands:
+ self._logger.error('Don\'t have MPD read permission, diconnecting.')
+ return self.disconnect_mpd()
+
+ # update cached values
+ self._command('outputs', callback = self._parse_outputs)
+ self._command('tagtypes', callback = self._parse_tagtypes)
+ self._command('urlhandlers', callback = self._parse_urlhandlers)
+
+ def _parse_outputs(self, data):
+ """
+ Update a list of outputs.
+ """
+ self._logger.debug('Receiving outputs.')
+ self.outputs = []
+ for output in self._parse_objects(data, ['outputid']):
+ self.outputs.append(AudioOutput(output, lambda val, outid = output['outputid']: self._set_output(outid, val), self))
+
+ def _parse_tagtypes(self, data):
+ """
+ Update a list of tag types.
+ """
+ self._logger.debug('Receiving tag types.')
+ self.tagtypes = list(self._parse_list(data)) + ['file']
+ def _parse_urlhandlers(self, data):
+ """
+ Update a list of URL handlers and finish connection.
+ """
+ self._logger.debug('Receiving URL handlers.')
+ self.urlhandlers = list(self._parse_list(data))
+
+ # done initializing data, finish connecting
+ return self._finish_connect()
+
+ def _finish_connect(self):
+ """
+ Called when connecting is completely done. Emit all signals.
+ """
+ self._logger.info('Successfully connected to MPD.')
+
+ self._socket.subsystems_changed.connect(self._mpd_changed)
+ self.connect_changed.emit(True)
+ self._mpd_changed()
+
+ @Slot()
+ def _handle_disconnected(self):
+ """
+ Called when connection is closed. Clear all cached data and emit
+ corresponding signals.
+ """
+ self._logger.info('Disconnected from MPD.')
+ self._commands = []
+ self.outputs = {}
+ self.tagtypes = []
+ self.urlhandlers = []
+
+ self._mpd_changed()
+ self.connect_changed.emit(False)
+
+ ################################
+
+ @Slot(list)
+ def _mpd_changed(self, subsystems = None):
+ """
+ Called when MPD signals a change in some subsystems.
+ """
+ if not subsystems:
+ subsystems = ['database', 'update', 'stored_playlist', 'playlist', 'output',
+ 'player', 'mixer', 'options']
+
+ if ('player' in subsystems or
+ 'mixer' in subsystems or
+ 'options' in subsystems):
+ self._command('status', callback = self._update_status)
+ if 'database' in subsystems:
+ self.db_updated.emit()
+ if 'update' in subsystems:
+ pass # just list for completeness
+ if 'stored_playlist' in subsystems:
+ pass
+ if 'playlist' in subsystems:
+ self.playlist_changed.emit()
+ if 'output' in subsystems:
+ self._command('outputs', callback = self._update_outputs)
+
+ def _update_outputs(self, data):
+ """
+ Update outputs states.
+ """
+ for output in self._parse_objects(data, ['outputid']):
+ self.outputs[int(output['outputid'])].update(output)
+
+ def _update_status(self, data):
+ """
+ Called when something in status has changed. Check what was it and emit
+ corresponding signals.
+ """
+ status = self.status
+ try:
+ self.status = MPDStatus(list(self._parse_objects(data, ''))[0])
+ except IndexError:
+ self.status = MPDStatus()
+
+ if self.status['state'] == 'play':
+ self._timer.start()
+ else:
+ self._timer.stop()
+
+ if status['state'] != self.status['state']:
+ self.state_changed.emit(self.status['state'])
+ if status['time'][0] != self.status['time'][0]:
+ self.time_changed.emit(self.status['time'][0])
+ if status['volume'] != self.status['volume']:
+ self.volume_changed.emit(self.status['volume'])
+ if status['repeat'] != self.status['repeat']:
+ self.repeat_changed.emit(self.status['repeat'])
+ if status['random'] != self.status['random']:
+ self.random_changed.emit(self.status['random'])
+ if status['single'] != self.status['single']:
+ self.single_changed.emit(self.status['single'])
+ if status['consume'] != self.status['consume']:
+ self.consume_changed.emit(self.status['consume'])
+ if status['playlist'] != self.status['playlist']:
+ self.playlist_changed.emit()
+ if status['songid'] != self.status['songid']:
+ self._command('currentsong', callback = self._update_cur_song)
+
+ def _update_cur_song(self, data):
+ try:
+ self.cur_song = Song(list(self._parse_objects(data, ''))[0])
+ except IndexError:
+ self.cur_song = Song()
+ self.song_changed.emit(self.cur_song)
+
+ def _command(self, *cmd, **kwargs):
+ """
+ Send specified command to MPD asynchronously. kwargs must contain
+ a callable 'callback' if the caller want to read a response. Otherwise
+ any reponse from MPD is silently discarded.
+ """
+ if not self.is_connected():
+ self._logger.debug('Not connected -- not running command: %s'%cmd[0])
+ if 'callback' in kwargs:
+ kwargs['callback']([])
+ elif not cmd[0] in self._commands:
+ self._logger.error('Command %s not allowed.'%cmd[0])
+ if 'callback' in kwargs:
+ kwargs['callback']([])
+ else:
+ self._socket.write_command(*cmd, **kwargs)
+ def _command_sync(self, *cmd, **kwargs):
+ """
+ Send specified command to MPD synchronously. kwargs must contain
+ a callable 'parse' used for parsing the reponse.
+ """
+ parse = kwargs['parse']
+
+ if not self.is_connected():
+ self._logger.debug('Not connected -- not running command: %s'%cmd[0])
+ return parse([])
+ elif not cmd[0] in self._commands:
+ self._logger.error('Command %s not allowed.'%cmd[0])
+ return parse([])
+ else:
+ return parse(self._socket.write_command_sync(*cmd))
+
+ def _set_output(self, out_id, val):
+ """
+ Enable/disable speciffied output. Called only by AudioOutput.
+ """
+ cmd = 'enableoutput' if val else 'disableoutput'
+ self._command(cmd, out_id)
+
+ def _update_timer(self):
+ self.status['time'][0] += 1
+ self.time_changed.emit(self.status['time'][0])
+
+ ## MPD output parsing functions ##
+
+ def _parse_list(self, data):
+ """
+ Parse a list of 'id_we_dont_care_about: useful_data'.
+ """
+ for line in data:
+ parts = line.partition(': ')
+ if not parts[1]:
+ self._logger.error('Malformed line: %s.'%line)
+ continue
+ yield parts[2]
+
+ def _parse_objects(self, data, delimiters):
+ """
+ Parse a list of object separated by specified delimiters.
+ """
+ cur = {}
+ for line in data:
+ parts = line.partition(': ')
+ if not parts[1]:
+ self._logger.error('Malformed line: %s.'%line)
+ continue
+
+ if parts[0] in delimiters and cur:
+ yield cur
+ cur = {}
+
+ if parts[0] in cur:
+ cur[parts[0]] += ',' + parts[2]
+ else:
+ cur[parts[0]] = parts[2]
+ if cur:
+ yield cur
+
+ def _parse_songs(self, data):
+ """
+ Parse a list of songs -- output of playlistinfo/listallinfo.
+ """
+ for song in self._parse_objects(data, ['file', 'directory']):
+ if 'file' in song:
+ yield Song(song)