# # Copyright (C) 2010 Anton Khirnov # # Nephilim is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Nephilim is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Nephilim. If not, see . # from PyQt5 import QtCore, QtNetwork from PyQt5.QtCore import pyqtSignal as Signal, pyqtSlot as Slot import logging import os.path from .song import Song from .mpdsocket import MPDSocket class AudioOutput(QtCore.QObject): """ This class represents an MPD audio output. Instances of this class are generated by MPClient, do not instantiate directly. """ #### PUBLIC #### # constants name = None # read-only state = None # SIGNALS state_changed = Signal(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): """ This class represent MPD status with a dict-like interface. Instances of this class are generated by MPClient, do not instantiate directly. """ #### PRIVATE #### """All standard status items.""" _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' : u'', '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'] = list(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 MPClient(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 set 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 """ A dictionary of Song objects representing all songs currently in the database, indexed by their paths. Changes when db_updated() signal is emitted. """ db = None """ A list of Song objects representing current playlist """ #playlist = None # SIGNALS connect_changed = Signal(bool) db_updated = Signal() time_changed = Signal(int) song_changed = Signal(object) songpos_changed = Signal(object) # this one's emitted when only current song's position changed state_changed = Signal(str) volume_changed = Signal(int) repeat_changed = Signal(bool) random_changed = Signal(bool) single_changed = Signal(bool) consume_changed = Signal(bool) crossfade_changed = Signal(int) playlist_changed = Signal() sticker_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 #### ## internals ## def __init__(self, parent = None): QtCore.QObject.__init__(self, parent) self._logger = logging.getLogger('%smpclient'%(str(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.db = {} #self.playlist = [] self.outputs = [] self.urlhandlers = [] self.tagtypes = set() def __str__(self): return self._logger.name ## connection functions ## 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 ## playlists ## 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 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) def shuffle(self): """ Shuffle the current playlist. """ self._command('shuffle') ## database ## def update_database(self): """ Initiates a database update. """ self._command('update') def rescan_database(self): """ Initiase a rebuild of database from scratch. """ self._command('rescan') ## searching ## 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 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) ## playback options ## 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', time) ## controlling playback ## 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) ## stickers ## def sticker_list(self, song, callback): """ Get a list of stickers for specified song. callback is called with an iterator over (key, value) tuples as argument. """ self._command('sticker', 'list', 'song', song, callback = lambda data: callback(self._parse_stickers(data))) def sticker_set(self, song, key, value): """ Set a sticker associated with song. """ self._command('sticker', 'set', 'song', song, key, value) def sticker_get(self, song, key, callback): """ Get a sticker with the given key associated with song. Callback is called with the sticker's value as argument. """ self._command('sticker', 'get', 'song', song, key, callback = lambda data: callback(self._parse_sticker(data)), report_errors = False) #### 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 = set(self._parse_list(data)) self.tagtypes.add('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 = set() 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', 'sticker'] if ('player' in subsystems or 'mixer' in subsystems or 'options' in subsystems): self._command('status', callback = self._update_status) if 'database' in subsystems: self._command('listallinfo', callback = lambda data: self._update_db(self._parse_songs(data))) if 'sticker' in subsystems: self.sticker_changed.emit() if 'update' in subsystems: pass # just list for completeness if 'stored_playlist' in subsystems: pass if 'playlist' in subsystems: self.playlist_changed.emit() self._command('currentsong', callback = self._update_cur_song) 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['xfade'] != self.status['xfade']: self.crossfade_changed.emit(self.status['xfade']) if status['songid'] != self.status['songid']: self._command('currentsong', callback = self._update_cur_song) def _update_cur_song(self, data): song = self.cur_song try: self.cur_song = Song(list(self._parse_objects(data, ''))[0]) except IndexError: self.cur_song = Song() if song['id'] != self.cur_song['id']: self.song_changed.emit(self.cur_song) if 'played' in song: self._update_playcount(song) else: self.songpos_changed.emit(self.cur_song) def _update_db(self, songs): for song in songs: self.db[song['file']] = song self.db_updated.emit() def _update_playcount(self, song): if not 'file' in song or os.path.isabs(song['file']): return self._logger.info('Incrementing the playcount for song %s.'%song['file']) self.sticker_get(song['file'], 'playcount', lambda val: self._update_playcount2(song, val)) def _update_playcount2(self, song, playcount): try: playcount = int(playcount) except ValueError: playcount = 0 playcount += 1 self.sticker_set(song['file'], 'playcount', playcount) 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 _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]) if not 'played' in self.cur_song: if self.status['time'][0] / self.status['time'][1] > 0.75: self.cur_song['played'] = True ## 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) def _parse_stickers(self, data): """ Parse a list of stickers -- output of sticker list. Yields (key, value) tuples. """ for sticker in self._parse_list(data): parts = sticker.partition('=') if not parts[1]: self._logger.error('Malformed sticker: %s.'%sticker) continue yield (parts[0], parts[2]) def _parse_sticker(self, data): """ Parse a single sticker -- output of sticker get. Returns the sticker's value. """ sticker = list(self._parse_list(data)) try: return sticker[0].partition('=')[2] except IndexError: return ""