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:
2026-10-17 19:16:39 +01:00
co-authored by Claude Opus 5.5
commit 3610de7bae
45 changed files with 5666 additions and 0 deletions
+128
View File
@@ -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