[#295] Merged `music.mpd` plugin and backend.
continuous-integration/drone/push Build is passing Details

Closes: #295
This commit is contained in:
Fabio Manganiello 2024-03-03 22:36:47 +01:00
parent e96eae73ec
commit 2066db463b
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
8 changed files with 301 additions and 232 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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_KEY>
# api_secret: <API_SECRET>
# username: <USERNAME>
# password: <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.

View File

@ -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:

View File

@ -0,0 +1,12 @@
from dataclasses import dataclass
@dataclass
class MpdConfig:
"""
MPD configuration
"""
host: str = 'localhost'
port: int = 6600
password: str = ''

View File

@ -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:

View File

@ -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