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
+116
View File
@@ -0,0 +1,116 @@
"""Audiofy-Web – Spotify-Zugriff für Suche und Titelinfos.
Eigener Refresh-Token des Servers (auth.py --profil server, keine Scopes nötig).
Im Development Mode liefert die Suche höchstens 10 Treffer.
"""
import base64
import logging
import threading
import time
from functools import lru_cache
import httpx
from .config import Konfig, env_wert_setzen, konfig
log = logging.getLogger("audiofy.spotify")
API = "https://api.spotify.com/v1"
TOKEN_URL = "https://accounts.spotify.com/api/token"
CACHE_S = 300 # Suchergebnisse so lange zwischenspeichern
CACHE_MAX = 500
class SpotifyFehler(Exception):
def __init__(self, status: int, retry_after: int | None = None):
super().__init__(f"Spotify-Fehler {status}")
self.status = status
self.retry_after = retry_after
def titel_aus(t: dict) -> dict:
"""Spotify-Track-Objekt → schlankes Dict für Website und Datenbank."""
bilder = sorted((t.get("album") or {}).get("images") or [], key=lambda b: b.get("width") or 0)
# kleinstes Bild ab 200 px (gut für Listen), sonst das größte
cover = next((b["url"] for b in bilder if (b.get("width") or 0) >= 200), bilder[-1]["url"] if bilder else None)
return {
"track_uri": t["uri"],
"title": t.get("name") or "",
"artist": ", ".join(a.get("name", "") for a in t.get("artists") or []),
"artist_uris": [a["uri"] for a in t.get("artists") or [] if a.get("uri")],
"artists": [{"name": a.get("name", ""), "uri": a["uri"]} for a in t.get("artists") or [] if a.get("uri")],
"album": (t.get("album") or {}).get("name"),
"cover_url": cover,
"duration_ms": t.get("duration_ms"),
"explicit": bool(t.get("explicit")),
}
class Spotify:
def __init__(self, k: Konfig):
self._k = k
self._refresh_token = k.spotify_refresh_token
self._token: str | None = None
self._ablauf = 0.0
self._lock = threading.Lock()
self._http = httpx.Client(timeout=8)
self._cache: dict[str, tuple[float, list[dict]]] = {}
def verfuegbar(self) -> bool:
return bool(self._k.spotify_client_id and self._k.spotify_client_secret and self._refresh_token)
def _access_token(self) -> str:
with self._lock:
if self._token and time.monotonic() < self._ablauf - 60:
return self._token
basic = base64.b64encode(f"{self._k.spotify_client_id}:{self._k.spotify_client_secret}".encode()).decode()
r = self._http.post(TOKEN_URL, headers={"Authorization": f"Basic {basic}"},
data={"grant_type": "refresh_token", "refresh_token": self._refresh_token})
if r.status_code != 200:
log.error("Token-Erneuerung fehlgeschlagen (%s)", r.status_code)
raise SpotifyFehler(r.status_code)
daten = r.json()
self._token = daten["access_token"]
self._ablauf = time.monotonic() + daten.get("expires_in", 3600)
neu = daten.get("refresh_token")
if neu and neu != self._refresh_token:
self._refresh_token = neu
env_wert_setzen(self._k.env_pfad, "SPOTIFY_REFRESH_TOKEN", neu)
log.info("Neuer Refresh-Token von Spotify gespeichert")
return self._token
def _get(self, pfad: str, params: dict) -> dict:
for versuch in range(2):
r = self._http.get(API + pfad, params=params, headers={"Authorization": f"Bearer {self._access_token()}"})
if r.status_code == 401 and versuch == 0:
self._token = None
continue
if r.status_code == 429:
raise SpotifyFehler(429, int(r.headers.get("Retry-After", "5")))
if r.status_code >= 400:
raise SpotifyFehler(r.status_code)
return r.json()
raise SpotifyFehler(401)
def suche(self, q: str) -> list[dict]:
schluessel = " ".join(q.casefold().split())
treffer = self._cache.get(schluessel)
if treffer and time.monotonic() - treffer[0] < CACHE_S:
return treffer[1]
daten = self._get("/search", {"q": q, "type": "track", "limit": 10, "market": "from_token"})
ergebnis = [titel_aus(t) for t in (daten.get("tracks") or {}).get("items") or []
if t and t.get("uri", "").startswith("spotify:track:") and t.get("is_playable", True)]
if len(self._cache) >= CACHE_MAX:
self._cache.clear()
self._cache[schluessel] = (time.monotonic(), ergebnis)
return ergebnis
def titel(self, uri: str) -> dict:
return titel_aus(self._get(f"/tracks/{uri.rsplit(':', 1)[-1]}", {"market": "from_token"}))
@lru_cache
def spotify() -> Spotify:
"""FastAPI-Dependency (in Tests per dependency_overrides ersetzbar)."""
return Spotify(konfig())