From 1bd84eeb9026267d741764d01dbfb6acaeecc817 Mon Sep 17 00:00:00 2001 From: Anton Khirnov Date: Mon, 9 Aug 2010 18:38:46 +0200 Subject: mpclient: add a new asynchronous high-level MPD layer --- nephilim/mpclient.py | 565 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file 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 . # -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) -- cgit v1.2.3