forked from platypush/platypush
[WIP] music.mopidy
refactor, initial backend rewrite.
This commit is contained in:
parent
d2e5e5230b
commit
89d618b35f
20 changed files with 2198 additions and 339 deletions
|
@ -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
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
``music.mopidy``
|
|
||||||
==================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.music.mopidy
|
|
||||||
:members:
|
|
||||||
|
|
5
docs/source/platypush/plugins/music.mopidy.rst
Normal file
5
docs/source/platypush/plugins/music.mopidy.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``music.mopidy``
|
||||||
|
================
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.music.mopidy
|
||||||
|
:members:
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
|
|
@ -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:
|
||||||
|
|
1076
platypush/plugins/music/mopidy/__init__.py
Normal file
1076
platypush/plugins/music/mopidy/__init__.py
Normal file
File diff suppressed because it is too large
Load diff
486
platypush/plugins/music/mopidy/_client.py
Normal file
486
platypush/plugins/music/mopidy/_client.py
Normal 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:
|
4
platypush/plugins/music/mopidy/_common.py
Normal file
4
platypush/plugins/music/mopidy/_common.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT: Final[float] = 20
|
23
platypush/plugins/music/mopidy/_conf.py
Normal file
23
platypush/plugins/music/mopidy/_conf.py
Normal 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:
|
10
platypush/plugins/music/mopidy/_exc.py
Normal file
10
platypush/plugins/music/mopidy/_exc.py
Normal 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.
|
||||||
|
"""
|
33
platypush/plugins/music/mopidy/_playlist.py
Normal file
33
platypush/plugins/music/mopidy/_playlist.py
Normal 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))
|
47
platypush/plugins/music/mopidy/_status.py
Normal file
47
platypush/plugins/music/mopidy/_status.py
Normal 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))
|
50
platypush/plugins/music/mopidy/_sync.py
Normal file
50
platypush/plugins/music/mopidy/_sync.py
Normal 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
|
58
platypush/plugins/music/mopidy/_task.py
Normal file
58
platypush/plugins/music/mopidy/_task.py
Normal 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)
|
43
platypush/plugins/music/mopidy/_track.py
Normal file
43
platypush/plugins/music/mopidy/_track.py
Normal 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))
|
|
@ -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
|
|
@ -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
325
platypush/schemas/mopidy.py
Normal 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
|
|
@ -35,8 +35,11 @@ def OrEvent(*events, cls: Type = threading.Event):
|
||||||
e.changed()
|
e.changed()
|
||||||
|
|
||||||
def _or_set(e):
|
def _or_set(e):
|
||||||
e._set()
|
try:
|
||||||
e.changed()
|
e._set()
|
||||||
|
e.changed()
|
||||||
|
except RecursionError:
|
||||||
|
pass
|
||||||
|
|
||||||
for e in events:
|
for e in events:
|
||||||
_to_or(e, changed)
|
_to_or(e, changed)
|
||||||
|
|
Loading…
Reference in a new issue