platypush/platypush/plugins/music/mopidy/_client.py

487 lines
15 KiB
Python

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: