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
Executable
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Audiofy – einmaliger Spotify-Login (Authorization Code Flow).
Holt einen Refresh-Token für das eigene Spotify-Konto und schreibt ihn in die
.env-Datei. Funktioniert ohne Browser auf dem Gerät selbst:
1. Die ausgegebene Login-Adresse in einem beliebigen Browser öffnen.
2. Bei Spotify anmelden und zustimmen.
3. Der Browser landet auf http://127.0.0.1:8888/callback?... – die Seite lädt
nicht, das ist normal. Die komplette Adresse aus der Adresszeile kopieren
und hier einfügen.
Aufruf:
python3 auth.py --env /opt/audiofy/config/.env # Player (Wiedergabe steuern)
python3 auth.py --profil server --env /opt/audiofy/.env # Server (nur Suche)
Benötigte Einträge in der .env: SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET,
optional SPOTIFY_REDIRECT_URI. Nur Python-Standardbibliothek, kein venv nötig.
"""
import argparse
import base64
import getpass
import json
import os
import secrets
import stat
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
TOKEN_URL = "https://accounts.spotify.com/api/token"
ME_URL = "https://api.spotify.com/v1/me"
STANDARD_REDIRECT = "http://127.0.0.1:8888/callback"
# Player: Wiedergabe steuern + Playlist lesen; user-read-private nur, damit /me das
# Feld "product" (Premium-Check) liefert.
# Server: Suche braucht keinen Scope. ("pi"/"vps" = alte Namen)
SCOPES = {
"player": "user-read-playback-state user-modify-playback-state "
"user-read-currently-playing playlist-read-private user-read-private",
"server": "",
"pi": None, # alter Name für "player"
"vps": None, # alter Name für "server"
}
def env_lesen(pfad: Path) -> dict:
"""Einfacher KEY=VALUE-Parser (Kommentare und Anführungszeichen erlaubt)."""
werte = {}
for zeile in pfad.read_text().splitlines():
zeile = zeile.strip()
if not zeile or zeile.startswith("#") or "=" not in zeile:
continue
key, _, wert = zeile.partition("=")
werte[key.strip()] = wert.strip().strip('"').strip("'")
return werte
def env_setzen(pfad: Path, key: str, wert: str) -> None:
"""Setzt KEY=wert in der .env (ersetzt oder hängt an), atomar und mit Rechten 600."""
zeilen = pfad.read_text().splitlines()
neu, gefunden = [], False
for zeile in zeilen:
if zeile.strip().startswith(f"{key}="):
neu.append(f"{key}={wert}")
gefunden = True
else:
neu.append(zeile)
if not gefunden:
neu.append(f"{key}={wert}")
tmp = pfad.with_name(pfad.name + ".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write("\n".join(neu) + "\n")
os.replace(tmp, pfad)
def token_anfragen(client_id: str, client_secret: str, daten: dict) -> dict:
body = urllib.parse.urlencode(daten).encode()
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
req = urllib.request.Request(TOKEN_URL, data=body, headers={
"Authorization": f"Basic {basic}",
"Content-Type": "application/x-www-form-urlencoded",
})
try:
with urllib.request.urlopen(req, timeout=15) as antwort:
return json.load(antwort)
except urllib.error.HTTPError as e:
# Spotify-Fehlertexte enthalten keine Geheimnisse
sys.exit(f"Token-Anfrage fehlgeschlagen ({e.code}): {e.read().decode(errors='replace')[:300]}")
def me_abfragen(access_token: str) -> dict:
req = urllib.request.Request(ME_URL, headers={"Authorization": f"Bearer {access_token}"})
try:
with urllib.request.urlopen(req, timeout=15) as antwort:
return json.load(antwort)
except urllib.error.HTTPError as e:
sys.exit(f"/me fehlgeschlagen ({e.code}): {e.read().decode(errors='replace')[:300]}")
def main() -> None:
parser = argparse.ArgumentParser(description="Audiofy – Spotify-Login, schreibt den Refresh-Token in die .env")
parser.add_argument("--profil", choices=SCOPES, default="player", help="Scope-Satz (Standard: player)")
parser.add_argument("--env", type=Path, default=Path(__file__).resolve().parent / ".env",
help="Pfad zur .env (Standard: neben dem Skript)")
args = parser.parse_args()
args.profil = {"pi": "player", "vps": "server"}.get(args.profil, args.profil) # alte Namen
if not args.env.is_file():
sys.exit(f"{args.env} fehlt. Bitte anlegen (Rechte 600) mit SPOTIFY_CLIENT_ID und SPOTIFY_CLIENT_SECRET.")
if stat.S_IMODE(args.env.stat().st_mode) & 0o077:
print(f"Warnung: {args.env} ist für andere lesbar – bitte 'chmod 600 {args.env}'.", file=sys.stderr)
env = env_lesen(args.env)
client_id = env.get("SPOTIFY_CLIENT_ID", "")
client_secret = env.get("SPOTIFY_CLIENT_SECRET", "")
redirect_uri = env.get("SPOTIFY_REDIRECT_URI") or STANDARD_REDIRECT
if not client_id or not client_secret:
sys.exit(f"SPOTIFY_CLIENT_ID und SPOTIFY_CLIENT_SECRET müssen in {args.env} gesetzt sein.")
state = secrets.token_urlsafe(16)
parameter = {
"client_id": client_id,
"response_type": "code",
"redirect_uri": redirect_uri,
"state": state,
"show_dialog": "true",
}
if SCOPES[args.profil]:
parameter["scope"] = SCOPES[args.profil]
print(f"Profil: {args.profil}")
print("\n1) Diese Adresse im Browser öffnen und bei Spotify anmelden:\n")
print(f" {AUTHORIZE_URL}?{urllib.parse.urlencode(parameter)}\n")
print(f"2) Danach die komplette Adresse aus der Adresszeile ({redirect_uri}?...) einfügen.")
print(" (Eingabe wird nicht angezeigt.)\n")
eingabe = getpass.getpass("Adresse: ").strip()
url = urllib.parse.urlparse(eingabe)
query = urllib.parse.parse_qs(url.query)
if "error" in query:
sys.exit(f"Spotify meldet: {query['error'][0]}")
if query.get("state", [""])[0] != state:
sys.exit("State passt nicht – bitte die Adresse aus genau diesem Durchlauf einfügen.")
code = query.get("code", [""])[0]
if not code:
sys.exit("Kein 'code' in der Adresse gefunden.")
tokens = token_anfragen(client_id, client_secret, {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
})
refresh_token = tokens.get("refresh_token")
if not refresh_token:
sys.exit("Antwort enthält keinen Refresh-Token.")
env_setzen(args.env, "SPOTIFY_REFRESH_TOKEN", refresh_token)
print(f"\nRefresh-Token in {args.env} gespeichert.")
erhalten = set(tokens.get("scope", "").split())
fehlend = set(SCOPES[args.profil].split()) - erhalten
if fehlend:
print(f"Warnung: Diese Scopes wurden nicht gewährt: {' '.join(sorted(fehlend))}")
me = me_abfragen(tokens["access_token"])
print(f"Angemeldet als: {me.get('display_name') or me.get('id')}")
if "product" in me:
print(f"Abo: {me['product']}" + (" ✅" if me["product"] == "premium" else " ❌ (Premium nötig!)"))
if __name__ == "__main__":
main()
+716
View File
@@ -0,0 +1,716 @@
#!/usr/bin/env python3
"""Audiofy – Controller (Player).
Spielt zufällige Titel aus der Grund-Playlist auf dem Spotify-Connect-Gerät
"Audiofy" und schiebt nach jeweils N-1 Playlist-Titeln einen Wunsch ein.
Spotify bekommt immer nur den jeweils nächsten Titel (POST /me/player/queue,
ca. 15 s vor Titelende); die Reihenfolge verwaltet ausschließlich der Controller.
Wünsche, Einstellungen (N, Playlist, Neu mischen, Not-Aus) und Befehle des
Besitzers (Play, Pause, Weiter, Lautstärke) kommen vom Server (Player-API, ca. alle 3 s).
"Als Nächstes" des Besitzers wird sofort eingeplant und zählt nicht im Wunsch-Takt.
Ist der Server nicht erreichbar, läuft einfach die Grund-Playlist weiter.
Bedienung über die Spotify-App bleibt möglich: Fremde Titel werden erkannt und
geduldet, eine Pause durch den Besitzer (App oder Admin-Bereich) wird respektiert.
Aufruf: .venv/bin/python controller.py
"""
import argparse
import json
import logging
import os
import random
import re
import subprocess
import threading
import time
from collections import deque
from datetime import datetime, timezone
from dataclasses import dataclass
from pathlib import Path
import httpx
import lautsprecher
from auth import env_lesen, env_setzen
from spotify_client import SpotifyClient, SpotifyFehler
from server_client import ServerVerbindung, Wunsch
log = logging.getLogger("audiofy.controller")
ORDNER = Path(__file__).resolve().parent
POLL_S = 3 # Abfrageintervall /me/player und Server
VORLAUF_MS = 15_000 # so lange vor Titelende den Nächsten einreihen
ENDE_MS = 10_000 # Restzeit, ab der ein Stopp als "Titel zu Ende" gilt
START_TIMEOUT_S = 15 # spielt es nach einem Start so lange nicht, erneut versuchen
BT_PRUEF_S = 30 # Bluetooth-Verbindung so oft prüfen
BT_NEU_S = 10 # bei getrenntem Lautsprecher so oft neu verbinden
MAX_BACKOFF_S = 15 # nach Netzausfall schnell wieder dran sein
PAUSE_MIN_MS = 5_000 # pausierter Titel ab dieser Position gilt beim Start als Besitzer-Pause
BT_MELDEN_S = 30 # Lautsprecher-Zustand so oft an den Server melden
BT_BEREIT_S = 3 # nach dem Verbinden so lange warten, bis BlueALSA das Audiogerät anbietet
def kennung(item: dict) -> str:
"""Vergleichsschlüssel aus Titel + Hauptinterpret, unempfindlich für Zusätze wie "- 2022 Remaster"."""
name = re.sub(r"\s+-\s+.*$|\s*[(\[].*?[)\]]", "", item.get("name") or "").casefold().strip()
interpret = ((item.get("artists") or [{}])[0].get("name") or "").casefold().strip()
return f"{name}|{interpret}"
def titel_info(item: dict) -> dict | None:
"""Spotify-Track → Titelinfo für den Server (None für lokale Dateien, Podcasts usw.)."""
if not (item.get("uri") or "").startswith("spotify:track:"):
return None
bilder = sorted((item.get("album") or {}).get("images") or [], key=lambda b: b.get("width") or 0)
return {
"track_uri": item["uri"],
"title": (item.get("name") or "")[:300],
"artist": ", ".join(a.get("name", "") for a in item.get("artists") or [])[:300],
"album": ((item.get("album") or {}).get("name") or "")[:300] or None,
"cover_url": next((b["url"] for b in bilder if (b.get("width") or 0) >= 200),
bilder[-1]["url"] if bilder else None),
"duration_ms": item.get("duration_ms"),
}
@dataclass
class Eintrag:
"""Ein vom Controller geplanter Titel."""
uri: str
quelle: str = "playlist" # playlist, wish oder owner ("Als Nächstes" des Besitzers)
wunsch: Wunsch | None = None
direkt: bool = False # wurde schon einmal per PUT /me/player/play gestartet
kennung: str | None = None # Titel|Interpret, um Ersatzversionen zu erkennen
titel: dict | None = None # Titelinfo für die Anzeige "Als Nächstes"
class Playlist:
"""Grund-Playlist gemischt; ohne Wiederholung, bis alle Titel durch sind."""
def __init__(self, sp: SpotifyClient, playlist_id: str):
self.sp = sp
self.id = playlist_id
self._stapel: list[str] = []
self._letzter: str | None = None
def wechseln(self, playlist_id: str) -> None:
self.id = playlist_id
self._stapel = []
def neu_mischen(self) -> None:
self._stapel = [] # beim nächsten Titel neu laden und mischen
def naechster(self) -> str:
if not self._stapel:
titel = self.sp.playlist_titel(self.id)
if not titel:
raise RuntimeError("Grund-Playlist ist leer oder nicht lesbar")
random.shuffle(titel)
# Nicht denselben Titel direkt zweimal hintereinander (an der Mischgrenze)
if len(titel) > 1 and titel[-1] == self._letzter:
titel[0], titel[-1] = titel[-1], titel[0]
self._stapel = titel
log.info("Grund-Playlist geladen und gemischt: %d Titel", len(titel))
self._letzter = self._stapel.pop()
return self._letzter
class Controller:
def __init__(self, sp: SpotifyClient, playlist: Playlist, server: ServerVerbindung | None,
n: int, geraetename: str, bt_mac: str | None, zustand_pfad: Path | None = None,
env_pfad: Path | None = None):
self.sp = sp
self.env_pfad = env_pfad # hier wird ein neu gewählter Lautsprecher gespeichert
self.zustand_pfad = zustand_pfad # merkt sich Besitzer-Pause/Not-Aus über Neustarts hinweg
self.playlist = playlist
self.server = server
self.n = max(1, n)
self.geraetename = geraetename
self.bt_mac = bt_mac
self.seit_wunsch = 0 # Playlist-Titel seit dem letzten Wunsch
self.eingereiht: Eintrag | None = None # an Spotify übergeben, aber noch nicht gestartet
self.fremdwechsel = 0 # Titelwechsel, seit eingereiht wurde, ohne dass er kam
self.aktuell_uri: str | None = None
self.aktuell_quelle: str | None = None # Quelle des laufenden Titels (für den Server)
self.aktuell_wunsch_id: int | None = None
self.neuer_titel = False # Titelwechsel noch nicht an den Server gemeldet
self.vorher: dict | None = None # Zustand der letzten Abfrage
self.st: dict | None = None # letzte Antwort von /me/player
self.besitzer_pause = False
self.bt_pause = False # Wiedergabe stockt, weil der Lautsprecher weg ist
self.stopp = False # Not-Aus vom Admin-Bereich
self.neustart = False
self.direktstart = False # nächster Titel stammt aus starten()
self.gestartet_um: float | None = None
self.geraet_ok = True
self.fehler: str | None = None # z. B. Playlist nicht lesbar (wird gemeldet)
self._reshuffle: int | None = None
self._erledigt: deque[int] = deque(maxlen=200) # ausgeführte Befehls-IDs (nie doppelt)
self._bt_geprueft = 0.0
self._bt_verbunden: bool | None = None
self._bt_verbinden: subprocess.Popen | None = None # laufender Verbindungsversuch
self._bt_versuch = 0.0
self._geraet_fehlt_log = 0.0
self._bt_verbunden_seit = 0.0
self._lautsprecher_log = 0.0
self._gespeichert: dict | None = None
self._bt_aufgabe: threading.Thread | None = None # Suche/Kopplung im Hintergrund
self._bt_geraete: list[dict] = [] # Ergebnis der letzten Suche
self._bt_gesucht_um: str | None = None
self._bt_gemeldet = 0.0
self._zustand_laden()
# --- dauerhafter Zustand ---------------------------------------------
def _zustand_laden(self) -> None:
"""Pause/Not-Aus von vor dem Neustart übernehmen (sonst würde nach einem Reboot einfach losgespielt)."""
if not self.zustand_pfad or not self.zustand_pfad.is_file():
return
try:
daten = json.loads(self.zustand_pfad.read_text())
except (OSError, ValueError) as e:
log.warning("Zustandsdatei nicht lesbar (%s) – wird ignoriert", e)
return
self.besitzer_pause = bool(daten.get("besitzer_pause"))
self.stopp = bool(daten.get("stopp"))
self._gespeichert = {"besitzer_pause": self.besitzer_pause, "stopp": self.stopp}
if self.stopp:
log.warning("Vor dem Neustart war Not-Aus aktiv – bleibt aktiv, bis er im Admin-Bereich aufgehoben wird")
elif self.besitzer_pause:
log.info("Vor dem Neustart war pausiert – bleibt pausiert, bis Play gedrückt wird")
def zustand_sichern(self) -> None:
"""Schreibt nur bei Änderungen (schont die SD-Karte)."""
if not self.zustand_pfad:
return
aktuell = {"besitzer_pause": self.besitzer_pause, "stopp": self.stopp}
if aktuell == self._gespeichert:
return
try:
tmp = self.zustand_pfad.with_name(self.zustand_pfad.name + ".tmp")
tmp.write_text(json.dumps(aktuell))
os.replace(tmp, self.zustand_pfad)
self._gespeichert = aktuell
except OSError as e:
log.error("Zustand konnte nicht gespeichert werden: %s", e)
# --- Planung ----------------------------------------------------------
def naechster_eintrag(self) -> Eintrag:
wunsch = self.server.naechster_wunsch() if self.server else None
if wunsch and (wunsch.priority or self.seit_wunsch >= self.n - 1):
eintrag = Eintrag(wunsch.uri, "owner" if wunsch.priority else "wish", wunsch)
else:
# kein (fälliger) Wunsch → Playlist weiter, Zähler bleibt "fällig"
try:
eintrag = Eintrag(self.playlist.naechster())
self.fehler = None
except (RuntimeError, SpotifyFehler) as e:
self.fehler = "Grund-Playlist nicht lesbar – Link im Admin-Bereich prüfen"
raise RuntimeError(f"{self.fehler} ({e})") from e
try:
t = self.sp.titel(eintrag.uri)
eintrag.kennung = kennung(t)
eintrag.titel = titel_info(t)
except SpotifyFehler as e:
log.warning("Metadaten für %s nicht abrufbar (%s) – Vergleich nur per URI", eintrag.uri, e)
return eintrag
def geraet_id(self) -> str | None:
for geraet in self.sp.geraete():
if geraet.get("name") == self.geraetename:
self.geraet_ok = True
return geraet["id"]
self.geraet_ok = False
return None
def starten(self) -> None:
"""Wiedergabe auf dem Gerät "Audiofy" (neu) starten."""
gid = self.geraet_id()
if not gid:
# Warnung beim ersten Mal, danach höchstens einmal pro Minute
jetzt = time.monotonic()
if jetzt - self._geraet_fehlt_log >= 60:
log.warning("Gerät '%s' nicht gefunden – versuche es weiter alle %d s", self.geraetename, POLL_S)
self._geraet_fehlt_log = jetzt
return
self._geraet_fehlt_log = 0.0
# Ein bereits eingereihter Titel wird direkt gestartet. Liegt er noch in der
# Spotify-Queue, kann er später ein zweites Mal kommen (Queue nicht leerbar).
eintrag = self.eingereiht
if eintrag and eintrag.direkt:
# Schon einmal direkt gestartet – nicht in einer Schleife wiederholen
log.warning("Eintrag %s wurde bereits gestartet – wird übersprungen", eintrag.uri)
eintrag = None
eintrag = eintrag or self.naechster_eintrag()
self.sp.abspielen([eintrag.uri], gid)
eintrag.direkt = True
self.eingereiht = eintrag
self.fremdwechsel = 0
self.neustart = False
self.direktstart = True
self.aktuell_uri = None
self.gestartet_um = time.monotonic()
log.info("Wiedergabe auf '%s' gestartet: %s", self.geraetename, eintrag.uri)
# --- Abgleich mit dem Server ----------------------------------------------
def server_abgleich(self) -> list[dict]:
"""Konfiguration vom Server übernehmen; liefert die offenen Befehle."""
if not self.server:
return []
k = self.server.konfiguration()
if not k:
return [] # offline → mit den letzten bekannten Werten weiter
n = max(1, int(k.get("wish_every_n") or 3))
if n != self.n:
log.info("N geändert: jeder %d. Titel ist ein Wunsch", n)
self.n = n
if k.get("playlist_id") and k["playlist_id"] != self.playlist.id:
log.info("Grund-Playlist gewechselt: %s", k["playlist_id"])
self.playlist.wechseln(k["playlist_id"])
token = k.get("reshuffle_token")
if self._reshuffle is not None and token != self._reshuffle:
log.info("Grund-Playlist wird neu gemischt")
self.playlist.neu_mischen()
self._reshuffle = token
stopp = bool(k.get("stopped"))
if stopp != self.stopp:
log.warning("Not-Aus %s", "AKTIV – Controller angehalten" if stopp else "aufgehoben")
self.stopp = stopp
return k.get("commands") or []
def befehle_ausfuehren(self, befehle: list[dict]) -> None:
for b in befehle:
if b["id"] in self._erledigt:
continue
if b["type"] in ("bt_scan", "bt_connect"):
self._erledigt.append(b["id"])
self._bt_befehl(b["id"], b["type"], b.get("payload") or {})
continue
try:
ok, meldung = self._befehl(b["type"], b.get("payload") or {})
except SpotifyFehler as e:
ok, meldung = False, str(e)[:200]
self._erledigt.append(b["id"])
log.info("Befehl %s: %s%s", b["type"], "ausgeführt" if ok else "fehlgeschlagen",
f" ({meldung})" if meldung else "")
if self.server:
self.server.quittieren(b["id"], ok, meldung)
# --- Lautsprecher wechseln ------------------------------------------------
def _bt_befehl(self, befehl_id: int, typ: str, payload: dict) -> None:
"""Suche/Kopplung dauert bis zu 45 s → eigener Thread, Quittung kommt danach."""
fehler = None
if not lautsprecher.verfuegbar():
fehler = "Kein Bluetooth auf diesem Player"
elif self._bt_aufgabe and self._bt_aufgabe.is_alive():
fehler = "Es läuft schon eine Suche oder Kopplung"
elif typ == "bt_connect" and not lautsprecher.MAC_RE.match(payload.get("mac") or ""):
fehler = "Ungültige Lautsprecher-Adresse"
if fehler:
log.info("Befehl %s: fehlgeschlagen (%s)", typ, fehler)
if self.server:
self.server.quittieren(befehl_id, False, fehler)
return
log.info("Befehl %s: läuft im Hintergrund", typ)
self._bt_aufgabe = threading.Thread(target=self._bt_arbeit, args=(befehl_id, typ, payload.get("mac")),
daemon=True)
self._bt_aufgabe.start()
def _bt_arbeit(self, befehl_id: int, typ: str, mac: str | None) -> None:
ok, meldung = False, "Unbekannter Fehler"
try:
self.bluetooth_melden(laeuft=True)
if typ == "bt_scan":
self._bt_geraete = lautsprecher.suchen()
self._bt_gesucht_um = datetime.now(timezone.utc).isoformat(timespec="seconds")
ok, meldung = True, f"{len(self._bt_geraete)} Audiogerät(e) gefunden"
else:
alt = self.bt_mac
if alt and alt != mac:
lautsprecher.trennen(alt) # sonst bleibt BlueALSA beim alten Lautsprecher
ok, meldung = lautsprecher.koppeln(mac)
if ok:
self.bt_mac = mac
self._bt_verbunden = None # beim nächsten Durchlauf frisch prüfen
if self.env_pfad:
env_setzen(self.env_pfad, "BT_SPEAKER_MAC", mac)
self._bt_geraete = [lautsprecher.info(g["mac"]) for g in self._bt_geraete]
elif alt and alt != mac:
meldung += " – alter Lautsprecher wird wieder verbunden"
lautsprecher.verbinden(alt)
except Exception as e: # Hintergrund-Thread darf nie still sterben
log.exception("Lautsprecher-Vorgang fehlgeschlagen")
ok, meldung = False, f"Fehler: {type(e).__name__}"
finally:
log.info("Befehl %s: %s (%s)", typ, "ausgeführt" if ok else "fehlgeschlagen", meldung)
if self.server:
self.server.quittieren(befehl_id, ok, meldung[:500])
self.bluetooth_melden(laeuft=False)
def bluetooth_melden(self, laeuft: bool | None = None) -> None:
"""Lautsprecher und Suchergebnis an den Server (Admin → Einstellungen → Lautsprecher)."""
if not self.server or not lautsprecher.verfuegbar():
return
if laeuft is None: # Aufruf aus der Hauptschleife: nur alle BT_MELDEN_S Sekunden
if time.monotonic() - self._bt_gemeldet < BT_MELDEN_S:
return
laeuft = bool(self._bt_aufgabe and self._bt_aufgabe.is_alive())
self._bt_gemeldet = time.monotonic()
aktuell = lautsprecher.oeffentlich(lautsprecher.info(self.bt_mac)) if self.bt_mac else None
self.server.bluetooth_melden({
"current": aktuell,
"devices": [lautsprecher.oeffentlich(g) for g in self._bt_geraete],
"scanning": laeuft,
"scanned_at": self._bt_gesucht_um,
})
def _befehl(self, typ: str, payload: dict) -> tuple[bool, str | None]:
gid = self.geraet_id()
if not gid:
return False, f"Gerät '{self.geraetename}' nicht gefunden"
if typ == "pause":
try:
self.sp.anfrage("PUT", "/me/player/pause", params={"device_id": gid})
except SpotifyFehler as e:
if e.status != 403: # 403 = war schon pausiert
raise
self.besitzer_pause = True
return True, None
if typ == "volume":
prozent = max(0, min(100, int(payload.get("percent", 50))))
self.sp.anfrage("PUT", "/me/player/volume", params={"volume_percent": prozent, "device_id": gid})
if self.st and self.st.get("device"):
self.st["device"]["volume_percent"] = prozent # gleich so melden, nicht erst nach der nächsten Abfrage
return True, f"{prozent} %"
if self.stopp:
return False, "Not-Aus ist aktiv"
if typ in ("play", "skip") and not self.lautsprecher_bereit():
return False, "Lautsprecher nicht verbunden"
if typ == "play":
st = self.sp.wiedergabe()
self.besitzer_pause = False
if st and (st.get("device") or {}).get("name") == self.geraetename and st.get("item") \
and (st.get("progress_ms") or 0) >= 2000:
self.sp.fortsetzen(gid) # pausierten Titel an derselben Stelle fortsetzen
else:
self.starten() # nichts Sinnvolles zum Fortsetzen → nächster Titel
return True, None
if typ == "skip":
if self.eingereiht:
self.sp.anfrage("POST", "/me/player/next", params={"device_id": gid})
else:
self.starten() # Nächster noch nicht eingereiht → direkt starten
self.besitzer_pause = False
return True, None
return False, f"Unbekannter Befehl: {typ}"
def melden(self) -> None:
"""Aktuellen Zustand an den Server ("Läuft gerade", Status für den Admin-Bereich)."""
if not self.server:
return
st = self.st or {}
geraet = st.get("device") or {}
unser = geraet.get("name") == self.geraetename
track = titel_info(st.get("item") or {}) if unser else None
naechster = self.eingereiht.titel if self.eingereiht else None
zustand = {
"track": track,
"progress_ms": (st.get("progress_ms") or 0) if track else 0,
"is_playing": bool(track and st.get("is_playing")),
"new_track": self.neuer_titel,
"source": self.aktuell_quelle if track else None,
"wish_id": self.aktuell_wunsch_id if track else None,
"next": naechster,
"device_ok": self.geraet_ok or unser,
"speaker_ok": self._bt_verbunden is not False,
"stopped": self.stopp,
"volume_percent": geraet.get("volume_percent") if unser else None,
"error": self.fehler,
}
if self.server.melden(zustand):
self.neuer_titel = False
# --- Hauptschritt -----------------------------------------------------
def schritt(self) -> None:
st = self.sp.wiedergabe()
self.st = st
geraet = (st or {}).get("device") or {}
item = (st or {}).get("item") or {}
unser = geraet.get("name") == self.geraetename
spielt = bool(st and st.get("is_playing") and item)
uri = item.get("uri")
fortschritt = (st or {}).get("progress_ms") or 0
rest = (item.get("duration_ms") or 0) - fortschritt
if unser:
self.geraet_ok = True
vorher = self.vorher
self.vorher = {"spielt": spielt, "unser": unser, "uri": uri, "rest": rest, "fortschritt": fortschritt,
"zeit": time.monotonic()}
if self.stopp:
# Not-Aus: anhalten und nichts selbst starten oder einreihen
if spielt and unser:
self.sp.anfrage("PUT", "/me/player/pause", params={"device_id": geraet["id"]})
log.warning("Not-Aus: Wiedergabe angehalten")
self.neustart = False
self.besitzer_pause = True # nach dem Aufheben nicht eigenmächtig starten
return
if spielt and not unser:
if not vorher or vorher["unser"] or not vorher["spielt"]:
log.info("Wiedergabe läuft auf '%s' – Controller wartet", geraet.get("name"))
return
if spielt:
self.besitzer_pause = False
self.bt_pause = False
self.gestartet_um = None
self._titelwechsel_pruefen(uri, fortschritt, item, vorher)
if self.eingereiht is None and rest < VORLAUF_MS:
eintrag = self.naechster_eintrag()
self.sp.einreihen(eintrag.uri, geraet["id"])
self.eingereiht = eintrag
self.fremdwechsel = 0
log.info("Als Nächstes eingereiht: %s [%s]", eintrag.uri, eintrag.quelle)
return
# --- es läuft nichts (auf Audiofy) ---
if self.bt_pause:
# librespot pausiert bei Audiofehlern selbst – nach Neuverbindung fortsetzen
if self.lautsprecher_bereit():
gid = self.geraet_id()
if gid:
self.sp.fortsetzen(gid)
self.bt_pause = False
log.info("Lautsprecher wieder da – Wiedergabe fortgesetzt")
return
if vorher is None:
# Controller-Start: pausierter Titel auf Audiofy = Besitzer-Pause, sonst starten
if st and unser and item and fortschritt >= PAUSE_MIN_MS:
if not self._bt_pruefen_jetzt():
log.warning("Audiofy pausiert und Lautsprecher getrennt – geht nach Neuverbindung weiter")
self.bt_pause = True
else:
log.info("Audiofy ist pausiert – warte, bis der Besitzer fortsetzt")
self.besitzer_pause = True
else:
self.neustart = True
elif vorher["spielt"] and vorher["unser"]:
# Titelende: rechnerisch erreicht (auch über einen Netzausfall hinweg) oder
# Spotify hat die Position nach dem Ende auf ~0 zurückgesetzt
vergangen_ms = (time.monotonic() - vorher["zeit"]) * 1000
titel_zu_ende = (vorher["rest"] - vergangen_ms <= ENDE_MS
or (uri == vorher["uri"] and fortschritt < 2000 and vorher["fortschritt"] > fortschritt + 2000))
if titel_zu_ende:
log.warning("Wiedergabe nach Titelende gestoppt – starte nächsten Titel")
self.neustart = True
elif st and unser and not self._bt_pruefen_jetzt():
log.warning("Lautsprecher getrennt – Wiedergabe geht nach Neuverbindung weiter")
self.bt_pause = True
elif st and unser:
log.info("Pausiert (Besitzer) – wird respektiert")
self.besitzer_pause = True
else:
log.warning("Gerät '%s' nicht mehr aktiv – starte Wiedergabe neu", self.geraetename)
self.neustart = True
elif self.gestartet_um and time.monotonic() - self.gestartet_um > START_TIMEOUT_S:
log.warning("Start hat nicht gegriffen – neuer Versuch")
self.neustart = True
if self.neustart and not self.besitzer_pause and self.lautsprecher_bereit():
self.starten()
def lautsprecher_bereit(self) -> bool:
"""Ohne verbundenen Lautsprecher nichts starten: librespot stürzt sonst ab (kein Audiogerät)."""
if not self.bt_mac:
return True
if self._bt_verbunden is None:
self._bt_verbunden = self._bt_pruefen_jetzt()
if self._bt_verbunden:
self._bt_verbunden_seit = time.monotonic() - BT_BEREIT_S
bereit = self._bt_verbunden and time.monotonic() - self._bt_verbunden_seit >= BT_BEREIT_S
if not bereit and time.monotonic() - self._lautsprecher_log >= 60:
log.warning("Lautsprecher nicht verbunden – Wiedergabe startet, sobald er wieder da ist")
self._lautsprecher_log = time.monotonic()
if bereit:
self._lautsprecher_log = 0.0
return bool(bereit)
def _titelwechsel_pruefen(self, uri: str, fortschritt: int, item: dict, vorher: dict | None) -> None:
gleicher_titel_neu = (vorher is not None and vorher["uri"] == uri and vorher["rest"] <= ENDE_MS
and fortschritt + 5000 < vorher["fortschritt"])
if uri == self.aktuell_uri and not gleicher_titel_neu:
return
self.aktuell_uri = uri
self.neuer_titel = True
# Spotify spielt manchmal eine Ersatzversion mit anderer ID (z. B. Remaster),
# ohne linked_from. Deshalb zusätzlich über Titel+Interpret vergleichen.
# Alte Reste in der Spotify-Queue gelten so als "fremd"; unser Eintrag bleibt
# offen und kommt direkt danach.
eintrag = self.eingereiht
passt = eintrag is not None and (eintrag.uri == uri or self.direktstart
or (eintrag.kennung is not None and eintrag.kennung == kennung(item)))
self.direktstart = False
self.aktuell_wunsch_id = None
if passt:
if eintrag.uri != uri:
log.info("Spotify spielt %s statt %s (Ersatzversion)", uri, eintrag.uri)
self.eingereiht = None
self.aktuell_quelle = eintrag.quelle
if eintrag.wunsch:
self.aktuell_wunsch_id = eintrag.wunsch.id
if self.server:
self.server.wunsch_gespielt(eintrag.wunsch)
if eintrag.quelle == "wish":
self.seit_wunsch = 0
elif eintrag.quelle == "playlist":
self.seit_wunsch += 1
# "owner" (Als Nächstes des Besitzers) zählt nicht im Wunsch-Takt
else:
self.aktuell_quelle = "fremd"
if eintrag:
self.fremdwechsel += 1
if self.fremdwechsel >= 3:
# Ein Wunsch bleibt auf dem Server offen und wird später erneut angeboten
log.warning("Eingereihter Titel %s kam nicht – wird verworfen", eintrag.uri)
self.eingereiht = None
self.fremdwechsel = 0
interpreten = ", ".join(a["name"] for a in item.get("artists", []))
bis_wunsch = max(0, self.n - 1 - self.seit_wunsch)
log.info("▶ %s – %s [%s] (Wunsch fällig in %d)", item.get("name"), interpreten, self.aktuell_quelle, bis_wunsch)
# --- Bluetooth --------------------------------------------------------
def _bt_pruefen_jetzt(self) -> bool:
"""Sofortige Prüfung, ob der Lautsprecher verbunden ist (ohne Lautsprecher: immer True)."""
if not self.bt_mac:
return True
try:
info = subprocess.run(["bluetoothctl", "info", self.bt_mac], capture_output=True, text=True, timeout=10)
verbunden = "Connected: yes" in info.stdout
except (OSError, subprocess.SubprocessError):
return True # im Zweifel als Besitzer-Pause behandeln
if not verbunden and self._bt_verbunden is not False:
log.warning("Bluetooth-Lautsprecher %s nicht verbunden – verbinde neu", self.bt_mac)
self._bt_verbunden = False
return verbunden
def bluetooth_pruefen(self) -> None:
"""Lautsprecher bei Verbindungsverlust neu verbinden (bluetoothctl, ohne sudo).
Der Verbindungsversuch läuft als Hintergrundprozess, damit die Hauptschleife
nicht blockiert. Bei getrenntem Lautsprecher wird in jedem Durchlauf geprüft.
"""
if not self.bt_mac or (self._bt_aufgabe and self._bt_aufgabe.is_alive()):
return
if self._bt_verbinden and self._bt_verbinden.poll() is not None:
self._bt_verbinden = None # Versuch beendet (Erfolg oder nicht)
jetzt = time.monotonic()
if self._bt_verbunden is not False and jetzt - self._bt_geprueft < BT_PRUEF_S:
return
self._bt_geprueft = jetzt
vorher = self._bt_verbunden
verbunden = self._bt_pruefen_jetzt()
self._bt_verbunden = verbunden
if vorher is not None and verbunden != vorher:
self._bt_gemeldet = 0.0 # Änderung gleich an den Server melden
if verbunden:
if vorher is not True:
self._bt_verbunden_seit = jetzt
if vorher is False:
log.info("Bluetooth-Lautsprecher wieder verbunden")
return
if self._bt_verbinden is None and jetzt - self._bt_versuch >= BT_NEU_S:
self._bt_versuch = jetzt
try:
self._bt_verbinden = subprocess.Popen(
["bluetoothctl", "--timeout", "15", "connect", self.bt_mac],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as e:
log.error("bluetoothctl nicht startbar: %s", e)
def main() -> None:
parser = argparse.ArgumentParser(description="Audiofy-Controller")
parser.add_argument("--env", type=Path, default=ORDNER / ".env")
args = parser.parse_args()
# Unter systemd setzt journald die Zeitstempel selbst
unter_systemd = "JOURNAL_STREAM" in os.environ
logging.basicConfig(level=logging.INFO, datefmt="%H:%M:%S",
format="%(levelname)-7s %(message)s" if unter_systemd else "%(asctime)s %(levelname)-7s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # keine Request-URLs im Log
env = env_lesen(args.env)
sp = SpotifyClient(env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"], env["SPOTIFY_REFRESH_TOKEN"],
neuer_refresh_token=lambda t: env_setzen(args.env, "SPOTIFY_REFRESH_TOKEN", t))
# alte Namen (VPS_URL, PI_API_TOKEN) weiter akzeptieren
server_url = env.get("SERVER_URL") or env.get("VPS_URL")
token = env.get("PLAYER_API_TOKEN") or env.get("PI_API_TOKEN")
server = ServerVerbindung(server_url, token) if server_url and token else None
if not server:
log.warning("SERVER_URL/PLAYER_API_TOKEN fehlen – nur Grund-Playlist, keine Wünsche")
controller = Controller(
sp,
Playlist(sp, env["SPOTIFY_PLAYLIST_ID"]),
server,
n=int(env.get("WISH_EVERY_N") or 3),
geraetename=env.get("DEVICE_NAME") or "Audiofy",
bt_mac=env.get("BT_SPEAKER_MAC") or None,
zustand_pfad=args.env.parent / "zustand.json",
env_pfad=args.env,
)
log.info("Controller gestartet (Gerät '%s', Server: %s)", controller.geraetename, server_url or "–")
backoff = POLL_S
while True:
try:
controller.bluetooth_pruefen()
befehle = controller.server_abgleich()
controller.schritt()
controller.befehle_ausfuehren(befehle)
controller.melden()
controller.bluetooth_melden()
controller.zustand_sichern()
backoff = POLL_S
time.sleep(POLL_S)
except KeyboardInterrupt:
log.info("Beendet")
return
except SpotifyFehler as e:
if e.retry_after:
log.warning("Spotify-Rate-Limit – warte %d s", e.retry_after)
time.sleep(e.retry_after)
continue
log.error("Spotify-Fehler %s – neuer Versuch in %d s", e, backoff)
time.sleep(backoff)
backoff = min(backoff * 2, MAX_BACKOFF_S)
except httpx.HTTPError as e:
log.error("Netzwerkfehler (%s) – neuer Versuch in %d s", type(e).__name__, backoff)
time.sleep(backoff)
backoff = min(backoff * 2, MAX_BACKOFF_S)
except RuntimeError as e:
log.error("%s – neuer Versuch in %d s", e, backoff)
controller.melden() # Fehler im Admin-Bereich sichtbar machen
time.sleep(backoff)
backoff = min(backoff * 2, MAX_BACKOFF_S)
except Exception:
log.exception("Unerwarteter Fehler – neuer Versuch in %d s", backoff)
time.sleep(backoff)
backoff = min(backoff * 2, MAX_BACKOFF_S)
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
"""Audiofy – Bluetooth-Lautsprecher suchen, koppeln und verbinden (bluetoothctl, ohne sudo).
Gekoppelt wird immer MIT Agent in derselben bluetoothctl-Sitzung – sonst speichert
BlueZ den Schlüssel nicht dauerhaft ("Bonded: no"), und der Lautsprecher lässt sich
nach dem Aus- und Einschalten nicht mehr verbinden ("Permission denied").
"""
import logging
import re
import shutil
import subprocess
import time
log = logging.getLogger("audiofy.lautsprecher")
MAC_RE = re.compile(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$")
SUCHDAUER_S = 15
MAX_GERAETE = 30
def verfuegbar() -> bool:
return shutil.which("bluetoothctl") is not None
def _ctl(*args: str, timeout: int = 20) -> str:
try:
return subprocess.run(["bluetoothctl", *args], capture_output=True, text=True, timeout=timeout).stdout
except (OSError, subprocess.SubprocessError) as e:
log.warning("bluetoothctl %s fehlgeschlagen: %s", args[0] if args else "", e)
return ""
def info(mac: str) -> dict:
"""Name und Zustand eines Geräts; "audio" = kann Musik abspielen (A2DP-Sink)."""
text = _ctl("info", mac, timeout=10)
name = re.search(r"^\s*Name:\s*(.+)$", text, re.M)
ja = lambda feld: re.search(rf"^\s*{feld}:\s*yes", text, re.M) is not None # noqa: E731
return {
"mac": mac,
"name": (name.group(1).strip() if name else "")[:100],
"paired": ja("Paired"),
"bonded": ja("Bonded"),
"connected": ja("Connected"),
"audio": "Audio Sink" in text or re.search(r"^\s*Icon:\s*audio", text, re.M) is not None,
}
def oeffentlich(geraet: dict) -> dict:
"""Nur die Felder, die an den Server gemeldet werden."""
return {k: geraet[k] for k in ("mac", "name", "paired", "connected")}
def suchen(dauer: int = SUCHDAUER_S) -> list[dict]:
"""Sucht Bluetooth-Geräte und liefert nur Audiogeräte (verbundene und gekoppelte zuerst)."""
_ctl("--timeout", str(dauer), "scan", "on", timeout=dauer + 10)
macs = re.findall(r"^Device\s+(([0-9A-F]{2}:){5}[0-9A-F]{2})", _ctl("devices"), re.M)
geraete = [g for g in (info(m[0]) for m in macs) if g["audio"]]
geraete.sort(key=lambda g: (not g["connected"], not g["paired"], g["name"].casefold()))
return geraete[:MAX_GERAETE]
def trennen(mac: str) -> None:
_ctl("disconnect", mac, timeout=15)
def verbinden(mac: str) -> bool:
_ctl("--timeout", "15", "connect", mac, timeout=25)
return info(mac)["connected"]
def koppeln(mac: str) -> tuple[bool, str]:
"""Koppelt (falls nötig, mit Agent), vertraut und verbindet. Liefert (ok, Meldung)."""
if not MAC_RE.match(mac):
return False, "Ungültige Adresse"
vorher = info(mac)
if not vorher["bonded"]:
# Eine interaktive Sitzung, damit der Agent während des Koppelns registriert bleibt
befehle = []
if vorher["paired"]:
befehle.append((f"remove {mac}", 2)) # halbe Kopplung ohne Schlüssel verwerfen
befehle += [("agent NoInputNoOutput", 1), ("default-agent", 1), ("pairable on", 1),
("scan on", 8), ("scan off", 1), (f"pair {mac}", 12), (f"trust {mac}", 2),
(f"connect {mac}", 10), ("quit", 1)]
p = None
try:
p = subprocess.Popen(["bluetoothctl"], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, text=True)
for befehl, warten in befehle:
p.stdin.write(befehl + "\n")
p.stdin.flush()
time.sleep(warten)
p.stdin.close()
p.wait(timeout=10)
except (OSError, subprocess.SubprocessError, BrokenPipeError) as e:
return False, f"Kopplung fehlgeschlagen ({type(e).__name__})"
finally:
if p is not None and p.poll() is None:
p.kill()
else:
_ctl("trust", mac)
jetzt = info(mac)
if not jetzt["connected"] and jetzt["bonded"]:
verbinden(mac)
jetzt = info(mac)
if jetzt["connected"] and jetzt["bonded"]:
return True, f"Verbunden mit {jetzt['name'] or mac}"
if not jetzt["bonded"]:
return False, "Kopplung fehlgeschlagen – ist der Lautsprecher im Kopplungsmodus?"
return False, "Gekoppelt, aber keine Verbindung – Lautsprecher eingeschaltet und in Reichweite?"
+1
View File
@@ -0,0 +1 @@
httpx==0.28.1
+94
View File
@@ -0,0 +1,94 @@
"""Audiofy – Verbindung Player → Server (Player-API mit Bearer-Token, nur ausgehend).
Alle Aufrufe fangen Fehler selbst ab: Ist der Server nicht erreichbar, liefern sie
None/False, und der Controller spielt einfach die Grund-Playlist weiter. Im
Offline-Fall wird nur alle OFFLINE_RETRY_S Sekunden ein neuer Versuch gemacht.
"""
import logging
import time
from dataclasses import dataclass
import httpx
log = logging.getLogger("audiofy.server")
OFFLINE_RETRY_S = 15
TIMEOUT_S = 5
@dataclass
class Wunsch:
id: int
uri: str
title: str = ""
artist: str = ""
priority: bool = False # "Als Nächstes" des Besitzers
class ServerVerbindung:
def __init__(self, basis_url: str, token: str):
self._http = httpx.Client(base_url=basis_url.rstrip("/") + "/api/player", timeout=TIMEOUT_S,
headers={"Authorization": f"Bearer {token}"})
self.online: bool | None = None
self._naechster_versuch = 0.0
self._unbestaetigt: set[int] = set() # gespielte Wünsche, deren Meldung noch fehlt
# --- intern ----------------------------------------------------------------
def _anfrage(self, methode: str, pfad: str, **kwargs):
"""JSON der Antwort oder None bei jedem Fehler (Netz, Timeout, HTTP-Fehler)."""
if self.online is False and time.monotonic() < self._naechster_versuch:
return None
try:
r = self._http.request(methode, pfad, **kwargs)
if r.status_code == 401:
self._offline("Token abgelehnt (401) – PLAYER_API_TOKEN prüfen")
return None
r.raise_for_status()
if self.online is not True:
log.info("Server erreichbar")
self.online = True
return r.json() if r.content else {}
except (httpx.HTTPError, ValueError) as e:
self._offline(f"{type(e).__name__}")
return None
def _offline(self, grund: str) -> None:
if self.online is not False:
log.warning("Server nicht erreichbar (%s) – spiele Grund-Playlist weiter, neuer Versuch alle %d s",
grund, OFFLINE_RETRY_S)
self.online = False
self._naechster_versuch = time.monotonic() + OFFLINE_RETRY_S
# --- Player-API ----------------------------------------------------------------
def konfiguration(self) -> dict | None:
return self._anfrage("GET", "/config")
def naechster_wunsch(self) -> Wunsch | None:
self._bestaetigungen_nachholen()
daten = self._anfrage("GET", "/next-wish")
w = (daten or {}).get("wish")
if not w or w["id"] in self._unbestaetigt:
return None # noch nicht als gespielt bestätigt → nicht doppelt spielen
return Wunsch(id=w["id"], uri=w["track_uri"], title=w.get("title", ""), artist=w.get("artist", ""),
priority=bool(w.get("priority")))
def wunsch_gespielt(self, wunsch: Wunsch) -> None:
self._unbestaetigt.add(wunsch.id)
self._bestaetigungen_nachholen()
def _bestaetigungen_nachholen(self) -> None:
for wid in list(self._unbestaetigt):
if self._anfrage("POST", f"/wish/{wid}/played") is not None:
self._unbestaetigt.discard(wid)
def melden(self, zustand: dict) -> bool:
return self._anfrage("POST", "/now-playing", json=zustand) is not None
def bluetooth_melden(self, zustand: dict) -> bool:
return self._anfrage("POST", "/bluetooth", json=zustand) is not None
def quittieren(self, befehl_id: int, ok: bool, meldung: str | None = None) -> None:
self._anfrage("POST", f"/commands/{befehl_id}/ack", json={"ok": ok, "message": meldung})
+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