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/midi.rst
|
||||
platypush/backend/music.mopidy.rst
|
||||
platypush/backend/music.spotify.rst
|
||||
platypush/backend/nodered.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/mobile.join.rst
|
||||
platypush/plugins/mqtt.rst
|
||||
platypush/plugins/music.mopidy.rst
|
||||
platypush/plugins/music.mpd.rst
|
||||
platypush/plugins/music.snapcast.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 typing import Optional
|
||||
from typing import Dict, Iterable, Optional
|
||||
|
||||
from platypush.plugins import Plugin, action
|
||||
|
||||
|
@ -107,5 +107,18 @@ class MusicPlugin(Plugin, ABC):
|
|||
def search(self, query, **kwargs):
|
||||
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:
|
||||
|
|
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
|
||||
install:
|
||||
pip: []
|
||||
package: platypush.backend.music.mopidy
|
||||
type: backend
|
||||
package: platypush.plugins.music.mopidy
|
||||
type: plugin
|
|
@ -21,6 +21,18 @@ class MusicMpdPlugin(MusicPlugin, RunnablePlugin):
|
|||
the original protocol and with support for multiple music sources through
|
||||
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
|
||||
``mopidy-mpd`` extension. Make sure that you have the extension
|
||||
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)
|
||||
|
||||
@action
|
||||
def lsinfo(self, uri: Optional[str] = None):
|
||||
"""
|
||||
Returns the list of playlists and directories on the server.
|
||||
def browse(self, uri: Optional[str] = None):
|
||||
"""
|
||||
Browse the items under the specified URI.
|
||||
|
||||
:param uri: URI to browse (default: root directory).
|
||||
"""
|
||||
return (
|
||||
self._exec('lsinfo', uri, return_status=False)
|
||||
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()
|
||||
|
||||
def _or_set(e):
|
||||
try:
|
||||
e._set()
|
||||
e.changed()
|
||||
except RecursionError:
|
||||
pass
|
||||
|
||||
for e in events:
|
||||
_to_or(e, changed)
|
||||
|
|
Loading…
Reference in a new issue