Files
Alfredv2/alfred/app/services/dona_spotify.py
T
2026-10-30 22:10:03 +01:00

264 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.
"""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)