v3
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user