Files
playwish/player/spotify_client.py
T
99admandClaude Opus 5.5 fc15f2b933 Umbenennung: Audiofy heißt jetzt Playwish
Name, Seitentitel, Dienste (playwish-web, playwish-controller), Dienst-Benutzer
(playwish), Pfade (/opt/playwish, /usr/local/src/playwish), Vorlagen und
Repository-Adresse. „Audiofy“ war bereits von mehreren Produkten belegt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:27:59 +01:00

129 lines
5.7 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.
"""Playwish – 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("playwish.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