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
+167
View File
@@ -0,0 +1,167 @@
"""Audiofy-Web – Player-API (/api/player/…), geschützt per Bearer-Token (PLAYER_API_TOKEN).
Der Player baut nur ausgehende Verbindungen auf und fragt hier ca. alle 3 s nach:
GET /api/player/config Einstellungen + offene Befehle (Ziel: Reaktion < 3 s)
GET /api/player/next-wish nächster Wunsch (Besitzer-Priorität zuerst) oder null
POST /api/player/wish/{id}/played Wunsch wurde gestartet → erledigt
POST /api/player/now-playing aktueller Titel/Zustand (schreibt den Verlauf)
POST /api/player/bluetooth Lautsprecher und Suchergebnisse
POST /api/player/commands/{id}/ack Befehl ausgeführt (oder fehlgeschlagen)
Der alte Pfad /api/pi/… bleibt vorerst als Alias erhalten (ältere Player).
"""
import hmac
import json
import sqlite3
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from .config import Konfig, konfig
from .db import einstellungen, get_db, jetzt, lebenszeichen, offene_befehle
_bearer = HTTPBearer(auto_error=False)
def player_auth(cred: HTTPAuthorizationCredentials | None = Depends(_bearer), k: Konfig = Depends(konfig)) -> None:
if not k.player_api_token:
raise HTTPException(503, "PLAYER_API_TOKEN ist auf dem Server nicht gesetzt")
if cred is None or not hmac.compare_digest(cred.credentials.encode(), k.player_api_token.encode()):
raise HTTPException(401, "Ungültiger Token", headers={"WWW-Authenticate": "Bearer"})
router = APIRouter(tags=["player"], dependencies=[Depends(player_auth)]) # Präfix setzt main.py
class Titel(BaseModel):
track_uri: str = Field(pattern=r"^spotify:track:[A-Za-z0-9]{22}$")
title: str = Field(max_length=300)
artist: str = Field(max_length=300)
album: str | None = Field(default=None, max_length=300)
cover_url: str | None = Field(default=None, max_length=500)
duration_ms: int | None = Field(default=None, ge=0)
class JetztLaeuft(BaseModel):
track: Titel | None = None # None = es läuft nichts
progress_ms: int = Field(default=0, ge=0)
is_playing: bool = False
new_track: bool = False # Controller hat einen Titelwechsel erkannt
source: Literal["playlist", "wish", "owner", "fremd"] | None = None
wish_id: int | None = None
next: Titel | None = None # bereits eingereihter nächster Titel
device_ok: bool = True # Gerät "Audiofy" erreichbar
speaker_ok: bool = True # Bluetooth-Lautsprecher verbunden
stopped: bool = False # Controller im Not-Aus
volume_percent: int | None = Field(default=None, ge=0, le=100)
error: str | None = Field(default=None, max_length=300) # z. B. "Playlist nicht lesbar"
MAC_RE = r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$"
class Lautsprecher(BaseModel):
mac: str = Field(pattern=MAC_RE)
name: str = Field(default="", max_length=100)
paired: bool = False
connected: bool = False
class BluetoothZustand(BaseModel):
current: Lautsprecher | None = None # eingestellter Lautsprecher
devices: list[Lautsprecher] = Field(default_factory=list, max_length=50) # letzte Suche
scanning: bool = False # Suche oder Kopplung läuft gerade
scanned_at: str | None = Field(default=None, max_length=40)
class Quittung(BaseModel):
ok: bool = True
message: str | None = Field(default=None, max_length=500)
@router.get("/config")
def pi_config(db: sqlite3.Connection = Depends(get_db)) -> dict:
lebenszeichen(db)
e = einstellungen(db)
return {
"wish_every_n": e["wish_every_n"],
"playlist_id": e["playlist_id"],
"reshuffle_token": e["reshuffle_token"],
"stopped": e["emergency_stop"],
"commands": offene_befehle(db),
}
@router.get("/next-wish")
def naechster_wunsch(db: sqlite3.Connection = Depends(get_db)) -> dict:
lebenszeichen(db)
if einstellungen(db)["emergency_stop"]:
return {"wish": None}
r = db.execute("SELECT id, track_uri, title, artist, cover_url, duration_ms, priority FROM wishes "
"WHERE status = 'queued' ORDER BY priority DESC, position, id LIMIT 1").fetchone()
if r is None:
return {"wish": None}
return {"wish": {"id": r["id"], "track_uri": r["track_uri"], "title": r["title"], "artist": r["artist"],
"cover_url": r["cover_url"], "duration_ms": r["duration_ms"], "priority": bool(r["priority"])}}
@router.post("/wish/{wish_id}/played")
def wunsch_gespielt(wish_id: int, db: sqlite3.Connection = Depends(get_db)) -> dict:
with db:
cur = db.execute("UPDATE wishes SET status = 'played', played_at = ? WHERE id = ? AND status = 'queued'",
(jetzt(), wish_id))
if cur.rowcount:
return {"ok": True, "status": "played"}
r = db.execute("SELECT status FROM wishes WHERE id = ?", (wish_id,)).fetchone()
if r is None:
raise HTTPException(404, "Wunsch nicht gefunden")
return {"ok": True, "status": r["status"]} # schon erledigt/gelöscht – idempotent
@router.post("/now-playing")
def jetzt_laeuft(daten: JetztLaeuft, db: sqlite3.Connection = Depends(get_db)) -> dict:
zeit = jetzt()
uri = daten.track.track_uri if daten.track else None
with db:
db.execute("UPDATE pi_state SET track_uri = ?, payload = ?, updated_at = ?, last_seen = ? WHERE id = 1",
(uri, daten.model_dump_json(), zeit, zeit))
# Verlauf: nur Titel, die wirklich laufen – neuer Eintrag bei jedem Titelwechsel
# (auch derselbe Titel erneut); ein beim Start nur pausierter Titel zählt nicht
letzter = db.execute("SELECT track_uri FROM history ORDER BY id DESC LIMIT 1").fetchone()
if daten.track and daten.is_playing and (daten.new_track or uri != (letzter[0] if letzter else None)):
wish_id = daten.wish_id
if wish_id is not None and not db.execute("SELECT 1 FROM wishes WHERE id = ?", (wish_id,)).fetchone():
wish_id = None
db.execute("INSERT INTO history (track_uri, title, artist, cover_url, source, wish_id, started_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(uri, daten.track.title, daten.track.artist, daten.track.cover_url, daten.source,
wish_id, zeit))
return {"ok": True}
@router.post("/bluetooth")
def bluetooth_melden(daten: BluetoothZustand, db: sqlite3.Connection = Depends(get_db)) -> dict:
"""Player meldet Lautsprecher und Suchergebnisse (für den Admin-Bereich)."""
with db:
db.execute("UPDATE pi_state SET bluetooth = ?, last_seen = ? WHERE id = 1", (daten.model_dump_json(), jetzt()))
return {"ok": True}
@router.post("/commands/{befehl_id}/ack")
def befehl_quittieren(befehl_id: int, q: Quittung, db: sqlite3.Connection = Depends(get_db)) -> dict:
with db:
cur = db.execute("UPDATE commands SET acked_at = ?, ok = ?, message = ? WHERE id = ? AND acked_at IS NULL",
(jetzt(), int(q.ok), q.message, befehl_id))
if not cur.rowcount and not db.execute("SELECT 1 FROM commands WHERE id = ?", (befehl_id,)).fetchone():
raise HTTPException(404, "Befehl nicht gefunden")
return {"ok": True}
def player_zustand(db: sqlite3.Connection) -> dict:
"""Letzter gemeldeter Zustand (für Gastseite und Admin)."""
r = db.execute("SELECT payload, bluetooth, updated_at, last_seen FROM pi_state WHERE id = 1").fetchone()
return {"state": json.loads(r["payload"] or "{}"), "bluetooth": json.loads(r["bluetooth"] or "{}"),
"updated_at": r["updated_at"], "last_seen": r["last_seen"]}