"""Spotify Connect for Dona's local dashboard. Uses the EXISTING Hermes Spotify OAuth (never sends OAuth tokens to the browser). Only Spotify's own HTTPS endpoints are contacted. UI requests never invoke an LLM. """ from __future__ import annotations from datetime import datetime, timezone import fcntl import json import logging import os from pathlib import Path import re import tempfile import threading import time from urllib.parse import urlsplit import requests from flask import Blueprint, jsonify, request LOG = logging.getLogger(__name__) AUTH_FILE = Path(os.getenv('DONA_SPOTIFY_AUTH_FILE', '/root/.hermes/auth.json')) API = 'https://api.spotify.com/v1' ACCOUNTS = 'https://accounts.spotify.com/api/token' _SYNC = threading.RLock() _LAST_STATE: dict = {} _STATE_AT = 0.0 _ALLOWED_ACTIONS = {'play', 'pause', 'next', 'previous', 'seek', 'volume', 'transfer'} class SpotifyPanelError(Exception): def __init__(self, message, status=503): self.status = status super().__init__(message) def _credentials(store): providers = store.get('providers') if isinstance(store, dict) else None # Hermes' documented flat OAuth provider storage shape. state = providers.get('spotify') if isinstance(providers, dict) else None if not isinstance(state, dict): raise SpotifyPanelError('Spotify non connecté dans le profil Hermes root. Lance hermes spotify login.') if not state.get('access_token'): raise SpotifyPanelError('Jeton Spotify absent. Reconnecte Hermes à Spotify.') return state def _store_read(): try: return json.loads(AUTH_FILE.read_text(encoding='utf-8')) except (ValueError, OSError) as exc: raise SpotifyPanelError('Impossible de lire la connexion Spotify d’Hermes.') from exc def _is_expiring(state): try: expires = datetime.fromisoformat(str(state.get('expires_at', '')).replace('Z', '+00:00')) if not expires.tzinfo: expires = expires.replace(tzinfo=timezone.utc) return expires.timestamp() - time.time() < 90 except (TypeError, ValueError): return False # A 401 triggers the refresh path. def _refresh_token(force=False): """Refresh with PKCE. Preserve Hermes' entire auth store and use atomic writes. Respect Hermes' auth.json locking convention (auth.lock). Re-read before replace to avoid overwriting a concurrent auth rotation. """ lock_path = AUTH_FILE.with_suffix('.lock') AUTH_FILE.parent.mkdir(parents=True, exist_ok=True) with lock_path.open('a+') as lock: fcntl.flock(lock.fileno(), fcntl.LOCK_EX) store = _store_read() state = _credentials(store) if not force and not _is_expiring(state): return state['access_token'] old_refresh = state.get('refresh_token') client_id = state.get('client_id') if not old_refresh or not client_id: raise SpotifyPanelError('Connexion Spotify expirée : relance hermes spotify login.') try: r = requests.post(ACCOUNTS, data={'grant_type': 'refresh_token', 'refresh_token': old_refresh, 'client_id': client_id}, timeout=12) except requests.RequestException as exc: raise SpotifyPanelError('Service de connexion Spotify indisponible.') from exc if r.status_code != 200: raise SpotifyPanelError('Spotify demande une nouvelle connexion : hermes spotify login.', 401) data = r.json() if not isinstance(data, dict) or not data.get('access_token'): raise SpotifyPanelError('Réponse de renouvellement Spotify invalide.') # If Hermes independently rotated credentials, never overwrite its tokens. latest = _store_read() current = _credentials(latest) if current.get('refresh_token') != old_refresh or current.get('access_token') != state.get('access_token'): return current['access_token'] current.update({ 'access_token': data['access_token'], 'refresh_token': data.get('refresh_token') or old_refresh, 'token_type': data.get('token_type') or current.get('token_type') or 'Bearer', 'obtained_at': datetime.now(timezone.utc).isoformat(), 'expires_at': datetime.fromtimestamp(time.time() + int(data.get('expires_in', 3600)), timezone.utc).isoformat(), 'expires_in': int(data.get('expires_in', 3600)), }) payload = json.dumps(latest, ensure_ascii=False, indent=2) + '\n' tmp_name = None try: fd, tmp_name = tempfile.mkstemp(prefix='.dona-auth-', dir=AUTH_FILE.parent) with os.fdopen(fd, 'w', encoding='utf-8') as fp: fp.write(payload) fp.flush() os.fsync(fp.fileno()) os.chmod(tmp_name, 0o600) os.replace(tmp_name, AUTH_FILE) finally: if tmp_name and os.path.exists(tmp_name): os.unlink(tmp_name) return data['access_token'] def _token(): store = _store_read() state = _credentials(store) if _is_expiring(state): with _SYNC: return _refresh_token() return str(state['access_token']) def _call(method, endpoint, *, body=None, params=None, retry=True): assert endpoint.startswith('/me/player') try: resp = requests.request(method, API + endpoint, headers={'Authorization': 'Bearer ' + _token()}, params=params, json=body, timeout=10) except requests.RequestException as exc: raise SpotifyPanelError('Spotify ne répond pas pour le moment.') from exc if resp.status_code == 401 and retry: with _SYNC: # On an invalid access token with unknown expiration, force the refresh. _refresh_token(force=True) return _call(method, endpoint, body=body, params=params, retry=False) if resp.status_code == 429: raise SpotifyPanelError('Spotify limite temporairement les requêtes (429).', 429) if resp.status_code in (403, 404): raise SpotifyPanelError('Commande refusée : vérifie Spotify Premium et l’appareil actif.', resp.status_code) if resp.status_code >= 400: raise SpotifyPanelError(f'Erreur Spotify ({resp.status_code}).', 502) if resp.status_code == 204 or not resp.content: return {} try: return resp.json() except ValueError: return {} def _same_origin(): if request.headers.get('X-Alfred-Request') != '1' or not request.is_json: return False origin = request.headers.get('Origin') if origin: p = urlsplit(origin) return p.scheme in ('http', 'https') and p.netloc == request.host return request.headers.get('Sec-Fetch-Site', 'same-origin') in ('same-origin', 'none') def _public_state(payload, devices): global _LAST_STATE, _STATE_AT item = payload.get('item') if isinstance(payload, dict) else None device = payload.get('device') if isinstance(payload, dict) else None if not isinstance(item, dict): item = {} album = item.get('album') or {} images = album.get('images') if isinstance(album, dict) else [] image = next((x.get('url') for x in images if isinstance(x, dict) and str(x.get('url', '')).startswith('https://')), None) if isinstance(images, list) else None artists = item.get('artists') or [] data = { 'available': bool(item), 'is_playing': bool(payload.get('is_playing')), 'title': str(item.get('name') or ''), 'artist': ', '.join(str(x.get('name')) for x in artists if isinstance(x, dict) and x.get('name')), 'album': str(album.get('name') or '') if isinstance(album, dict) else '', 'cover': image, 'duration_ms': int(item.get('duration_ms') or 0), 'progress_ms': int(payload.get('progress_ms') or 0), 'device_id': str((device or {}).get('id') or ''), 'device_name': str((device or {}).get('name') or ''), 'volume_percent': (device or {}).get('volume_percent'), 'devices': devices, } if item: _LAST_STATE, _STATE_AT = dict(data), time.monotonic() elif _LAST_STATE and time.monotonic() - _STATE_AT < 300: for k in ('title', 'artist', 'album', 'cover', 'duration_ms', 'device_name', 'device_id'): data[k] = _LAST_STATE.get(k) data['progress_ms'] = _LAST_STATE.get('progress_ms', 0) data['available'] = True data['is_playing'] = False return data def register_spotify_panel(app): bp = Blueprint('dona_spotify_panel', __name__) @bp.get('/api/dona/spotify/state') def spotify_state(): try: raw = _call('GET', '/me/player') d = _call('GET', '/me/player/devices') devices = [{'id': str(x.get('id') or ''), 'name': str(x.get('name') or ''), 'is_active': bool(x.get('is_active')), 'type': str(x.get('type') or '')} for x in d.get('devices', []) if isinstance(x, dict)] response = jsonify(ok=True, **_public_state(raw, devices)) response.headers['Cache-Control'] = 'no-store, private' return response except SpotifyPanelError as exc: return jsonify(ok=False, error=str(exc)), exc.status @bp.post('/api/dona/spotify/control') def spotify_control(): if not _same_origin(): return jsonify(ok=False, error='Requête non autorisée.'), 403 data = request.get_json(silent=True) if not isinstance(data, dict): return jsonify(ok=False, error='JSON invalide.'), 400 action = str(data.get('action') or '') if action not in _ALLOWED_ACTIONS: return jsonify(ok=False, error='Action non autorisée.'), 400 dev = str(data.get('device_id') or '') if dev and (len(dev) > 150 or not re.fullmatch(r'[A-Za-z0-9_-]+', dev)): return jsonify(ok=False, error='Appareil invalide.'), 400 params = {'device_id': dev} if dev else None try: if action == 'play': _call('PUT', '/me/player/play', params=params, body={}) elif action == 'pause': _call('PUT', '/me/player/pause', params=params) elif action in ('next', 'previous'): _call('POST', '/me/player/' + action, params=params) elif action == 'seek': ms = int(data.get('position_ms')) if not 0 <= ms <= 36000000: raise ValueError('position_ms') _call('PUT', '/me/player/seek', params={**(params or {}), 'position_ms': ms}) elif action == 'volume': volume = int(data.get('volume_percent')) if not 0 <= volume <= 100: raise ValueError('volume_percent') _call('PUT', '/me/player/volume', params={**(params or {}), 'volume_percent': volume}) elif action == 'transfer': if not dev: raise ValueError('device_id') _call('PUT', '/me/player', body={'device_ids': [dev], 'play': True}) return jsonify(ok=True) except (ValueError, TypeError): return jsonify(ok=False, error='Paramètre invalide.'), 400 except SpotifyPanelError as exc: return jsonify(ok=False, error=str(exc)), exc.status app.register_blueprint(bp)