Files
StarPilot/openpilot/starpilot/soloist/client.py
T

282 lines
11 KiB
Python

"""One persistent connection to Soloist's loopback WebSocket API, normalized into small snapshots.
Soloist's API has no authentication, so only this owner talks to it; Galaxy and the UI talk to the
supervisor's control socket instead. Every vendor field is treated as untrusted display input.
"""
from __future__ import annotations
import json
import queue
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any, TypeGuard
from urllib.parse import urlsplit
MAX_FRAME = 256 * 1024
MAX_TEXT = 200
ARTWORK_HOSTS = ('i.scdn.co', 'mosaic.scdn.co', 'image-cdn-ak.spotifycdn.com', 'image-cdn-fa.spotifycdn.com')
STATUSES = ('idle', 'playing', 'paused', 'buffering')
REPEATS = ('off', 'context', 'track')
ACTIONS = ('play', 'pause', 'skip_next', 'skip_prev', 'seek', 'shuffle', 'set_repeat')
AUTH_POLL_SECONDS = 15.0
AUTH_TIMEOUT_SECONDS = 45.0
def _text(value: Any) -> str:
return ''.join(ch for ch in value if ch.isprintable())[:MAX_TEXT] if isinstance(value, str) else ''
def _name(entity: Any) -> str:
return _text(((entity or {}).get('decorations') or {}).get('identity', {}).get('name')) if isinstance(entity, dict) else ''
def _artwork(entity: dict) -> str:
covers = ((entity.get('decorations') or {}).get('visual_identity') or {}).get('cover') or []
ranked = {'default': 0, 'large': 1, 'small': 2, 'xlarge': 3}
for cover in sorted((c for c in covers if isinstance(c, dict)), key=lambda c: ranked.get(c.get('size'), 9)):
url = cover.get('url')
if isinstance(url, str) and len(url) <= 512:
parts = urlsplit(url)
if parts.scheme == 'https' and parts.hostname in ARTWORK_HOSTS and not parts.username and parts.port is None:
return url
return ''
def _number(value: Any) -> TypeGuard[int | float]:
return type(value) in (int, float)
def track(entity: Any) -> dict | None:
if not isinstance(entity, dict) or not entity.get('uri') and not _name(entity):
return None
decorations = entity.get('decorations') or {}
creators = [_name((c or {}).get('entity')) for c in (decorations.get('creators') or [])[:8] if isinstance(c, dict)]
duration = (decorations.get('playback') or {}).get('duration_ms')
kind = entity.get('entity_type') if entity.get('entity_type') in ('track', 'episode', 'ad') else 'unknown'
return {'title': _name(entity), 'artists': ', '.join(c for c in creators if c)[:MAX_TEXT],
'album': _name((decorations.get('parent') or {}).get('entity')), 'artwork': _artwork(entity), 'kind': kind,
'durationMs': int(duration) if _number(duration) and 0 <= duration < 86400000 else None}
def position(value: Any) -> dict | None:
if not isinstance(value, dict):
return None
ms, at, speed = value.get('position_ms'), value.get('timestamp_ms'), value.get('speed')
if not all(type(x) in (int, float) for x in (ms, at, speed)) or ms < 0 or not 0 <= speed <= 4:
return None
return {'ms': int(ms), 'at': int(at), 'speed': float(speed)}
class PlayerState:
"""Applies vendor events to one bounded snapshot. Not thread-safe; the client serializes access."""
def __init__(self):
self.reset()
def reset(self) -> None:
self.value: dict[str, Any] = {'connected': False, 'loggedIn': False, 'active': False, 'deviceName': '', 'status': 'idle', 'track': None,
'position': None, 'volume': None, 'shuffle': False, 'repeat': 'off', 'actions': [], 'updated': 0.0}
def apply(self, message: dict) -> None:
kind = message.get('type')
v = self.value
if kind == 'auth_state':
v['loggedIn'] = message.get('logged_in') is True
v['active'] = message.get('is_active') is True
v['deviceName'] = _text(message.get('device_name'))
if not v['loggedIn']:
v.update(status='idle', track=None, position=None, actions=[])
elif kind == 'playback_state':
self._status(message.get('status'))
v['track'] = track(message.get('item'))
v['position'] = position(message.get('position'))
self._volume(message.get('volume'))
v['active'] = message.get('is_active') is True
self._options(message.get('options'))
actions = message.get('available_actions')
v['actions'] = sorted(a for a in actions if a in ACTIONS) if isinstance(actions, dict) else []
elif kind == 'track_changed':
v['track'] = track(message.get('item'))
elif kind == 'playback_changed':
self._status(message.get('status'))
elif kind == 'volume_changed':
self._volume(message.get('volume'))
elif kind == 'device_changed':
v['active'] = message.get('is_active') is True
v['deviceName'] = _text(message.get('device_name')) or v['deviceName']
elif kind == 'options_changed':
self._options(message.get('options'))
elif kind == 'position_sync':
v['position'] = position(message.get('position'))
else:
return
v['updated'] = time.monotonic()
def _status(self, status: Any) -> None:
if status in STATUSES:
self.value['status'] = status
def _volume(self, volume: Any) -> None:
if type(volume) is int and 0 <= volume <= 100:
self.value['volume'] = volume
def _options(self, options: Any) -> None:
if isinstance(options, dict):
self.value['shuffle'] = options.get('shuffle') is True
self.value['repeat'] = options.get('repeat') if options.get('repeat') in REPEATS else 'off'
def commands(action: str, args: dict) -> list[dict]:
"""Translate one validated StarPilot control into Soloist commands. Raises ValueError otherwise."""
simple = {'play': 'play', 'pause': 'pause', 'next': 'skip_next', 'previous': 'skip_prev', 'activate': 'activate',
'deactivate': 'deactivate'}
if action in simple and not args:
return [{'type': 'command', 'command': simple[action]}]
if action == 'seek' and set(args) == {'positionMs'} and type(args['positionMs']) is int and 0 <= args['positionMs'] < 86400000:
return [{'type': 'command', 'command': 'seek', 'position_ms': args['positionMs']}]
if action == 'volume' and set(args) == {'volume'} and type(args['volume']) is int and 0 <= args['volume'] <= 100:
return [{'type': 'command', 'command': 'set_volume', 'volume': args['volume']}]
if action == 'shuffle' and set(args) == {'enabled'} and type(args['enabled']) is bool:
return [{'type': 'command', 'command': 'set_shuffle', 'enabled': args['enabled']}]
if action == 'repeat' and set(args) == {'mode'} and args['mode'] in REPEATS:
track_on, context_on = args['mode'] == 'track', args['mode'] == 'context'
order = [('set_repeat_context', False), ('set_repeat_track', True)] if track_on else \
[('set_repeat_track', False), ('set_repeat_context', context_on)]
return [{'type': 'command', 'command': name, 'enabled': enabled} for name, enabled in order]
raise ValueError('Unsupported Spotify control')
def endpoint(data_dir: Path) -> str | None:
try:
address = (data_dir / 'ws.addr').read_text().strip()
port = int((data_dir / 'ws.port').read_text().strip())
except (OSError, ValueError):
return None
return f'ws://127.0.0.1:{port}' if address == '127.0.0.1' and 0 < port < 65536 else None
def _connect(url: str):
import websocket # websocket-client, present in the device venv
return websocket.create_connection(url, timeout=5, enable_multithread=True)
class PlayerClient:
def __init__(self, data_dir: Path, *, connect: Callable[[str], Any] = _connect, on_change: Callable[[], None] = lambda: None,
clock: Callable[[], float] = time.monotonic):
self.data_dir, self.connect, self.on_change = data_dir, connect, on_change
self.clock = clock
self.state = PlayerState()
self.lock = threading.Lock()
self.outbox: queue.Queue[dict] = queue.Queue(maxsize=16)
self.stop_event = threading.Event()
self.thread: threading.Thread | None = None
self.last_error = ''
def snapshot(self) -> dict:
with self.lock:
value = dict(self.state.value)
return value
def start(self) -> None:
if self.thread is None or not self.thread.is_alive():
self.stop_event.clear()
self.thread = threading.Thread(target=self._loop, name='soloist_ws', daemon=True)
self.thread.start()
def stop(self) -> None:
self.stop_event.set()
if self.thread is not None:
self.thread.join(timeout=3)
with self.lock:
self.state.reset()
def send(self, action: str, args: dict) -> None:
for command in commands(action, args):
try:
self.outbox.put_nowait(command)
except queue.Full as error:
raise RuntimeError('Spotify is busy; try again') from error
def _loop(self) -> None:
delay = 0.5
while not self.stop_event.is_set():
url = endpoint(self.data_dir)
if url is None:
self.stop_event.wait(0.5)
continue
try:
connection = self.connect(url)
except Exception as error:
self.last_error = type(error).__name__
self.stop_event.wait(delay)
delay = min(delay * 2, 10.0)
continue
delay = 0.5
try:
self._session(connection)
except Exception as error:
self.last_error = type(error).__name__
finally:
try:
connection.close()
except Exception:
pass
with self.lock:
self.state.value['connected'] = False
self.on_change()
def _session(self, connection) -> None:
connection.settimeout(0.25)
with self.lock:
self.state.reset()
while not self.outbox.empty(): # never replay commands from before a reconnect
self.outbox.get_nowait()
connection.send(json.dumps({'type': 'command', 'command': 'get_auth_state'}))
pending_auth = self.clock()
next_auth = pending_auth + AUTH_POLL_SECONDS
asked_state = False
while not self.stop_event.is_set():
now = self.clock()
if pending_auth is not None and now - pending_auth >= AUTH_TIMEOUT_SECONDS:
raise TimeoutError('Soloist did not answer its local health check')
if pending_auth is None and now >= next_auth:
connection.send(json.dumps({'type': 'command', 'command': 'get_auth_state'}))
pending_auth = now
next_auth = now + AUTH_POLL_SECONDS
try:
while True:
connection.send(json.dumps(self.outbox.get_nowait()))
except queue.Empty:
pass
try:
frame = connection.recv()
except Exception as error:
if type(error).__name__ in ('WebSocketTimeoutException', 'TimeoutError', 'timeout'):
continue
raise
if not frame:
return
if len(frame) > MAX_FRAME:
continue
try:
message = json.loads(frame)
except ValueError:
continue
if not isinstance(message, dict):
continue
if message.get('type') == 'auth_state':
pending_auth = None
with self.lock:
self.state.apply(message)
if message.get('type') == 'auth_state':
self.state.value['connected'] = True
logged_in = self.state.value['loggedIn']
if logged_in and not asked_state:
asked_state = True
connection.send(json.dumps({'type': 'command', 'command': 'get_state'}))
elif not logged_in:
asked_state = False
self.on_change()