import json import re import threading import websocket from platypush.backend import Backend from platypush.message.event.music import ( MusicPlayEvent, MusicPauseEvent, MusicStopEvent, NewPlayingTrackEvent, PlaylistChangeEvent, VolumeChangeEvent, PlaybackConsumeModeChangeEvent, PlaybackSingleModeChangeEvent, PlaybackRepeatModeChangeEvent, PlaybackRandomModeChangeEvent, MuteChangeEvent, SeekChangeEvent, ) # noinspection PyUnusedLocal class MusicMopidyBackend(Backend): """ This backend listens for events on a Mopidy music server streaming port. Since this backend leverages the Mopidy websocket interface it is only compatible with Mopidy and not with other MPD servers. Please use the :class:`platypush.backend.music.mpd.MusicMpdBackend` for a similar polling solution if you're not running Mopidy or your instance has the websocket interface or web port disabled. Requires: * A Mopidy instance running with the HTTP service enabled. """ def __init__(self, host='localhost', port=6680, **kwargs): super().__init__(**kwargs) self.host = host self.port = int(port) self.url = 'ws://{}:{}/mopidy/ws'.format(host, port) self._msg_id = 0 self._ws = None self._latest_status = {} self._reconnect_thread = None self._connected_event = threading.Event() try: self._latest_status = self._get_tracklist_status() except Exception as e: self.logger.warning('Unable to get mopidy status: {}'.format(str(e))) @staticmethod def _parse_track(track, pos=None): if not track: return {} conv_track = track.get('track', {}).copy() conv_track['id'] = track.get('tlid') conv_track['file'] = conv_track['uri'] del conv_track['uri'] if 'artists' in conv_track: conv_track['artist'] = conv_track['artists'][0].get('name') del conv_track['artists'] if 'name' in conv_track: conv_track['title'] = conv_track['name'] del conv_track['name'] if 'album' in conv_track: conv_track['album'] = conv_track['album']['name'] if 'length' in conv_track: conv_track['time'] = ( conv_track['length'] / 1000 if conv_track['length'] else conv_track['length'] ) del conv_track['length'] if pos is not None: conv_track['pos'] = pos if '__model__' in conv_track: del conv_track['__model__'] return conv_track def _communicate(self, msg): if isinstance(msg, str): msg = json.loads(msg) self._msg_id += 1 msg['jsonrpc'] = '2.0' msg['id'] = self._msg_id msg = json.dumps(msg) ws = websocket.create_connection(self.url) ws.send(msg) response = json.loads(ws.recv()).get('result') ws.close() return response def _get_tracklist_status(self): return { 'repeat': self._communicate({'method': 'core.tracklist.get_repeat'}), 'random': self._communicate({'method': 'core.tracklist.get_random'}), 'single': self._communicate({'method': 'core.tracklist.get_single'}), 'consume': self._communicate({'method': 'core.tracklist.get_consume'}), } def _on_msg(self): def hndl(*args): msg = args[1] if len(args) > 1 else args[0] msg = json.loads(msg) event = msg.get('event') if not event: return status = {} track = msg.get('tl_track', {}) if event == 'track_playback_paused': status['state'] = 'pause' track = self._parse_track(track) if not track: return self.bus.post( MusicPauseEvent(status=status, track=track, plugin_name='music.mpd') ) elif event == 'track_playback_resumed': status['state'] = 'play' track = self._parse_track(track) if not track: return self.bus.post( MusicPlayEvent(status=status, track=track, plugin_name='music.mpd') ) elif event == 'track_playback_ended' or ( event == 'playback_state_changed' and msg.get('new_state') == 'stopped' ): status['state'] = 'stop' track = self._parse_track(track) self.bus.post( MusicStopEvent(status=status, track=track, plugin_name='music.mpd') ) elif event == 'track_playback_started': track = self._parse_track(track) if not track: return status['state'] = 'play' status['position'] = 0.0 status['time'] = track.get('time') self.bus.post( NewPlayingTrackEvent( status=status, track=track, plugin_name='music.mpd' ) ) elif event == 'stream_title_changed': m = re.match(r'^\s*(.+?)\s+-\s+(.*)\s*$', msg.get('title', '')) if not m: return track['artist'] = m.group(1) track['title'] = m.group(2) status['state'] = 'play' status['position'] = 0.0 self.bus.post( NewPlayingTrackEvent( status=status, track=track, plugin_name='music.mpd' ) ) elif event == 'volume_changed': status['volume'] = msg.get('volume') self.bus.post( VolumeChangeEvent( volume=status['volume'], status=status, track=track, plugin_name='music.mpd', ) ) elif event == 'mute_changed': status['mute'] = msg.get('mute') self.bus.post( MuteChangeEvent( mute=status['mute'], status=status, track=track, plugin_name='music.mpd', ) ) elif event == 'seeked': status['position'] = msg.get('time_position') / 1000 self.bus.post( SeekChangeEvent( position=status['position'], status=status, track=track, plugin_name='music.mpd', ) ) elif event == 'tracklist_changed': tracklist = [ self._parse_track(t, pos=i) for i, t in enumerate( self._communicate({'method': 'core.tracklist.get_tl_tracks'}) ) ] self.bus.post( PlaylistChangeEvent(changes=tracklist, plugin_name='music.mpd') ) elif event == 'options_changed': new_status = self._get_tracklist_status() if new_status['random'] != self._latest_status.get('random'): self.bus.post( PlaybackRandomModeChangeEvent( state=new_status['random'], plugin_name='music.mpd' ) ) if new_status['repeat'] != self._latest_status['repeat']: self.bus.post( PlaybackRepeatModeChangeEvent( state=new_status['repeat'], plugin_name='music.mpd' ) ) if new_status['single'] != self._latest_status['single']: self.bus.post( PlaybackSingleModeChangeEvent( state=new_status['single'], plugin_name='music.mpd' ) ) if new_status['consume'] != self._latest_status['consume']: self.bus.post( PlaybackConsumeModeChangeEvent( state=new_status['consume'], plugin_name='music.mpd' ) ) self._latest_status = new_status return hndl def _retry_connect(self): def reconnect(): while not self.should_stop() and not self._connected_event.is_set(): try: self._connect() except Exception as e: self.logger.warning('Error on websocket reconnection: %s', e) self._connected_event.wait(timeout=10) self._reconnect_thread = None if not self._reconnect_thread or not self._reconnect_thread.is_alive(): self._reconnect_thread = threading.Thread(target=reconnect) self._reconnect_thread.start() def _on_error(self): def hndl(*args): error = args[1] if len(args) > 1 else args[0] ws = args[0] if len(args) > 1 else None self.logger.warning('Mopidy websocket error: {}'.format(error)) if ws: ws.close() return hndl def _on_close(self): def hndl(*_): self._connected_event.clear() self._ws = None self.logger.warning('Mopidy websocket connection closed') if not self.should_stop(): self._retry_connect() return hndl def _on_open(self): def hndl(*_): self._connected_event.set() self.logger.info('Mopidy websocket connected') return hndl def _connect(self): if not self._ws: self._ws = websocket.WebSocketApp( self.url, on_open=self._on_open(), on_message=self._on_msg(), on_error=self._on_error(), on_close=self._on_close(), ) self._ws.run_forever() def run(self): super().run() self.logger.info( 'Started tracking Mopidy events backend on {}:{}'.format( self.host, self.port ) ) self._connect() def on_stop(self): self.logger.info('Received STOP event on the Mopidy backend') if self._ws: self._ws.close() self.logger.info('Mopidy backend terminated') # vim:sw=4:ts=4:et: