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>
129 lines
5.7 KiB
Python
129 lines
5.7 KiB
Python
"""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
|