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