264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""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)
|