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:
@@ -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"]}
|
||||
Reference in New Issue
Block a user