Files
StarPilot/openpilot/starpilot/galaxy/spotify_setup.py
T

267 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Galaxy setup owner for Spotify Soloist, modeled on the Android Auto package flow.
The HTTP server must authenticate the caller before invoking this owner. The API key is write-only:
no method here returns it. Configuration that changes the running player (install, output, account)
uses the shared connectivity authority (Park or offroad); playback controls work in any road state.
"""
from __future__ import annotations
import json
import os
import shutil
import socket
import tempfile
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import BinaryIO
from openpilot.starpilot.soloist import backend, credentials, package, preferences
from openpilot.starpilot.soloist.phone_audio import A2DP_SOURCE
from openpilot.starpilot.soloist.paths import CONTROL_SOCKET, Layout, private_dir
LINKS = {
'download': package.DOWNLOAD_PAGE,
'key': 'https://developer.spotify.com/dashboard/soloist',
'help': 'https://developer.spotify.com/documentation/soloist/tutorials/getting-started',
}
class SetupRejected(RuntimeError):
pass
class SpotifyClient:
def __init__(self, path: str = CONTROL_SOCKET, timeout: float = 3.0):
self.path, self.timeout = path, timeout
@property
def available(self) -> bool:
return os.path.exists(self.path)
def call(self, command: str, **payload) -> dict:
if not self.available:
raise RuntimeError('Spotify isn’t running')
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(self.timeout)
sock.connect(self.path)
sock.sendall(json.dumps({'command': command, **payload}).encode() + b'\n')
response = bytearray()
while not response.endswith(b'\n') and len(response) < 1024 * 1024:
chunk = sock.recv(65536)
if not chunk:
break
response.extend(chunk)
result = json.loads(response) if response else {}
if not result.get('ok'):
raise RuntimeError(str(result.get('error') or 'Spotify did not respond'))
return result
class SpotifySetup:
def __init__(self, *, parked: Callable[[], bool], session_valid: Callable[[tuple], bool],
layout: Layout | None = None, client: SpotifyClient | None = None,
import_job: package.ImportJob | None = None,
paired_devices: Callable[[tuple], list[dict]] = lambda session: [],
clock: Callable[[], float] = time.monotonic):
self.parked, self.session_valid = parked, session_valid
self.layout = layout or Layout.default()
self.client = client or SpotifyClient()
self.job = import_job or package.ImportJob(self.layout)
self.paired_devices, self.clock = paired_devices, clock
self._upload_lock = threading.Lock()
def _require_session(self, session: tuple) -> None:
if not session or not self.session_valid(session):
raise SetupRejected('Galaxy session expired; sign in again')
def _require_parked(self, what: str) -> None:
if not self.parked():
raise SetupRejected(f'Put the car in Park or use offroad mode to {what}')
def _service(self) -> dict | None:
try:
return self.client.call('status')['status'] if self.client.available else None
except (OSError, RuntimeError, ValueError, KeyError):
return None
def status(self, session: tuple) -> dict:
self._require_session(session)
prefs = preferences.load(self.layout)
try:
devices = [{'address': d['address'], 'name': d['name'], 'connected': d.get('connected') is True}
for d in self.paired_devices(session) if d.get('paired')]
except Exception:
devices = []
return {
'enabled': self.layout.enabled_flag.exists(), 'parked': self.parked(),
'release': package.release_status(self.layout), 'keyPresent': credentials.present(self.layout),
'audioReady': backend.bundle_ready(),
'preferences': prefs.public(), 'devices': devices, 'service': self._service(),
'import': self.job.status(), 'maxUploadBytes': package.MAX_UPLOAD_BYTES, 'archiveName': package.ARCHIVE_NAME,
'cacheChoices': list(preferences.CACHE_CHOICES), 'links': LINKS,
'clock': int(time.time() * 1000), # noqa: TID251 lets the browser rebase epoch-ms play positions
}
def enable(self, session: tuple, value: bool) -> dict:
self._require_session(session)
if type(value) is not bool:
raise SetupRejected('Invalid Spotify setting')
preferences.set_enabled(value, self.layout)
return self.status(session)
def upload(self, session: tuple, source: BinaryIO, length: int) -> dict:
self._require_session(session)
self._require_parked('install Spotify')
if type(length) is not int or not 0 < length <= package.MAX_UPLOAD_BYTES:
raise SetupRejected('Invalid Soloist download size')
if not self._upload_lock.acquire(blocking=False):
raise SetupRejected('Another Soloist upload is running')
path: Path | None = None
started = False
try:
if self.job.busy():
raise SetupRejected('A Soloist install is running')
work_dir = private_dir(self.job.work_dir)
for stale in work_dir.glob('galaxy-upload-*'):
stale.unlink(missing_ok=True)
fd, name = tempfile.mkstemp(prefix='galaxy-upload-', suffix='.tar.gz', dir=work_dir)
path = Path(name)
with os.fdopen(fd, 'wb') as out:
remaining = length
while remaining:
self._require_session(session)
chunk = source.read(min(1024 * 1024, remaining))
if not chunk:
raise SetupRejected('Soloist upload ended early')
out.write(chunk)
remaining -= len(chunk)
out.flush()
os.fsync(out.fileno())
self._require_session(session)
self.job.start(path=path, enabled=lambda: self.session_valid(session), may_activate=self.parked)
started = True
return self.status(session)
finally:
if path is not None and not started:
path.unlink(missing_ok=True)
self._upload_lock.release()
def set_key(self, session: tuple, key: object) -> dict:
self._require_session(session)
try:
credentials.store(key, self.layout)
except credentials.InvalidKey as error:
raise SetupRejected(str(error)) from error
return self.status(session)
def save_preferences(self, session: tuple, payload: dict) -> dict:
self._require_session(session)
current = preferences.load(self.layout)
fields = {'deviceName': 'device_name', 'cacheMb': 'cache_mb', 'initialVolume': 'initial_volume',
'output': 'output', 'outputAddress': 'output_address', 'source': 'source', 'phoneAddress': 'phone_address',
'pauseMute': 'pause_mute', 'skipMute': 'skip_mute'}
if not isinstance(payload, dict) or not payload or set(payload) - set(fields):
raise SetupRejected('Invalid Spotify settings')
values = {**current.__dict__, **{fields[key]: value for key, value in payload.items()}}
changes_output = values['output'] != current.output or values['output_address'] != current.output_address
# Flipping between Spotify and the already chosen phone is a playback control; picking a different phone is setup.
if values['phone_address'] != current.phone_address:
self._require_parked('change the phone')
if values['source'] != current.source or values['phone_address'] != current.phone_address:
if values['phone_address']:
match = next((d for d in self.status(session)['devices'] if d['address'] == str(values['phone_address']).upper()), None)
if match is None:
raise SetupRejected('Pair your phone on the Bluetooth page first')
values['phone_name'] = match['name']
else:
values['phone_name'] = ''
if changes_output:
self._require_parked('change where music plays')
if values['output'] == 'bluetooth' and values['output_address']:
match = next((d for d in self.status(session)['devices'] if d['address'] == str(values['output_address']).upper()), None)
if match is None:
raise SetupRejected('Pair that speaker or car on the Bluetooth page first')
values['output_name'] = match['name']
else:
values['output_name'] = ''
try:
preferences.save(preferences.validated(values), self.layout)
except (ValueError, TypeError) as error:
raise SetupRejected(str(error)) from error
return self.status(session)
def prepare_phone_pairing(self) -> None:
"""Advertise the music receiver before a phone pairs, so the phone offers the comma as an audio output."""
if not (self.layout.enabled_flag.exists() and self.client.available):
return
try:
self.client.call('prepare_phone_pairing')
except (OSError, RuntimeError, ValueError):
pass
def adopt_phone(self, address: str, name: str, uuids: object) -> bool:
"""Offer a newly paired phone as the music source without overriding anything the owner chose."""
if not isinstance(uuids, (list, tuple)) or A2DP_SOURCE not in {str(value).lower() for value in uuids}:
return False
current = preferences.load(self.layout)
if current.phone_address:
return False
values = {**current.__dict__, 'phone_address': str(address).upper(), 'phone_name': str(name)}
if not package.release_status(self.layout).get('installed'):
values['source'] = 'phone'
try:
preferences.save(preferences.validated(values), self.layout)
except (ValueError, TypeError):
return False
return True
def control(self, session: tuple, action: object, args: object) -> dict:
self._require_session(session)
if not isinstance(action, str) or not isinstance(args, dict):
raise SetupRejected('Invalid Spotify control')
try:
self.client.call('control', action=action, args=args)
except (OSError, ValueError) as error:
raise SetupRejected('Spotify isn’t responding') from error
except RuntimeError as error:
raise SetupRejected(str(error)) from error
return {'ok': True}
def maintenance(self, session: tuple, action: str) -> dict:
self._require_session(session)
if action not in ('reset_session', 'clear_cache', 'remove_key'):
raise SetupRejected('Invalid Spotify action')
if action == 'remove_key':
credentials.remove(self.layout)
return self.status(session)
self._require_parked('change your Spotify account' if action == 'reset_session' else 'clear the cache')
if self.client.available:
try:
self.client.call(action)
except (OSError, RuntimeError, ValueError) as error:
raise SetupRejected('Spotify isn’t responding') from error
else:
shutil.rmtree(self.layout.cache, ignore_errors=True)
if action == 'reset_session':
shutil.rmtree(self.layout.state, ignore_errors=True)
return self.status(session)
def remove(self, session: tuple, *, wait: float = 12.0) -> dict:
"""Disable, wait for the manager to stop the player, then delete only Soloist-owned files."""
self._require_session(session)
with self._upload_lock:
if self.job.busy():
raise SetupRejected('Wait for the Soloist install to finish')
preferences.set_enabled(False, self.layout)
deadline = self.clock() + wait
while self.client.available and self.clock() < deadline:
time.sleep(0.25)
if self.client.available:
raise SetupRejected('Spotify is still stopping; try again in a moment')
from openpilot.starpilot.soloist.supervisor import reap_orphans
reap_orphans(self.layout)
shutil.rmtree(self.layout.root, ignore_errors=True)
return self.status(session)