"""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