Files
playwish/server/app/player_api.py
T
99admandClaude Opus 5.5 b83da952ee Durchsicht: Robustheit bei Neuinstallationen
- auth.py: .env auch in schreibgeschütztem Ordner aktualisieren (Server-Login)
- Controller: ohne Spotify-Login warten statt abstürzen; Playlist darf fehlen;
  nicht abspielbare Wünsche überspringen statt hängen zu bleiben
- install-server.sh: alle SSH-Ports freigeben (kein Aussperren), curl und
  python3-systemd (fail2ban) installieren
- install-player.sh: Bluetooth per rfkill entsperren und Dienst starten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:55:00 +01:00

169 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]}