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
+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})