Audiofy – Musikwünsche für Partys
Server (Website, Admin-Bereich, Player-API) und Player (raspotify + Controller) mit Install-Skripten für Debian-Systeme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""Audiofy – schlanker Spotify-Web-API-Client für den Controller (httpx).
|
||||
|
||||
Erneuert den Access-Token selbst per Refresh-Token. Fehler werden als
|
||||
SpotifyFehler geworfen (bei 429 mit retry_after); Netzwerkfehler kommen als
|
||||
httpx.HTTPError durch – die Wiederholung übernimmt der Controller.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger("audiofy.spotify")
|
||||
|
||||
API = "https://api.spotify.com/v1"
|
||||
TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||
|
||||
|
||||
class SpotifyFehler(Exception):
|
||||
def __init__(self, status: int, nachricht: str, retry_after: int | None = None):
|
||||
super().__init__(f"{status}: {nachricht}")
|
||||
self.status = status
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
def _fehlertext(r: httpx.Response) -> str:
|
||||
try:
|
||||
fehler = r.json().get("error")
|
||||
if isinstance(fehler, dict):
|
||||
return fehler.get("message", "") + (f" ({fehler['reason']})" if fehler.get("reason") else "")
|
||||
return str(r.json().get("error_description") or fehler)
|
||||
except ValueError:
|
||||
return r.text[:200]
|
||||
|
||||
|
||||
class SpotifyClient:
|
||||
def __init__(self, client_id: str, client_secret: str, refresh_token: str,
|
||||
neuer_refresh_token: Callable[[str], None] | None = None):
|
||||
self._basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||
self._refresh_token = refresh_token
|
||||
self._neuer_refresh_token = neuer_refresh_token
|
||||
self._token: str | None = None
|
||||
self._ablauf = 0.0
|
||||
self._http = httpx.Client(timeout=10)
|
||||
|
||||
def _access_token(self) -> str:
|
||||
if self._token and time.monotonic() < self._ablauf - 60:
|
||||
return self._token
|
||||
r = self._http.post(TOKEN_URL, headers={"Authorization": f"Basic {self._basic}"},
|
||||
data={"grant_type": "refresh_token", "refresh_token": self._refresh_token})
|
||||
if r.status_code != 200:
|
||||
raise SpotifyFehler(r.status_code, "Token-Erneuerung fehlgeschlagen: " + _fehlertext(r))
|
||||
daten = r.json()
|
||||
self._token = daten["access_token"]
|
||||
self._ablauf = time.monotonic() + daten.get("expires_in", 3600)
|
||||
# Spotify kann den Refresh-Token rotieren – dann den neuen dauerhaft speichern
|
||||
neu = daten.get("refresh_token")
|
||||
if neu and neu != self._refresh_token:
|
||||
self._refresh_token = neu
|
||||
if self._neuer_refresh_token:
|
||||
self._neuer_refresh_token(neu)
|
||||
log.info("Neuer Refresh-Token von Spotify erhalten und gespeichert")
|
||||
return self._token
|
||||
|
||||
def anfrage(self, methode: str, pfad: str, **kwargs):
|
||||
"""Liefert das JSON der Antwort oder None (204, leerer oder Nicht-JSON-Body)."""
|
||||
url = pfad if pfad.startswith("http") else API + pfad
|
||||
for versuch in range(2):
|
||||
r = self._http.request(methode, url, headers={"Authorization": f"Bearer {self._access_token()}"},
|
||||
**kwargs)
|
||||
if r.status_code == 401 and versuch == 0:
|
||||
self._token = None # Token abgelaufen/ungültig → einmal erneuern
|
||||
continue
|
||||
if r.status_code == 429:
|
||||
raise SpotifyFehler(429, "Rate-Limit", int(r.headers.get("Retry-After", "5")))
|
||||
if r.status_code >= 400:
|
||||
raise SpotifyFehler(r.status_code, _fehlertext(r))
|
||||
if not r.content:
|
||||
return None
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
# z. B. PUT /me/player/pause liefert teils 200 mit Nicht-JSON-Body
|
||||
return None
|
||||
raise SpotifyFehler(401, "Nicht autorisiert")
|
||||
|
||||
# --- Wiedergabe -------------------------------------------------------
|
||||
|
||||
def wiedergabe(self) -> dict | None:
|
||||
"""GET /me/player – None, wenn gerade nichts aktiv ist (204)."""
|
||||
return self.anfrage("GET", "/me/player", params={"market": "from_token"})
|
||||
|
||||
def geraete(self) -> list[dict]:
|
||||
return (self.anfrage("GET", "/me/player/devices") or {}).get("devices", [])
|
||||
|
||||
def einreihen(self, uri: str, device_id: str) -> None:
|
||||
self.anfrage("POST", "/me/player/queue", params={"uri": uri, "device_id": device_id})
|
||||
|
||||
def titel(self, uri: str) -> dict:
|
||||
"""Metadaten eines Titels (GET /tracks/{id})."""
|
||||
return self.anfrage("GET", f"/tracks/{uri.rsplit(':', 1)[-1]}", params={"market": "from_token"}) or {}
|
||||
|
||||
def fortsetzen(self, device_id: str) -> None:
|
||||
"""Pausierte Wiedergabe auf dem Gerät fortsetzen (ohne Titelwechsel)."""
|
||||
self.anfrage("PUT", "/me/player/play", params={"device_id": device_id})
|
||||
|
||||
def abspielen(self, uris: list[str], device_id: str) -> None:
|
||||
self.anfrage("PUT", "/me/player/play", params={"device_id": device_id}, json={"uris": uris})
|
||||
|
||||
# --- Playlist ---------------------------------------------------------
|
||||
|
||||
def playlist_titel(self, playlist_id: str) -> list[str]:
|
||||
"""Alle abspielbaren Track-URIs der Playlist (seit 2026 nur über /items, Feld "item")."""
|
||||
uris: list[str] = []
|
||||
pfad: str | None = f"/playlists/{playlist_id}/items"
|
||||
params: dict | None = {"limit": 50, "market": "from_token"}
|
||||
while pfad:
|
||||
daten = self.anfrage("GET", pfad, params=params) or {}
|
||||
for eintrag in daten.get("items", []):
|
||||
titel = eintrag.get("item") or eintrag.get("track") or {}
|
||||
uri = titel.get("uri", "")
|
||||
if titel.get("type") == "track" and uri.startswith("spotify:track:") \
|
||||
and titel.get("is_playable", True):
|
||||
uris.append(uri)
|
||||
pfad, params = daten.get("next"), None # "next" enthält die Query schon
|
||||
return uris
|
||||
Reference in New Issue
Block a user