"""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 player_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"], "playlist_repeat_percent": e["playlist_repeat_percent"], "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"]}