[WIP] music.mopidy refactor, initial backend rewrite.

This commit is contained in:
Fabio Manganiello 2024-04-02 16:18:11 +02:00
parent d2e5e5230b
commit 89d618b35f
20 changed files with 2198 additions and 339 deletions

View file

@ -8,7 +8,6 @@ Backends
platypush/backend/http.rst platypush/backend/http.rst
platypush/backend/midi.rst platypush/backend/midi.rst
platypush/backend/music.mopidy.rst
platypush/backend/music.spotify.rst platypush/backend/music.spotify.rst
platypush/backend/nodered.rst platypush/backend/nodered.rst
platypush/backend/redis.rst platypush/backend/redis.rst

View file

@ -1,6 +0,0 @@
``music.mopidy``
==================================
.. automodule:: platypush.backend.music.mopidy
:members:

View file

@ -0,0 +1,5 @@
``music.mopidy``
================
.. automodule:: platypush.plugins.music.mopidy
:members:

View file

@ -84,6 +84,7 @@ Plugins
platypush/plugins/ml.cv.rst platypush/plugins/ml.cv.rst
platypush/plugins/mobile.join.rst platypush/plugins/mobile.join.rst
platypush/plugins/mqtt.rst platypush/plugins/mqtt.rst
platypush/plugins/music.mopidy.rst
platypush/plugins/music.mpd.rst platypush/plugins/music.mpd.rst
platypush/plugins/music.snapcast.rst platypush/plugins/music.snapcast.rst
platypush/plugins/music.spotify.rst platypush/plugins/music.spotify.rst

View file

@ -1,324 +0,0 @@
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:

View file

@ -1,5 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional from typing import Dict, Iterable, Optional
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
@ -107,5 +107,18 @@ class MusicPlugin(Plugin, ABC):
def search(self, query, **kwargs): def search(self, query, **kwargs):
raise NotImplementedError() raise NotImplementedError()
@action
def get_images(self, resources: Iterable[str], **__) -> Dict[str, Optional[str]]:
"""
Get the images for a list of URIs.
.. note:: This is an optional action, and it may not be implemented by all plugins.
If the plugin doesn't implement this action, it will return an empty dictionary.
:param uris: List of URIs.
:return: Dictionary in the form ``{uri: image_url}``.
"""
return {}
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,486 @@
import json
import logging
import re
import time
from dataclasses import asdict
from queue import Empty
from threading import Event, RLock, Thread
from typing import Dict, Generator, List, Optional, Type
import websocket
from platypush.context import get_bus
from platypush.message.event.music import (
MusicEvent,
MusicPauseEvent,
MusicPlayEvent,
MusicStopEvent,
MuteChangeEvent,
NewPlayingTrackEvent,
PlaybackConsumeModeChangeEvent,
PlaybackRandomModeChangeEvent,
PlaybackRepeatModeChangeEvent,
PlaybackSingleModeChangeEvent,
PlaylistChangeEvent,
SeekChangeEvent,
VolumeChangeEvent,
)
from platypush.plugins.media import PlayerState
from ._common import DEFAULT_TIMEOUT
from ._conf import MopidyConfig
from ._status import MopidyStatus
from ._sync import PlaylistSync
from ._task import MopidyTask
from ._track import MopidyTrack
class MopidyClient(Thread):
"""
Thread that listens for Mopidy events and posts them to the bus.
"""
def __init__(
self,
config: MopidyConfig,
status: MopidyStatus,
stop_event: Event,
playlist_sync: PlaylistSync,
tasks: Dict[int, MopidyTask],
**_,
):
super().__init__(name='platypush:mopidy:listener')
self.logger = logging.getLogger('platypush:mopidy:listener')
self.config = config
self._status = status
self._stop_event = stop_event
self._playlist_sync = playlist_sync
self._tasks = tasks
self._refresh_in_progress = Event()
self._refresh_lock = RLock()
self._req_lock = RLock()
self._close_lock = RLock()
self._tracks: List[MopidyTrack] = []
self._msg_id = 0
self._ws = None
self.connected_event = Event()
self.closed_event = Event()
@property
def _bus(self):
return get_bus()
@property
def status(self):
return self._status
@property
def tracks(self):
return self._tracks
def should_stop(self):
return self._stop_event.is_set()
def wait_stop(self, timeout: Optional[float] = None):
self._stop_event.wait(timeout=timeout)
def make_task(self, method: str, **args: dict) -> MopidyTask:
with self._req_lock:
self._msg_id += 1
task = MopidyTask(
id=self._msg_id,
method=method,
args=args or {},
)
self._tasks[task.id] = task
return task
def send(self, *tasks: MopidyTask):
"""
Send a list of tasks to the Mopidy server.
"""
assert self._ws, 'Websocket not connected'
for task in tasks:
with self._req_lock:
task.send(self._ws)
def gather(
self,
*tasks: MopidyTask,
timeout: Optional[float] = DEFAULT_TIMEOUT,
) -> Generator:
t_start = time.time()
for task in tasks:
remaining_timeout = (
max(0, timeout - (time.time() - t_start)) if timeout else None
)
if not self._tasks.get(task.id):
yield None
try:
ret = self._tasks[task.id].get_response(timeout=remaining_timeout)
assert not isinstance(ret, Exception), ret
self.logger.debug('Got response for %s: %s', task, ret)
yield ret
except Empty as e:
t = self._tasks.get(task.id)
err = 'Mopidy request timeout'
if t:
err += f' - method: {t.method} args: {t.args}'
raise TimeoutError(err) from e
finally:
self._tasks.pop(task.id, None)
def exec(self, *msgs: dict, timeout: Optional[float] = DEFAULT_TIMEOUT) -> list:
tasks = [self.make_task(**msg) for msg in msgs]
for task in tasks:
self.send(task)
return list(self.gather(*tasks, timeout=timeout))
def refresh_status( # pylint: disable=too-many-branches
self, timeout: Optional[float] = DEFAULT_TIMEOUT, with_tracks: bool = False
):
if self._refresh_in_progress.is_set():
return
events = []
try:
with self._refresh_lock:
self._refresh_in_progress.set()
# Refresh the tracklist attributes
opts = ('repeat', 'random', 'single', 'consume')
ret = self.exec(
*[
*[{'method': f'core.tracklist.get_{opt}'} for opt in opts],
{'method': 'core.playback.get_current_tl_track'},
{'method': 'core.playback.get_state'},
{'method': 'core.mixer.get_volume'},
{'method': 'core.playback.get_time_position'},
*(
[{'method': 'core.tracklist.get_tl_tracks'}]
if with_tracks
else []
),
],
timeout=timeout,
)
for i, opt in enumerate(opts):
new_value = ret[i]
if opt == 'random' and self._status.random != new_value:
events.append(
(PlaybackRandomModeChangeEvent, {'state': new_value})
)
if opt == 'repeat' and self._status.repeat != new_value:
events.append(
(PlaybackRepeatModeChangeEvent, {'state': new_value})
)
if opt == 'single' and self._status.single != new_value:
events.append(
(PlaybackSingleModeChangeEvent, {'state': new_value})
)
if opt == 'consume' and self._status.consume != new_value:
events.append(
(PlaybackConsumeModeChangeEvent, {'state': new_value})
)
setattr(self._status, opt, new_value)
# Get remaining info
track = MopidyTrack.parse(ret[4])
state, volume, t = ret[5:8]
if track:
idx = self.exec(
{
'method': 'core.tracklist.index',
'tlid': track.track_id,
},
timeout=timeout,
)[0]
self._status.track = track
self._status.duration = track.time
if idx is not None:
self._status.playing_pos = self._status.track.playlist_pos = idx
if track != self._status.track and state != 'stopped':
events.append((NewPlayingTrackEvent, {}))
if state != self._status.state:
if state == 'paused':
self._status.state = PlayerState.PAUSE
events.append((MusicPauseEvent, {}))
elif state == 'playing':
self._status.state = PlayerState.PLAY
events.append((MusicPlayEvent, {}))
elif state == 'stopped':
self._status.state = PlayerState.STOP
events.append((MusicStopEvent, {}))
if volume != self._status.volume:
self._status.volume = volume
events.append((VolumeChangeEvent, {'volume': volume}))
if t != self._status.time:
self._status.time = t / 1000
events.append((SeekChangeEvent, {'position': self._status.time}))
if with_tracks:
self._tracks = [ # type: ignore
MopidyTrack.parse({**t, 'playlist_pos': i})
for i, t in enumerate(ret[8])
]
for evt in events:
self._post_event(evt[0], **evt[1])
finally:
self._refresh_in_progress.clear()
def _refresh_status(
self, timeout: Optional[float] = DEFAULT_TIMEOUT, with_tracks: bool = False
):
"""
Refresh the status from the Mopidy server.
It runs in a separate thread because the status refresh logic runs in
synchronous mode, and it would block the main thread preventing the
listener from receiving new messages.
Also, an event+reenrant lock mechanism is used to ensure that only one
refresh task is running at a time.
"""
if self._refresh_in_progress.is_set():
return
with self._refresh_lock:
Thread(
target=self.refresh_status,
kwargs={'timeout': timeout, 'with_tracks': with_tracks},
daemon=True,
).start()
def _post_event(self, evt_cls: Type[MusicEvent], **kwargs):
self._bus.post(
evt_cls(
status=asdict(self._status),
track=asdict(self._status.track) if self._status.track else None,
plugin_name='music.mopidy',
**kwargs,
)
)
def _handle_error(self, msg: dict):
msg_id = msg.get('id')
err = msg.get('error')
if not err:
return
err_data = err.get('data', {})
tb = err_data.get('traceback')
self.logger.warning(
'Mopidy error: %s: %s: %s',
err.get('message'),
err_data.get('type'),
err_data.get('message'),
)
if tb:
self.logger.warning(tb)
if msg_id:
task = self._tasks.get(msg_id)
if task:
task.put_response(
RuntimeError(err.get('message') + ': ' + err_data.get('message'))
)
def on_pause(self, *_, **__):
self._status.state = PlayerState.PAUSE
self._post_event(MusicPauseEvent)
def on_resume(self, *_, **__):
self._status.state = PlayerState.PLAY
self._post_event(MusicPlayEvent)
def on_start(self, *_, **__):
self._refresh_status()
def on_end(self, *_, **__):
self._refresh_status()
def on_state_change(self, msg: dict, *_, **__):
state = msg.get('new_state')
if state == PlayerState.PLAY:
self._status.state = PlayerState.PLAY
self._post_event(MusicPlayEvent)
elif state == PlayerState.PAUSE:
self._status.state = PlayerState.PAUSE
self._post_event(MusicPauseEvent)
elif state == PlayerState.STOP:
self._status.state = PlayerState.STOP
self._post_event(MusicStopEvent)
def on_title_change(self, msg: dict, *_, track: MopidyTrack, **__):
title = msg.get('title', '')
m = re.match(r'^\s*(.+?)\s+-\s+(.*)\s*$', title)
if not m:
return
track.artist = m.group(1)
track.title = m.group(2)
self._post_event(NewPlayingTrackEvent)
def on_volume_change(self, msg: dict, *_, **__):
volume = msg.get('volume')
if volume is None:
return
self._status.volume = volume
self._post_event(VolumeChangeEvent, volume=volume)
def on_mute_change(self, msg: dict, *_, **__):
mute = msg.get('mute')
if mute is None:
return
self._status.mute = mute
self._post_event(MuteChangeEvent, mute=mute)
def on_seek(self, msg: dict, *_, **__):
position = msg.get('time_position')
if position is None:
return
self._status.time = position / 1000
self._post_event(SeekChangeEvent, position=self._status.time)
def on_tracklist_change(self, *_, **__):
should_proceed = self._playlist_sync.wait_for_loading(timeout=2)
if not should_proceed:
return
self.logger.debug('Tracklist changed, refreshing changes')
self._refresh_status(with_tracks=True)
self._post_event(PlaylistChangeEvent)
def on_options_change(self, *_, **__):
self._refresh_status()
def _on_msg(self, *args):
msg = args[1] if len(args) > 1 else args[0]
msg = json.loads(msg)
msg_id = msg.get('id')
event = msg.get('event')
track: Optional[MopidyTrack] = None
self.logger.debug('Received Mopidy message: %s', msg)
if msg.get('error'):
self._handle_error(msg)
return
if msg_id:
task = self._tasks.get(msg_id)
if task:
task.put_response(msg)
return
if not event:
return
if msg.get('tl_track'):
track = self._status.track = MopidyTrack.parse(msg['tl_track'])
hndl = self._msg_handlers.get(event)
if not hndl:
return
hndl(self, msg, track=track)
def _on_error(self, *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: %s', error)
if ws:
ws.close()
def _on_close(self, *_):
self.connected_event.clear()
self.closed_event.set()
if self._ws:
try:
self._ws.close()
except Exception as e:
self.logger.debug(e, exc_info=True)
finally:
self._ws = None
self.logger.warning('Mopidy websocket connection closed')
def _on_open(self, *_):
self.connected_event.set()
self.closed_event.clear()
self.logger.info('Mopidy websocket connected')
self._refresh_status(with_tracks=True)
def _connect(self):
if not self._ws:
self._ws = websocket.WebSocketApp(
self.config.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):
while not self.should_stop():
try:
self._connect()
except Exception as e:
self.logger.warning(
'Error on websocket connection: %s', e, exc_info=True
)
finally:
self.connected_event.clear()
self.closed_event.set()
self.wait_stop(10)
def stop(self):
with self._close_lock:
if self._ws:
self._ws.close()
self._ws = None
def __enter__(self):
return self
def __exit__(self, *_):
self.stop()
_msg_handlers = {
'track_playback_paused': on_pause,
'playback_state_changed': on_state_change,
'track_playback_resumed': on_resume,
'track_playback_ended': on_end,
'track_playback_started': on_start,
'stream_title_changed': on_title_change,
'volume_changed': on_volume_change,
'mute_changed': on_mute_change,
'seeked': on_seek,
'tracklist_changed': on_tracklist_change,
'options_changed': on_options_change,
}
# vim:sw=4:ts=4:et:

View file

@ -0,0 +1,4 @@
from typing import Final
DEFAULT_TIMEOUT: Final[float] = 20

View file

@ -0,0 +1,23 @@
from dataclasses import dataclass
from typing import Optional
from ._common import DEFAULT_TIMEOUT
@dataclass
class MopidyConfig:
"""
Mopidy configuration.
"""
host: str = 'localhost'
port: int = 6680
ssl: bool = False
timeout: Optional[float] = DEFAULT_TIMEOUT
@property
def url(self) -> str:
return f'ws{"s" if self.ssl else ""}://{self.host}:{self.port}/mopidy/ws'
# vim:sw=4:ts=4:et:

View file

@ -0,0 +1,10 @@
class MopidyException(Exception):
"""
Base class for all Mopidy exceptions.
"""
class EmptyTrackException(MopidyException, ValueError):
"""
Raised when a parsed track is empty.
"""

View file

@ -0,0 +1,33 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional
from platypush.schemas.mopidy import MopidyPlaylistSchema
from ._track import MopidyTrack
@dataclass
class MopidyPlaylist:
"""
Model for a Mopidy playlist.
"""
uri: str
name: str
last_modified: Optional[datetime] = None
tracks: List[MopidyTrack] = field(default_factory=list)
type: str = "playlist"
@classmethod
def parse(cls, playlist: dict) -> "MopidyPlaylist":
"""
Parse a Mopidy playlist from a dictionary received from the Mopidy API.
"""
return cls(**MopidyPlaylistSchema().load(playlist)) # type: ignore
def to_dict(self) -> dict:
"""
Convert the Mopidy playlist to a dictionary.
"""
return dict(MopidyPlaylistSchema().dump(self))

View file

@ -0,0 +1,47 @@
from dataclasses import asdict, dataclass
from typing import Optional
from platypush.plugins.media import PlayerState
from platypush.schemas.mopidy import MopidyStatusSchema
from ._track import MopidyTrack
@dataclass
class MopidyStatus:
"""
A dataclass to hold the status of the Mopidy client.
"""
state: PlayerState = PlayerState.STOP
volume: float = 0
consume: bool = False
random: bool = False
repeat: bool = False
single: bool = False
mute: bool = False
time: Optional[float] = None
duration: Optional[float] = None
playing_pos: Optional[int] = None
track: Optional[MopidyTrack] = None
def copy(self):
return MopidyStatus(
state=self.state,
volume=self.volume,
consume=self.consume,
random=self.random,
repeat=self.repeat,
single=self.single,
mute=self.mute,
time=self.time,
duration=self.duration,
playing_pos=self.playing_pos,
track=MopidyTrack(**asdict(self.track)) if self.track else None,
)
def to_dict(self):
"""
Convert the Mopidy status to a dictionary.
"""
return dict(MopidyStatusSchema().dump(self))

View file

@ -0,0 +1,50 @@
from dataclasses import dataclass, field
from threading import Event, RLock
from typing import Optional
@dataclass
class PlaylistSync:
"""
Object used to synchronize playlist load/change events between threads.
"""
_loading_lock: RLock = field(default_factory=RLock)
_loading: Event = field(default_factory=Event)
_loaded: Event = field(default_factory=Event)
def wait_for_loading(self, timeout: Optional[float] = None):
"""
Wait for the playlist to be loaded.
:param timeout: The maximum time to wait for the playlist to be loaded.
"""
# If the loading event is not set, no playlist change - we can proceed
# with notifying the event.
if not self._loading.is_set():
return True
# Wait for the full playlist to be loaded.
return self._loaded.wait(timeout)
def __enter__(self):
"""
Called when entering a context manager to handle a playlist loading
session.
"""
self._loading_lock.acquire()
self._loading.set()
self._loaded.clear()
def __exit__(self, *_):
"""
Called when exiting a context manager to handle a playlist loading
session.
"""
self._loading.clear()
self._loaded.set()
try:
self._loading_lock.release()
except RuntimeError:
pass

View file

@ -0,0 +1,58 @@
import json
from dataclasses import dataclass, field
from queue import Queue
from threading import Event
from typing import Any, Optional, Union
from websocket import WebSocketApp
from ._common import DEFAULT_TIMEOUT
@dataclass
class MopidyTask:
"""
A task to be executed by the Mopidy client.
"""
id: int
method: str
args: dict = field(default_factory=dict)
response: Optional[Any] = None
response_ready: Event = field(default_factory=Event)
response_queue: Queue = field(default_factory=Queue)
def to_dict(self):
return {
"jsonrpc": "2.0",
"id": self.id,
"method": self.method,
'params': self.args,
}
def __str__(self):
return json.dumps(self.to_dict())
def send(self, ws: WebSocketApp):
assert ws, "Websocket connection not established"
self.response_ready.clear()
ws.send(str(self))
def get_response(self, timeout: Optional[float] = DEFAULT_TIMEOUT) -> Any:
ret = self.response_queue.get(timeout=timeout)
if isinstance(ret, dict):
ret = ret.get('result')
return ret
def put_response(self, response: Union[dict, Exception]):
self.response = response
self.response_ready.set()
self.response_queue.put_nowait(response)
def wait(self, timeout: Optional[float] = DEFAULT_TIMEOUT) -> bool:
return self.response_ready.wait(timeout=timeout)
def run(self, ws: WebSocketApp, timeout: Optional[float] = DEFAULT_TIMEOUT):
self.send(ws)
return self.get_response(timeout=timeout)

View file

@ -0,0 +1,43 @@
from dataclasses import dataclass
from typing import Optional
from platypush.schemas.mopidy import MopidyTrackSchema
from ._exc import EmptyTrackException
@dataclass
class MopidyTrack:
"""
Model for a Mopidy track.
"""
uri: str
artist: Optional[str] = None
title: Optional[str] = None
album: Optional[str] = None
artist_uri: Optional[str] = None
album_uri: Optional[str] = None
time: Optional[float] = None
playlist_pos: Optional[int] = None
track_id: Optional[int] = None
track_no: Optional[int] = None
date: Optional[str] = None
genre: Optional[str] = None
type: str = 'track'
@classmethod
def parse(cls, track: dict) -> Optional["MopidyTrack"]:
"""
Parse a Mopidy track from a dictionary received from the Mopidy API.
"""
try:
return cls(**MopidyTrackSchema().load(track)) # type: ignore
except EmptyTrackException:
return None
def to_dict(self) -> dict:
"""
Convert the Mopidy track to a dictionary.
"""
return dict(MopidyTrackSchema().dump(self))

View file

@ -13,5 +13,5 @@ manifest:
platypush.message.event.music.VolumeChangeEvent: if the main volume has changed platypush.message.event.music.VolumeChangeEvent: if the main volume has changed
install: install:
pip: [] pip: []
package: platypush.backend.music.mopidy package: platypush.plugins.music.mopidy
type: backend type: plugin

View file

@ -21,6 +21,18 @@ class MusicMpdPlugin(MusicPlugin, RunnablePlugin):
the original protocol and with support for multiple music sources through the original protocol and with support for multiple music sources through
plugins (e.g. Spotify, TuneIn, Soundcloud, local files etc.). plugins (e.g. Spotify, TuneIn, Soundcloud, local files etc.).
.. note:: If you use Mopidy, and unless you have quite specific use-cases
(like you don't want to expose the Mopidy HTTP interface, or you have
some legacy automation that uses the MPD interface), you should use the
:class:`platypush.plugins.music.mopidy.MusicMopidyPlugin` plugin instead
of this. The Mopidy plugin provides a more complete and feature-rich
experience, as not all the features of Mopidy are available through the
MPD interface, and its API is 100% compatible with this plugin. Also,
this plugin operates a synchronous/polling logic because of the
limitations of the MPD protocol, while the Mopidy plugin, as it uses the
Mopidy Websocket API, can operate in a more efficient way and provide
real-time updates.
.. note:: As of Mopidy 3.0 MPD is an optional interface provided by the .. note:: As of Mopidy 3.0 MPD is an optional interface provided by the
``mopidy-mpd`` extension. Make sure that you have the extension ``mopidy-mpd`` extension. Make sure that you have the extension
installed and enabled on your instance to use this plugin if you want to installed and enabled on your instance to use this plugin if you want to
@ -645,11 +657,12 @@ class MusicMpdPlugin(MusicPlugin, RunnablePlugin):
self._exec('rename', playlist, new_name) self._exec('rename', playlist, new_name)
@action @action
def lsinfo(self, uri: Optional[str] = None): def browse(self, uri: Optional[str] = None):
"""
Returns the list of playlists and directories on the server.
""" """
Browse the items under the specified URI.
:param uri: URI to browse (default: root directory).
"""
return ( return (
self._exec('lsinfo', uri, return_status=False) self._exec('lsinfo', uri, return_status=False)
if uri if uri

325
platypush/schemas/mopidy.py Normal file
View file

@ -0,0 +1,325 @@
from marshmallow import EXCLUDE, fields, post_dump, post_load, pre_dump, pre_load
from marshmallow.schema import Schema
from platypush.plugins.media import PlayerState
from platypush.schemas import DateTime
class MopidyTrackSchema(Schema):
"""
Mopidy track schema.
"""
uri = fields.String(required=True, metadata={"description": "Track URI"})
file = fields.String(
metadata={"description": "Track URI, for MPD compatibility purposes"}
)
artist = fields.String(missing=None, metadata={"description": "Artist name"})
title = fields.String(missing=None, metadata={"description": "Track title"})
album = fields.String(missing=None, metadata={"description": "Album name"})
artist_uri = fields.String(
missing=None, metadata={"description": "Artist URI (if available)"}
)
album_uri = fields.String(
missing=None, metadata={"description": "Album URI (if available)"}
)
time = fields.Float(
missing=None, metadata={"description": "Track length (in seconds)"}
)
playlist_pos = fields.Integer(
missing=None,
metadata={"description": "Track position in the tracklist/playlist"},
)
track_id = fields.Integer(
missing=None, metadata={"description": "Track ID in the current tracklist"}
)
track_no = fields.Integer(
missing=None, metadata={"description": "Track number in the album"}
)
date = fields.String(missing=None, metadata={"description": "Track release date"})
genre = fields.String(missing=None, metadata={"description": "Track genre"})
type = fields.Constant("track", metadata={"description": "Item type"})
@pre_load
def parse(self, track: dict, **_):
from platypush.plugins.music.mopidy import EmptyTrackException
uri = (track or {}).get("uri", (track or {}).get("track", {}).get("uri"))
if not uri:
raise EmptyTrackException("Empty track")
tlid = track.get("tlid")
playlist_pos = track.get("playlist_pos")
if track.get("track"):
track = track.get("track", {})
length = track.get("length", track.get("time", track.get("duration")))
return {
"uri": uri,
"artist": next(
iter(item.get("name") for item in track.get("artists", [])),
None,
),
"title": track.get("name"),
"album": track.get("album", {}).get("name"),
"artist_uri": next(
iter(item.get("uri") for item in track.get("artists", [])), None
),
"album_uri": track.get("album", {}).get("uri"),
"time": length / 1000 if length is not None else None,
"playlist_pos": (
track.get("playlist_pos") if playlist_pos is None else playlist_pos
),
"date": track.get("date", track.get("album", {}).get("date")),
"track_id": tlid,
"track_no": track.get("track_no"),
"genre": track.get("genre"),
}
@post_dump
def to_dict(self, track: dict, **_):
"""
Fill/move missing fields in the dictionary.
"""
return {
"file": track["uri"],
**track,
}
class MopidyStatusSchema(Schema):
"""
Mopidy status schema.
"""
state = fields.Enum(
PlayerState,
required=True,
metadata={"description": "Player state"},
)
volume = fields.Float(metadata={"description": "Player volume (0-100)"})
consume = fields.Boolean(metadata={"description": "Consume mode"})
random = fields.Boolean(metadata={"description": "Random mode"})
repeat = fields.Boolean(metadata={"description": "Repeat mode"})
single = fields.Boolean(metadata={"description": "Single mode"})
mute = fields.Boolean(metadata={"description": "Mute mode"})
time = fields.Float(metadata={"description": "Current time (in seconds)"})
playing_pos = fields.Integer(
metadata={"description": "Index of the currently playing track"}
)
track = fields.Nested(
MopidyTrackSchema, missing=None, metadata={"description": "Current track"}
)
@post_dump
def post_dump(self, data: dict, **_):
"""
Post-dump hook.
"""
state = data.get("state")
if state:
data["state"] = getattr(PlayerState, state).value
return data
class MopidyPlaylistSchema(Schema):
"""
Mopidy playlist schema.
"""
# pylint: disable=too-few-public-methods
class Meta: # type: ignore
"""
Mopidy playlist schema metadata.
"""
unknown = EXCLUDE
uri = fields.String(required=True, metadata={"description": "Playlist URI"})
name = fields.String(required=True, metadata={"description": "Playlist name"})
last_modified = DateTime(metadata={"description": "Last modified timestamp"})
tracks = fields.List(
fields.Nested(MopidyTrackSchema),
missing=None,
metadata={"description": "Playlist tracks"},
)
type = fields.Constant("playlist", metadata={"description": "Item type"})
@pre_dump
def pre_dump(self, playlist, **_):
"""
Pre-dump hook.
"""
last_modified = (
playlist.last_modified
if hasattr(playlist, "last_modified")
else playlist.get("last_modified")
)
if last_modified:
last_modified /= 1000
if hasattr(playlist, "last_modified"):
playlist.last_modified = last_modified
else:
playlist["last_modified"] = last_modified
return playlist
class MopidyArtistSchema(Schema):
"""
Mopidy artist schema.
"""
uri = fields.String(required=True, metadata={"description": "Artist URI"})
file = fields.String(
metadata={"description": "Artist URI, for MPD compatibility purposes"}
)
name = fields.String(missing=None, metadata={"description": "Artist name"})
artist = fields.String(
missing=None,
metadata={"description": "Same as name - for MPD compatibility purposes"},
)
type = fields.Constant("artist", metadata={"description": "Item type"})
@post_dump
def to_dict(self, artist: dict, **_):
"""
Fill/move missing fields in the dictionary.
"""
return {
"file": artist["uri"],
"artist": artist["name"],
**artist,
}
class MopidyAlbumSchema(Schema):
"""
Mopidy album schema.
"""
uri = fields.String(required=True, metadata={"description": "Album URI"})
file = fields.String(
metadata={"description": "Artist URI, for MPD compatibility purposes"}
)
artist = fields.String(missing=None, metadata={"description": "Artist name"})
album = fields.String(
missing=None,
metadata={"description": "Same as name - for MPD compatibility purposes"},
)
name = fields.String(missing=None, metadata={"description": "Album name"})
artist_uri = fields.String(missing=None, metadata={"description": "Artist URI"})
date = fields.String(missing=None, metadata={"description": "Album release date"})
genre = fields.String(missing=None, metadata={"description": "Album genre"})
def parse(self, data: dict, **_):
assert data.get("uri"), "Album URI is required"
return {
"uri": data["uri"],
"artist": data.get("artist")
or next(
iter(item.get("name") for item in data.get("artists", [])),
None,
),
"name": data.get("name"),
"artist_uri": data.get("artist_uri")
or next(iter(item.get("uri") for item in data.get("artists", [])), None),
"album_uri": data.get("album_uri") or data.get("album", {}).get("uri"),
"date": data.get("date", data.get("album", {}).get("date")),
"genre": data.get("genre"),
}
@pre_load
def pre_load(self, album: dict, **_):
"""
Pre-load hook.
"""
return self.parse(album)
@pre_dump
def pre_dump(self, album: dict, **_):
"""
Pre-dump hook.
"""
return self.parse(album)
@post_dump
def to_dict(self, album: dict, **_):
"""
Fill/move missing fields in the dictionary.
"""
return {
"file": album["uri"],
"album": album["name"],
**album,
}
class MopidyDirectorySchema(Schema):
"""
Mopidy directory schema.
"""
uri = fields.String(required=True, metadata={"description": "Directory URI"})
name = fields.String(required=True, metadata={"description": "Directory name"})
type = fields.Constant("directory", metadata={"description": "Item type"})
class MopidyFilterSchema(Schema):
"""
Mopidy filter schema.
"""
uris = fields.List(fields.String, metadata={"description": "Filter by URIs"})
artist = fields.List(fields.String, metadata={"description": "Artist name(s)"})
album = fields.List(fields.String, metadata={"description": "Album name(s)"})
title = fields.List(fields.String, metadata={"description": "Track title(s)"})
albumartist = fields.List(
fields.String, metadata={"description": "Album artist name(s)"}
)
date = fields.List(fields.String, metadata={"description": "Track release date(s)"})
genre = fields.List(fields.String, metadata={"description": "Genre(s)"})
comment = fields.List(fields.String, metadata={"description": "Comment(s)"})
disc_no = fields.List(fields.Integer, metadata={"description": "Disc number(s)"})
musicbrainz_artistid = fields.List(
fields.String, metadata={"description": "MusicBrainz artist ID(s)"}
)
musicbrainz_albumid = fields.List(
fields.String, metadata={"description": "MusicBrainz album ID(s)"}
)
musicbrainz_trackid = fields.List(
fields.String, metadata={"description": "MusicBrainz album artist ID(s)"}
)
any = fields.List(
fields.String, metadata={"description": "Generic search string(s)"}
)
@pre_load
def pre_load(self, data: dict, **_):
"""
Pre-load hook.
"""
for field_name, field in self.fields.items():
value = data.get(field_name)
# Back-compatibtility with MPD's single-value filters
if (
value is not None
and isinstance(field, fields.List)
and isinstance(value, str)
):
data[field_name] = [value]
return data
@post_load
def post_load(self, data: dict, **_):
"""
Post-load hook.
"""
title = data.pop("title", None)
if title:
data["track_name"] = title
return data

View file

@ -35,8 +35,11 @@ def OrEvent(*events, cls: Type = threading.Event):
e.changed() e.changed()
def _or_set(e): def _or_set(e):
try:
e._set() e._set()
e.changed() e.changed()
except RecursionError:
pass
for e in events: for e in events:
_to_or(e, changed) _to_or(e, changed)