diff --git a/platypush/app/_app.py b/platypush/app/_app.py index 589efb666..bf0054151 100644 --- a/platypush/app/_app.py +++ b/platypush/app/_app.py @@ -274,6 +274,10 @@ class Application: backend.stop() for plugin in runnable_plugins: + # This is required because some plugins may redefine the `stop` method. + # In that case, at the very least the _should_stop event should be + # set to notify the plugin to stop. + plugin._should_stop.set() # pylint: disable=protected-access plugin.stop() for backend in backends: diff --git a/platypush/backend/music/mpd/__init__.py b/platypush/backend/music/mpd/__init__.py deleted file mode 100644 index 30ced92ff..000000000 --- a/platypush/backend/music/mpd/__init__.py +++ /dev/null @@ -1,174 +0,0 @@ -import time - -from platypush.backend import Backend -from platypush.context import get_plugin -from platypush.message.event.music import ( - MusicPlayEvent, - MusicPauseEvent, - MusicStopEvent, - NewPlayingTrackEvent, - PlaylistChangeEvent, - VolumeChangeEvent, - PlaybackConsumeModeChangeEvent, - PlaybackSingleModeChangeEvent, - PlaybackRepeatModeChangeEvent, - PlaybackRandomModeChangeEvent, -) - - -class MusicMpdBackend(Backend): - """ - This backend listens for events on a MPD/Mopidy music server. - - Requires: - - * :class:`platypush.plugins.music.mpd.MusicMpdPlugin` configured - - """ - - def __init__(self, server='localhost', port=6600, poll_seconds=3, **kwargs): - """ - :param poll_seconds: Interval between queries to the server (default: 3 seconds) - :type poll_seconds: float - """ - - super().__init__(**kwargs) - - self.server = server - self.port = port - self.poll_seconds = poll_seconds - - def run(self): - super().run() - - last_status = {} - last_state = None - last_track = None - last_playlist = None - - while not self.should_stop(): - success = False - state = None - status = None - playlist = None - track = None - - while not success: - try: - plugin = get_plugin('music.mpd') - if not plugin: - raise StopIteration - - status = plugin.status().output - if not status or status.get('state') is None: - raise StopIteration - - track = plugin.currentsong().output - state = status['state'].lower() - playlist = status['playlist'] - success = True - except Exception as e: - self.logger.debug(e) - get_plugin('music.mpd', reload=True) - if not state: - state = last_state - if not playlist: - playlist = last_playlist - if not track: - track = last_track - finally: - time.sleep(self.poll_seconds) - - if state != last_state: - if state == 'stop': - self.bus.post( - MusicStopEvent( - status=status, track=track, plugin_name='music.mpd' - ) - ) - elif state == 'pause': - self.bus.post( - MusicPauseEvent( - status=status, track=track, plugin_name='music.mpd' - ) - ) - elif state == 'play': - self.bus.post( - MusicPlayEvent( - status=status, track=track, plugin_name='music.mpd' - ) - ) - - if playlist != last_playlist: - if last_playlist: - # XXX plchanges can become heavy with big playlists, - # PlaylistChangeEvent temporarily disabled - # changes = plugin.plchanges(last_playlist).output - # self.bus.post(PlaylistChangeEvent(changes=changes)) - self.bus.post(PlaylistChangeEvent(plugin_name='music.mpd')) - last_playlist = playlist - - if state == 'play' and track != last_track: - self.bus.post( - NewPlayingTrackEvent( - status=status, track=track, plugin_name='music.mpd' - ) - ) - - if last_status.get('volume') != status['volume']: - self.bus.post( - VolumeChangeEvent( - volume=int(status['volume']), - status=status, - track=track, - plugin_name='music.mpd', - ) - ) - - if last_status.get('random') != status['random']: - self.bus.post( - PlaybackRandomModeChangeEvent( - state=bool(int(status['random'])), - status=status, - track=track, - plugin_name='music.mpd', - ) - ) - - if last_status.get('repeat') != status['repeat']: - self.bus.post( - PlaybackRepeatModeChangeEvent( - state=bool(int(status['repeat'])), - status=status, - track=track, - plugin_name='music.mpd', - ) - ) - - if last_status.get('consume') != status['consume']: - self.bus.post( - PlaybackConsumeModeChangeEvent( - state=bool(int(status['consume'])), - status=status, - track=track, - plugin_name='music.mpd', - ) - ) - - if last_status.get('single') != status['single']: - self.bus.post( - PlaybackSingleModeChangeEvent( - state=bool(int(status['single'])), - status=status, - track=track, - plugin_name='music.mpd', - ) - ) - - last_status = status - last_state = state - last_track = track - time.sleep(self.poll_seconds) - - -# vim:sw=4:ts=4:et: diff --git a/platypush/backend/music/mpd/manifest.yaml b/platypush/backend/music/mpd/manifest.yaml deleted file mode 100644 index 5d3328b6c..000000000 --- a/platypush/backend/music/mpd/manifest.yaml +++ /dev/null @@ -1,22 +0,0 @@ -manifest: - events: - platypush.message.event.music.MusicPauseEvent: if the playback state changed to - pause - platypush.message.event.music.MusicPlayEvent: if the playback state changed to - play - platypush.message.event.music.MusicStopEvent: if the playback state changed to - stop - platypush.message.event.music.NewPlayingTrackEvent: if a new track is being played - platypush.message.event.music.PlaylistChangeEvent: if the main playlist has changed - platypush.message.event.music.VolumeChangeEvent: if the main volume has changed - install: - apt: - - python3-mpd2 - dnf: - - python-mpd2 - pacman: - - python-mpd2 - pip: - - python-mpd2 - package: platypush.backend.music.mpd - type: backend diff --git a/platypush/config/config.yaml b/platypush/config/config.yaml index a5ec7e0a2..6af32c3b8 100644 --- a/platypush/config/config.yaml +++ b/platypush/config/config.yaml @@ -320,6 +320,18 @@ backend.http: # port: 6600 ### +### +# # Example last.fm scrobbler configuration, to synchronize your music +# # activities to your Last.fm profile. You'll need to register an application +# # with your account at https://www.last.fm/api. +# +# lastfm: +# api_key: +# api_secret: +# username: +# password: +### + ### # # Plugins with empty configuration can also be explicitly enabled by specifying # # `enabled: true` or `disabled: false`. An integration with no items will be @@ -1100,6 +1112,23 @@ backend.http: # source: ${msg["source"]} ### +### +# # The example below is a hook that reacts when a `NewPlayingTrackEvent` event +# # is received and synchronize the listening activity to the users' Last.fm +# # profile (it requires the `lastfm` plugin and at least a music plugin +# # enabled, like `music.mpd`). +# +# event.hook.OnNewMusicActivity: +# if: +# type: platypush.message.event.music.NewPlayingTrackEvent +# then: +# - if ${track.get('artist') and track.get('title')}: +# - action: lastfm.scrobble +# args: +# artist: ${track['artist']} +# title: ${track['title']} +## + ### # # The example below plays the music on mpd/mopidy when your voice assistant # # triggers a speech recognized event with "play the music" content. diff --git a/platypush/plugins/music/mpd/__init__.py b/platypush/plugins/music/mpd/__init__.py index 0de6c483b..2dd54a16b 100644 --- a/platypush/plugins/music/mpd/__init__.py +++ b/platypush/plugins/music/mpd/__init__.py @@ -1,13 +1,15 @@ import re import threading -import time from typing import Collection, Optional, Union -from platypush.plugins import action +from platypush.plugins import RunnablePlugin, action from platypush.plugins.music import MusicPlugin +from ._conf import MpdConfig +from ._listener import MpdListener -class MusicMpdPlugin(MusicPlugin): + +class MusicMpdPlugin(MusicPlugin, RunnablePlugin): """ This plugin allows you to interact with an MPD/Mopidy music server. @@ -21,22 +23,29 @@ class MusicMpdPlugin(MusicPlugin): .. note:: As of Mopidy 3.0 MPD is an optional interface provided by the ``mopidy-mpd`` extension. Make sure that you have the extension - installed and enabled on your instance to use this plugin with your - server. + installed and enabled on your instance to use this plugin if you want to + use it with Mopidy instead of MPD. """ _client_lock = threading.RLock() - def __init__(self, host: str, port: int = 6600): + def __init__( + self, + host: str, + port: int = 6600, + poll_interval: Optional[float] = 5.0, + **kwargs, + ): """ - :param host: MPD IP/hostname - :param port: MPD port (default: 6600) + :param host: MPD IP/hostname. + :param port: MPD port (default: 6600). + :param poll_interval: Polling interval in seconds. If set, the plugin + will poll the MPD server for status updates and trigger change + events when required. Default: 5 seconds. """ - - super().__init__() - self.host = host - self.port = port + super().__init__(poll_interval=poll_interval, **kwargs) + self.conf = MpdConfig(host=host, port=port) self.client = None def _connect(self, n_tries: int = 2): @@ -51,7 +60,7 @@ class MusicMpdPlugin(MusicPlugin): try: n_tries -= 1 self.client = mpd.MPDClient() - self.client.connect(self.host, self.port) + self.client.connect(self.conf.host, self.conf.port) return self.client except Exception as e: error = e @@ -60,7 +69,7 @@ class MusicMpdPlugin(MusicPlugin): e, (': Retrying' if n_tries > 0 else ''), ) - time.sleep(0.5) + self.wait_stop(0.5) self.client = None if error: @@ -83,7 +92,8 @@ class MusicMpdPlugin(MusicPlugin): response = getattr(self.client, method)(*args, **kwargs) if return_status: - return self.status().output + return self._status() + return response except Exception as e: error = str(e) @@ -145,7 +155,7 @@ class MusicMpdPlugin(MusicPlugin): return self._exec('play') if status in ('pause', 'stop') else None @action - def stop(self, *_, **__): + def stop(self, *_, **__): # type: ignore """Stop playback""" return self._exec('stop') @@ -185,6 +195,9 @@ class MusicMpdPlugin(MusicPlugin): :param vol: Volume value (range: 0-100). """ + self.logger.warning( + 'music.mpd.setvol is deprecated, use music.mpd.set_volume instead' + ) return self.set_volume(vol) @action @@ -404,6 +417,9 @@ class MusicMpdPlugin(MusicPlugin): :param value: Seek position in seconds, or delta string (e.g. '+15' or '-15') to indicate a seek relative to the current position """ + self.logger.warning( + 'music.mpd.seekcur is deprecated, use music.mpd.seek instead' + ) return self.seek(value) @action @@ -472,6 +488,24 @@ class MusicMpdPlugin(MusicPlugin): """ return self._status() + def _current_track(self): + track = self._exec('currentsong', return_status=False) + if not isinstance(track, dict): + return None + + if 'title' in track and ( + 'artist' not in track + or not track['artist'] + or re.search('^https?://', track['file']) + or re.search('^tunein:', track['file']) + ): + m = re.match(r'^\s*(.+?)\s+-\s+(.*)\s*$', track['title']) + if m and m.group(1) and m.group(2): + track['artist'] = m.group(1) + track['title'] = m.group(2) + + return track + @action def currentsong(self): """ @@ -500,23 +534,7 @@ class MusicMpdPlugin(MusicPlugin): "x-albumuri": "spotify:album:6q5KhDhf9BZkoob7uAnq19" } """ - - track = self._exec('currentsong', return_status=False) - if not isinstance(track, dict): - return None - - if 'title' in track and ( - 'artist' not in track - or not track['artist'] - or re.search('^https?://', track['file']) - or re.search('^tunein:', track['file']) - ): - m = re.match(r'^\s*(.+?)\s+-\s+(.*)\s*$', track['title']) - if m and m.group(1) and m.group(2): - track['artist'] = m.group(1) - track['title'] = m.group(2) - - return track + return self._current_track() @action def playlistinfo(self): @@ -589,6 +607,9 @@ class MusicMpdPlugin(MusicPlugin): """ Deprecated alias for :meth:`.playlists`. """ + self.logger.warning( + 'music.mpd.listplaylists is deprecated, use music.mpd.get_playlists instead' + ) return self.get_playlists() @action @@ -611,6 +632,9 @@ class MusicMpdPlugin(MusicPlugin): """ Deprecated alias for :meth:`.playlist`. """ + self.logger.warning( + 'music.mpd.listplaylist is deprecated, use music.mpd.get_playlist instead' + ) return self._exec('listplaylist', name, return_status=False) @action @@ -618,10 +642,15 @@ class MusicMpdPlugin(MusicPlugin): """ Deprecated alias for :meth:`.playlist` with ``with_tracks=True``. """ + self.logger.warning( + 'music.mpd.listplaylistinfo is deprecated, use music.mpd.get_playlist instead' + ) return self.get_playlist(name, with_tracks=True) @action - def add_to_playlist(self, playlist: str, resources: Union[str, Collection[str]]): + def add_to_playlist( + self, playlist: str, resources: Union[str, Collection[str]], **_ + ): """ Add one or multiple resources to a playlist. @@ -640,6 +669,9 @@ class MusicMpdPlugin(MusicPlugin): """ Deprecated alias for :meth:`.add_to_playlist`. """ + self.logger.warning( + 'music.mpd.playlistadd is deprecated, use music.mpd.add_to_playlist instead' + ) return self.add_to_playlist(name, uri) @action @@ -677,6 +709,9 @@ class MusicMpdPlugin(MusicPlugin): """ Deprecated alias for :meth:`.remove_from_playlist`. """ + self.logger.warning( + 'music.mpd.playlistdelete is deprecated, use music.mpd.remove_from_playlist instead' + ) return self.remove_from_playlist(name, pos) @action @@ -684,6 +719,9 @@ class MusicMpdPlugin(MusicPlugin): """ Deprecated alias for :meth:`.playlist_move`. """ + self.logger.warning( + 'music.mpd.playlistmove is deprecated, use music.mpd.playlist_move instead' + ) return self.playlist_move(name, from_pos=from_pos, to_pos=to_pos) @action @@ -816,7 +854,9 @@ class MusicMpdPlugin(MusicPlugin): ) @action - def searchadd(self, filter: dict, *args, **kwargs): + def searchadd( + self, filter: dict, *args, **kwargs # pylint: disable=redefined-builtin + ): """ Free search by filter and add the results to the current playlist. @@ -828,5 +868,16 @@ class MusicMpdPlugin(MusicPlugin): 'searchadd', *filter_list, *args, return_status=False, **kwargs ) + def main(self): + listener = None + if self.poll_interval is not None: + listener = MpdListener(self) + listener.start() + + self.wait_stop() + + if listener: + listener.join() + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/music/mpd/_conf.py b/platypush/plugins/music/mpd/_conf.py new file mode 100644 index 000000000..313aa5422 --- /dev/null +++ b/platypush/plugins/music/mpd/_conf.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@dataclass +class MpdConfig: + """ + MPD configuration + """ + + host: str = 'localhost' + port: int = 6600 + password: str = '' diff --git a/platypush/plugins/music/mpd/_listener.py b/platypush/plugins/music/mpd/_listener.py new file mode 100644 index 000000000..0c0ea6214 --- /dev/null +++ b/platypush/plugins/music/mpd/_listener.py @@ -0,0 +1,163 @@ +from dataclasses import dataclass, field +from threading import Thread +from typing import Optional + +from platypush.context import get_bus +from platypush.message.event.music import ( + MusicPlayEvent, + MusicPauseEvent, + MusicStopEvent, + NewPlayingTrackEvent, + PlaylistChangeEvent, + VolumeChangeEvent, + PlaybackConsumeModeChangeEvent, + PlaybackSingleModeChangeEvent, + PlaybackRepeatModeChangeEvent, + PlaybackRandomModeChangeEvent, +) +from platypush.plugins.music import MusicPlugin + + +@dataclass +class MpdStatus: + """ + Data class for the MPD status. + """ + + state: Optional[str] = None + playlist: Optional[int] = None + volume: Optional[int] = None + random: Optional[bool] = None + repeat: Optional[bool] = None + consume: Optional[bool] = None + single: Optional[bool] = None + track: dict = field(default_factory=dict) + + +class MpdListener(Thread): + """ + Thread that listens/polls for MPD events and posts them to the bus. + """ + + def __init__(self, plugin: MusicPlugin, *_, **__): + from . import MusicMpdPlugin + + super().__init__(name='platypush:mpd:listener') + assert isinstance(plugin, MusicMpdPlugin) + self.plugin: MusicMpdPlugin = plugin + self._status = MpdStatus() + + @property + def logger(self): + return self.plugin.logger + + @property + def bus(self): + return get_bus() + + def wait_stop(self, timeout=None): + self.plugin.wait_stop(timeout=timeout) + + def _process_events(self, status: dict, track: Optional[dict] = None): + state = status.get('state', '').lower() + evt_args = {'status': status, 'track': track, 'plugin_name': 'music.mpd'} + + if state != self._status.state: + if state == 'stop': + self.bus.post(MusicStopEvent(**evt_args)) + elif state == 'pause': + self.bus.post(MusicPauseEvent(**evt_args)) + elif state == 'play': + self.bus.post(MusicPlayEvent(**evt_args)) + + if status.get('playlist') != self._status.playlist and self._status.playlist: + # XXX plchanges can become heavy with big playlists, + # PlaylistChangeEvent temporarily disabled + # changes = plugin.plchanges(last_playlist).output + # self.bus.post(PlaylistChangeEvent(changes=changes)) + self.bus.post(PlaylistChangeEvent(plugin_name='music.mpd')) + + if state == 'play' and track != self._status.track: + self.bus.post(NewPlayingTrackEvent(**evt_args)) + + if ( + status.get('volume') is not None + and status.get('volume') != self._status.volume + ): + self.bus.post(VolumeChangeEvent(volume=int(status['volume']), **evt_args)) + + if ( + status.get('random') is not None + and status.get('random') != self._status.random + ): + self.bus.post( + PlaybackRandomModeChangeEvent( + state=bool(int(status['random'])), **evt_args + ) + ) + + if ( + status.get('repeat') is not None + and status.get('repeat') != self._status.repeat + ): + self.bus.post( + PlaybackRepeatModeChangeEvent( + state=bool(int(status['repeat'])), **evt_args + ) + ) + + if ( + status.get('consume') is not None + and status.get('consume') != self._status.consume + ): + self.bus.post( + PlaybackConsumeModeChangeEvent( + state=bool(int(status['consume'])), **evt_args + ) + ) + + if ( + status.get('single') is not None + and status.get('single') != self._status.single + ): + self.bus.post( + PlaybackSingleModeChangeEvent( + state=bool(int(status['single'])), **evt_args + ) + ) + + def _update_status(self, status: dict, track: Optional[dict] = None): + self._status = MpdStatus( + state=status.get('state', '').lower(), + playlist=status.get('playlist'), + volume=status.get('volume'), + random=status.get('random'), + repeat=status.get('repeat'), + consume=status.get('consume'), + single=status.get('single'), + track=track or {}, + ) + + def run(self): + super().run() + + while not self.plugin.should_stop(): + try: + status = self.plugin._status() # pylint: disable=protected-access + assert status and status.get('state'), 'No status returned' + if not (status and status.get('state')): + self.wait_stop(self.plugin.poll_interval) + break + + track = self.plugin._current_track() # pylint: disable=protected-access + self._process_events(status, track) + self._update_status(status, track) + except Exception as e: + self.logger.warning( + 'Could not retrieve the latest status: %s', e, exc_info=True + ) + finally: + self.wait_stop(self.plugin.poll_interval) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/music/mpd/manifest.yaml b/platypush/plugins/music/mpd/manifest.yaml index 8e9f5a7dd..fafe723ac 100644 --- a/platypush/plugins/music/mpd/manifest.yaml +++ b/platypush/plugins/music/mpd/manifest.yaml @@ -1,5 +1,11 @@ manifest: - events: {} + events: + - platypush.message.event.music.MusicPauseEvent + - platypush.message.event.music.MusicPlayEvent + - platypush.message.event.music.MusicStopEvent + - platypush.message.event.music.NewPlayingTrackEvent + - platypush.message.event.music.PlaylistChangeEvent + - platypush.message.event.music.VolumeChangeEvent install: apt: - python3-mpd