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
+1
View File
@@ -0,0 +1 @@
"""Audiofy-Web – Website, Admin-Bereich und Player-API (läuft auf dem Server)."""
+408
View File
@@ -0,0 +1,408 @@
"""Audiofy-Web – Admin-Bereich (/admin): der Besitzer steuert alles, auch vom Handy.
Zugang:
- Passwort als argon2-Hash in der .env (ADMIN_PASSWORD_HASH, gesetzt per verwaltung.py)
- Login-Versuche: max. 5 pro 15 min pro IP
- Sitzungs-Cookie HttpOnly, Secure, SameSite=Strict; in der DB nur als SHA-256-Hash
- jede ändernde Aktion braucht den CSRF-Token der Sitzung (Header X-CSRF-Token)
Befehle an den Player (play, pause, skip, volume, bt_scan, bt_connect) landen in der
Tabelle commands und werden vom Controller mit /api/player/config abgeholt.
"""
import hashlib
import hmac
import io
import re
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Literal
import segno
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel, Field
from .config import konfig
from .db import (BEFEHL_GUELTIG_S, befehl_anlegen, einstellung_setzen, einstellungen, get_db, jetzt,
neuer_zugangs_token)
from .gaeste import (PLAYER_OFFLINE_S, STATIC, Drossel, gleiche_herkunft, ip_hash, zeitpunkt)
from .player_api import player_zustand
from . import turnstile
from .spotify import Spotify, SpotifyFehler, spotify
router = APIRouter()
ADMIN_COOKIE = "audiofy_admin"
SITZUNG_TAGE = 14
login_drossel = Drossel(5, 15 * 60)
_hasher = PasswordHasher()
PLAYLIST_RE = re.compile(r"(?:playlist[/:])([A-Za-z0-9]{22})|^([A-Za-z0-9]{22})$")
# Einstellungen, die direkt per /admin/api/settings geändert werden dürfen (Typ, Min, Max)
AENDERBAR = {
"wish_every_n": (int, 1, 20),
"wish_limit_count": (int, 1, 50),
"wish_limit_minutes": (int, 1, 1440),
"repeat_block_minutes": (int, 0, 1440),
"moderation": (bool, None, None),
"wishes_paused": (bool, None, None),
"block_explicit": (bool, None, None),
}
# --- Sitzung ---------------------------------------------------------------
def _hash(wert: str) -> str:
return hashlib.sha256(wert.encode()).hexdigest()
def sitzung(request: Request, db: sqlite3.Connection) -> sqlite3.Row | None:
wert = request.cookies.get(ADMIN_COOKIE)
if not wert or len(wert) > 100:
return None
return db.execute("SELECT * FROM admin_sessions WHERE id = ? AND expires_at > ?", (_hash(wert), jetzt())).fetchone()
def admin_pflicht(request: Request, db: sqlite3.Connection = Depends(get_db)) -> sqlite3.Row:
s = sitzung(request, db)
if s is None:
raise HTTPException(401, "Bitte anmelden.")
if request.method not in ("GET", "HEAD"):
gleiche_herkunft(request)
if not hmac.compare_digest(request.headers.get("x-csrf-token", ""), s["csrf"]):
raise HTTPException(403, "Sicherheitsprüfung fehlgeschlagen – Seite neu laden.")
return s
api = APIRouter(prefix="/admin/api", dependencies=[Depends(admin_pflicht)])
# --- Seiten & Login --------------------------------------------------------
def _seite(datei: str) -> FileResponse:
return FileResponse(STATIC / datei, headers={"Cache-Control": "no-store", "X-Robots-Tag": "noindex"})
@router.get("/admin", include_in_schema=False)
def admin_seite(request: Request, db: sqlite3.Connection = Depends(get_db)):
if sitzung(request, db) is None:
return RedirectResponse("/admin/login", status_code=303)
return _seite("admin.html")
@router.get("/admin/login", include_in_schema=False)
def login_seite(request: Request, db: sqlite3.Connection = Depends(get_db)):
if sitzung(request, db) is not None:
return RedirectResponse("/admin", status_code=303)
return _seite("login.html")
class Login(BaseModel):
password: str = Field(min_length=1, max_length=200)
turnstile: str | None = Field(default=None, max_length=2048)
@router.post("/admin/login", include_in_schema=False)
def login(daten: Login, request: Request, response: Response, db: sqlite3.Connection = Depends(get_db)) -> dict:
gleiche_herkunft(request)
if not login_drossel.erlaubt(ip_hash(request)):
raise HTTPException(429, "Zu viele Anmeldeversuche – bitte 15 Minuten warten.")
gespeichert = konfig().admin_passwort_hash
if not gespeichert:
raise HTTPException(503, "Es ist noch kein Admin-Passwort gesetzt.")
turnstile.pruefen(daten.turnstile, request, "login", bei_ausfall_erlauben=False)
try:
_hasher.verify(gespeichert, daten.password)
except (VerificationError, InvalidHashError):
raise HTTPException(401, "Falsches Passwort.")
wert = secrets.token_urlsafe(32)
ablauf = jetzt(timedelta(days=SITZUNG_TAGE))
with db:
db.execute("DELETE FROM admin_sessions WHERE expires_at <= ?", (jetzt(),))
db.execute("INSERT INTO admin_sessions (id, csrf, created_at, expires_at) VALUES (?, ?, ?, ?)",
(_hash(wert), secrets.token_urlsafe(24), jetzt(), ablauf))
response.set_cookie(ADMIN_COOKIE, wert, max_age=SITZUNG_TAGE * 86400, httponly=True,
secure=konfig().cookie_secure, samesite="strict", path="/")
return {"ok": True}
@api.post("/logout")
def logout(request: Request, response: Response, db: sqlite3.Connection = Depends(get_db)) -> dict:
with db:
db.execute("DELETE FROM admin_sessions WHERE id = ?", (_hash(request.cookies.get(ADMIN_COOKIE, "")),))
response.delete_cookie(ADMIN_COOKIE, path="/")
return {"ok": True}
# --- Gesamtzustand -----------------------------------------------------------
def _wunsch(r: sqlite3.Row) -> dict:
return {k: r[k] for k in ("id", "track_uri", "title", "artist", "cover_url", "guest_name", "status",
"priority", "explicit", "created_at")} | {"has_guest": bool(r["guest_id"] or r["guest_ip"])}
@api.get("/state")
def zustand(s: sqlite3.Row = Depends(admin_pflicht), db: sqlite3.Connection = Depends(get_db)) -> dict:
e = einstellungen(db)
pi = player_zustand(db)
zuletzt = zeitpunkt(pi["last_seen"])
alter_s = (datetime.now(timezone.utc) - zuletzt).total_seconds() if zuletzt else None
gemeldet = zeitpunkt(pi["updated_at"])
grenze = jetzt(-timedelta(seconds=BEFEHL_GUELTIG_S))
befehle = [{"id": r["id"], "type": r["type"], "created_at": r["created_at"],
"status": ("ok" if r["ok"] else "fehler") if r["acked_at"] else
("wartet" if r["created_at"] >= grenze else "abgelaufen"),
"message": r["message"]}
for r in db.execute("SELECT * FROM commands ORDER BY id DESC LIMIT 8")]
return {
"csrf": s["csrf"],
"player": {
"online": alter_s is not None and alter_s < PLAYER_OFFLINE_S,
"last_seen_s": None if alter_s is None else int(alter_s),
"state": pi["state"],
"bluetooth": pi["bluetooth"],
"age_ms": int((datetime.now(timezone.utc) - gemeldet).total_seconds() * 1000) if gemeldet else None,
},
"settings": {k: v for k, v in e.items() if k != "access_token"},
"queue": [_wunsch(r) for r in db.execute("SELECT * FROM wishes WHERE status = 'queued' "
"ORDER BY priority DESC, position, id")],
"pending": [_wunsch(r) for r in db.execute("SELECT * FROM wishes WHERE status = 'pending' "
"ORDER BY position, id")],
"commands": befehle,
}
# --- Wiedergabe --------------------------------------------------------------
class Befehl(BaseModel):
type: Literal["play", "pause", "skip", "volume", "bt_scan", "bt_connect"]
percent: int | None = Field(default=None, ge=0, le=100)
mac: str | None = Field(default=None, pattern=r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$")
@api.post("/command")
def befehl(b: Befehl, db: sqlite3.Connection = Depends(get_db)) -> dict:
if b.type == "volume" and b.percent is None:
raise HTTPException(422, "Lautstärke fehlt.")
if b.type == "bt_connect" and not b.mac:
raise HTTPException(422, "Lautsprecher fehlt.")
payload = {"percent": b.percent} if b.type == "volume" else {"mac": b.mac} if b.type == "bt_connect" else {}
nr = befehl_anlegen(db, b.type, payload)
return {"ok": True, "id": nr}
class NotAus(BaseModel):
stop: bool
@api.post("/emergency")
def not_aus(n: NotAus, db: sqlite3.Connection = Depends(get_db)) -> dict:
"""Not-Aus: Wiedergabe stoppen und Controller anhalten, bis wieder freigegeben."""
einstellung_setzen(db, "emergency_stop", n.stop)
befehl_anlegen(db, "pause" if n.stop else "play")
return {"ok": True}
@api.get("/search")
def suche(q: str = Query(min_length=2, max_length=100), sp: Spotify = Depends(spotify)) -> dict:
if not sp.verfuegbar():
raise HTTPException(503, "Spotify-Suche ist nicht eingerichtet.")
try:
return {"results": sp.suche(q)}
except SpotifyFehler:
raise HTTPException(503, "Spotify ist gerade nicht erreichbar.")
class TitelWahl(BaseModel):
track_uri: str = Field(pattern=r"^spotify:track:[A-Za-z0-9]{22}$")
@api.post("/play-next")
def als_naechstes(t: TitelWahl, db: sqlite3.Connection = Depends(get_db), sp: Spotify = Depends(spotify)) -> dict:
"""Besitzer-Priorität: vor alle Wünsche, ohne Limits und Sperren."""
try:
titel = sp.titel(t.track_uri)
except SpotifyFehler:
raise HTTPException(503, "Titel konnte nicht geladen werden.")
with db:
pos = db.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM wishes").fetchone()[0]
db.execute("INSERT INTO wishes (track_uri, title, artist, album, cover_url, duration_ms, explicit, "
"guest_name, status, priority, position, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'Besitzer', 'queued', 1, ?, ?)",
(titel["track_uri"], titel["title"], titel["artist"], titel["album"], titel["cover_url"],
titel["duration_ms"], int(titel["explicit"]), pos, jetzt()))
return {"ok": True}
# --- Wünsche -----------------------------------------------------------------
@api.post("/wish/{wish_id}/{aktion}")
def wunsch_aktion(wish_id: int, aktion: Literal["approve", "reject", "delete", "up", "down", "block-guest"],
db: sqlite3.Connection = Depends(get_db)) -> dict:
w = db.execute("SELECT * FROM wishes WHERE id = ?", (wish_id,)).fetchone()
if w is None:
raise HTTPException(404, "Wunsch nicht gefunden.")
with db:
if aktion == "approve":
db.execute("UPDATE wishes SET status = 'queued' WHERE id = ? AND status = 'pending'", (wish_id,))
elif aktion == "reject":
db.execute("UPDATE wishes SET status = 'rejected' WHERE id = ? AND status = 'pending'", (wish_id,))
elif aktion == "delete":
db.execute("UPDATE wishes SET status = 'deleted' WHERE id = ? AND status IN ('pending', 'queued')",
(wish_id,))
elif aktion in ("up", "down"):
# mit dem Nachbarn gleicher Liste (Status + Priorität) die Position tauschen
vergleich, ordnung = ("<", "DESC") if aktion == "up" else (">", "ASC")
n = db.execute(f"SELECT id, position FROM wishes WHERE status = ? AND priority = ? "
f"AND (position {vergleich} ? OR (position = ? AND id {vergleich} ?)) "
f"ORDER BY position {ordnung}, id {ordnung} LIMIT 1",
(w["status"], w["priority"], w["position"], w["position"], wish_id)).fetchone()
if n:
if n["position"] == w["position"]: # gleiche Position → eindeutig machen
db.execute("UPDATE wishes SET position = position + 0.5 WHERE id = ?",
(wish_id if aktion == "down" else n["id"],))
else:
db.execute("UPDATE wishes SET position = ? WHERE id = ?", (n["position"], wish_id))
db.execute("UPDATE wishes SET position = ? WHERE id = ?", (w["position"], n["id"]))
elif aktion == "block-guest":
label = w["guest_name"] or f"Gast von „{w['title']}“"
for art, wert in (("guest", w["guest_id"]), ("ip", w["guest_ip"])):
if wert:
db.execute("INSERT OR IGNORE INTO blocklist (kind, value, label, created_at) VALUES (?, ?, ?, ?)",
(art, wert, label, jetzt()))
# offene Wünsche dieses Gasts gleich mit entfernen
db.execute("UPDATE wishes SET status = 'deleted' WHERE status IN ('pending', 'queued') "
"AND ((guest_id IS NOT NULL AND guest_id = ?) OR (guest_ip IS NOT NULL AND guest_ip = ?))",
(w["guest_id"], w["guest_ip"]))
return {"ok": True}
# --- Sperrliste ----------------------------------------------------------------
class Sperre(BaseModel):
kind: Literal["track", "artist"]
value: str = Field(pattern=r"^spotify:(track|artist):[A-Za-z0-9]{22}$")
label: str = Field(max_length=300)
@api.get("/blocklist")
def sperrliste(db: sqlite3.Connection = Depends(get_db)) -> dict:
return {"items": [{"id": r["id"], "kind": r["kind"], "label": r["label"], "created_at": r["created_at"]}
for r in db.execute("SELECT * FROM blocklist ORDER BY kind, id DESC")]}
@api.post("/blocklist")
def sperren(sp: Sperre, db: sqlite3.Connection = Depends(get_db)) -> dict:
if not sp.value.startswith(f"spotify:{sp.kind}:"):
raise HTTPException(422, "Art und Wert passen nicht zusammen.")
with db:
db.execute("INSERT OR IGNORE INTO blocklist (kind, value, label, created_at) VALUES (?, ?, ?, ?)",
(sp.kind, sp.value, sp.label, jetzt()))
return {"ok": True}
@api.post("/blocklist/{eintrag_id}/delete")
def entsperren(eintrag_id: int, db: sqlite3.Connection = Depends(get_db)) -> dict:
with db:
db.execute("DELETE FROM blocklist WHERE id = ?", (eintrag_id,))
return {"ok": True}
# --- Einstellungen -------------------------------------------------------------
@api.post("/settings")
def einstellungen_aendern(werte: dict, db: sqlite3.Connection = Depends(get_db)) -> dict:
for key, wert in werte.items():
if key not in AENDERBAR:
raise HTTPException(422, f"Unbekannte Einstellung: {key}")
typ, minimum, maximum = AENDERBAR[key]
if typ is bool and not isinstance(wert, bool):
raise HTTPException(422, f"{key}: ja/nein erwartet")
if typ is int and (isinstance(wert, bool) or not isinstance(wert, int) or not minimum <= wert <= maximum):
raise HTTPException(422, f"{key}: Zahl von {minimum} bis {maximum} erwartet")
for key, wert in werte.items():
einstellung_setzen(db, key, wert)
return {"ok": True}
class PlaylistLink(BaseModel):
link: str = Field(max_length=300)
@api.post("/playlist")
def playlist_wechseln(p: PlaylistLink, db: sqlite3.Connection = Depends(get_db)) -> dict:
treffer = PLAYLIST_RE.search(p.link.strip())
if not treffer:
raise HTTPException(422, "Das sieht nicht nach einem Spotify-Playlist-Link aus.")
playlist_id = treffer.group(1) or treffer.group(2)
einstellung_setzen(db, "playlist_id", playlist_id)
einstellung_setzen(db, "reshuffle_token", einstellungen(db)["reshuffle_token"] + 1)
return {"ok": True, "playlist_id": playlist_id}
@api.post("/reshuffle")
def neu_mischen(db: sqlite3.Connection = Depends(get_db)) -> dict:
einstellung_setzen(db, "reshuffle_token", einstellungen(db)["reshuffle_token"] + 1)
return {"ok": True}
# --- Gastzugang (Einladungslink / QR-Code) ---------------------------------------
def _einladungslink(request: Request, token: str) -> str:
basis = konfig().public_url or str(request.base_url).rstrip("/")
return f"{basis}/z/{token}"
@api.get("/access")
def zugang(request: Request, db: sqlite3.Connection = Depends(get_db)) -> dict:
e = einstellungen(db)
return {"enabled": e["access_enabled"], "link": _einladungslink(request, e["access_token"])}
class ZugangSchalter(BaseModel):
enabled: bool
@api.post("/access")
def zugang_schalten(z: ZugangSchalter, db: sqlite3.Connection = Depends(get_db)) -> dict:
einstellung_setzen(db, "access_enabled", z.enabled)
return {"ok": True}
@api.post("/access/renew")
def zugang_erneuern(db: sqlite3.Connection = Depends(get_db)) -> dict:
"""Neuer Link: der alte Link und alle damit erteilten Zugänge werden ungültig."""
einstellung_setzen(db, "access_token", neuer_zugangs_token())
return {"ok": True}
@api.get("/access/qr")
def zugang_qr(request: Request, format: Literal["svg", "png"] = "svg",
db: sqlite3.Connection = Depends(get_db)) -> Response:
qr = segno.make(_einladungslink(request, einstellungen(db)["access_token"]), error="m")
puffer = io.BytesIO()
if format == "png":
qr.save(puffer, kind="png", scale=16, border=3)
return Response(puffer.getvalue(), media_type="image/png", headers={
"Content-Disposition": 'attachment; filename="audiofy-einladung.png"', "Cache-Control": "no-store"})
qr.save(puffer, kind="svg", scale=8, border=2, xmldecl=False, svgns=True)
return Response(puffer.getvalue(), media_type="image/svg+xml", headers={"Cache-Control": "no-store"})
# --- Verlauf -------------------------------------------------------------------
@api.get("/history")
def verlauf(limit: int = Query(default=100, ge=1, le=500), db: sqlite3.Connection = Depends(get_db)) -> dict:
zeilen = db.execute("SELECT h.*, w.guest_name FROM history h LEFT JOIN wishes w ON w.id = h.wish_id "
"ORDER BY h.id DESC LIMIT ?", (limit,))
return {"items": [{"title": r["title"], "artist": r["artist"], "cover_url": r["cover_url"],
"source": r["source"], "guest_name": r["guest_name"], "started_at": r["started_at"]}
for r in zeilen]}
router.include_router(api)
+70
View File
@@ -0,0 +1,70 @@
"""Audiofy-Web – Konfiguration aus der .env.
Pfad über die Umgebungsvariable AUDIOFY_ENV, Standard /opt/audiofy/.env.
Geheimnisse stehen nur dort (Rechte 600), nie im Code oder in Logs.
"""
import os
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
def env_lesen(pfad: Path) -> dict:
"""Einfacher KEY=VALUE-Parser (Kommentare und Anführungszeichen erlaubt)."""
werte = {}
for zeile in pfad.read_text().splitlines():
zeile = zeile.strip()
if not zeile or zeile.startswith("#") or "=" not in zeile:
continue
key, _, wert = zeile.partition("=")
werte[key.strip()] = wert.strip().strip('"').strip("'")
return werte
@dataclass(frozen=True)
class Konfig:
env_pfad: Path
db_pfad: Path
player_api_token: str
spotify_client_id: str
spotify_client_secret: str
spotify_refresh_token: str
admin_passwort_hash: str
session_secret: str
playlist_id: str # nur Startwert – danach gilt die Einstellung in der Datenbank
cookie_secure: bool = True # nur für lokale Tests über http auf 0 setzen
public_url: str = "" # z. B. https://musik.example.org (für Einladungslink/QR-Code)
turnstile_site_key: str = "" # Cloudflare Turnstile (öffentlich)
turnstile_secret: str = "" # Cloudflare Turnstile (geheim)
def env_wert_setzen(pfad: Path, key: str, wert: str) -> None:
"""Setzt KEY=wert in der .env – direkt in der Datei (der Ordner ist für den Dienst nicht beschreibbar)."""
zeilen = pfad.read_text().splitlines() if pfad.is_file() else []
neu = [z for z in zeilen if not z.strip().startswith(f"{key}=")]
neu.append(f"{key}={wert}")
fd = os.open(pfad, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write("\n".join(neu) + "\n")
@lru_cache
def konfig() -> Konfig:
pfad = Path(os.environ.get("AUDIOFY_ENV", "/opt/audiofy/.env"))
werte = env_lesen(pfad) if pfad.is_file() else {}
return Konfig(
env_pfad=pfad,
db_pfad=Path(werte.get("DB_PATH") or "/opt/audiofy/data/audiofy.db"),
player_api_token=werte.get("PLAYER_API_TOKEN") or werte.get("PI_API_TOKEN", ""), # alter Name
spotify_client_id=werte.get("SPOTIFY_CLIENT_ID", ""),
spotify_client_secret=werte.get("SPOTIFY_CLIENT_SECRET", ""),
spotify_refresh_token=werte.get("SPOTIFY_REFRESH_TOKEN", ""),
admin_passwort_hash=werte.get("ADMIN_PASSWORD_HASH", ""),
session_secret=werte.get("SESSION_SECRET", ""),
playlist_id=werte.get("SPOTIFY_PLAYLIST_ID", ""),
cookie_secure=werte.get("COOKIE_SECURE", "1") != "0",
public_url=werte.get("PUBLIC_URL", "").rstrip("/"),
turnstile_site_key=werte.get("TURNSTILE_SITE_KEY", ""),
turnstile_secret=werte.get("TURNSTILE_SECRET_KEY", ""),
)
+201
View File
@@ -0,0 +1,201 @@
"""Audiofy-Web – SQLite-Datenbank (audiofy.db): Schema, Einstellungen, Hilfsfunktionen.
Zeitstempel sind UTC im ISO-Format (lexikografisch vergleichbar).
"""
import json
import secrets
import sqlite3
from collections.abc import Iterator
from datetime import datetime, timedelta, timezone
from pathlib import Path
from .config import konfig
SCHEMA = """
-- Einstellungen des Besitzers (Wert als JSON)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Wünsche der Gäste und "Als Nächstes" des Besitzers (priority = 1)
-- status: pending = zur Freigabe (Moderation), queued = Warteschlange,
-- played = gespielt, rejected = abgelehnt, deleted = vom Besitzer gelöscht
CREATE TABLE IF NOT EXISTS wishes (
id INTEGER PRIMARY KEY,
track_uri TEXT NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
cover_url TEXT,
duration_ms INTEGER,
explicit INTEGER NOT NULL DEFAULT 0,
guest_id TEXT, -- zufällige Cookie-ID des Geräts
guest_ip TEXT, -- gehashte IP (nie im Klartext)
guest_name TEXT, -- optionaler Spitzname
status TEXT NOT NULL CHECK (status IN ('pending', 'queued', 'played', 'rejected', 'deleted')),
priority INTEGER NOT NULL DEFAULT 0,
position REAL NOT NULL, -- Reihenfolge innerhalb der Warteschlange
created_at TEXT NOT NULL,
played_at TEXT
);
CREATE INDEX IF NOT EXISTS wishes_status ON wishes (status, priority DESC, position);
CREATE INDEX IF NOT EXISTS wishes_guest ON wishes (guest_id, created_at);
-- Verlauf: was wann lief (source: playlist, wish, owner, fremd)
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY,
track_uri TEXT NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
cover_url TEXT,
source TEXT,
wish_id INTEGER REFERENCES wishes (id),
started_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS history_started ON history (started_at);
CREATE INDEX IF NOT EXISTS history_track ON history (track_uri, started_at);
-- Befehle des Besitzers an den Player (play, pause, skip, volume, …)
CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
acked_at TEXT,
ok INTEGER,
message TEXT
);
-- Sperrliste (kind: track, artist, guest, ip)
CREATE TABLE IF NOT EXISTS blocklist (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('track', 'artist', 'guest', 'ip')),
value TEXT NOT NULL,
label TEXT,
created_at TEXT NOT NULL,
UNIQUE (kind, value)
);
-- Admin-Sitzungen (id = SHA-256 des Cookie-Werts, nie der Klartext)
CREATE TABLE IF NOT EXISTS admin_sessions (
id TEXT PRIMARY KEY,
csrf TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
-- Letzter gemeldeter Zustand des Players (genau eine Zeile)
CREATE TABLE IF NOT EXISTS pi_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
track_uri TEXT,
payload TEXT NOT NULL DEFAULT '{}',
updated_at TEXT, -- letzte "läuft gerade"-Meldung
last_seen TEXT -- letzter Kontakt überhaupt
);
"""
STANDARD_EINSTELLUNGEN = {
"wish_every_n": 3, # jeder N-te Titel ist ein Wunsch
"playlist_id": "", # Grund-Playlist
"reshuffle_token": 0, # Erhöhen = Playlist neu mischen
"emergency_stop": False, # Not-Aus
"moderation": False, # Wünsche erst nach Freigabe
"wishes_paused": False, # Wunschannahme pausiert
"wish_limit_count": 2, # max. Wünsche pro Gast …
"wish_limit_minutes": 30, # … pro Zeitraum
"repeat_block_minutes": 60, # Sperrzeit für Wiederholungen
"block_explicit": False, # explizite Titel nicht erlauben
"access_enabled": False, # Gastzugang per Einladungslink/QR-Code (anfangs aus)
"access_token": "", # geheimer Teil des Einladungslinks /z/<token>
}
BEFEHL_GUELTIG_S = 60 # nicht abgeholte Befehle verfallen danach (kein verspätetes Überspringen)
def jetzt(delta: timedelta = timedelta()) -> str:
return (datetime.now(timezone.utc) + delta).isoformat(timespec="seconds")
def verbinden(pfad: Path | None = None) -> sqlite3.Connection:
conn = sqlite3.connect(pfad or konfig().db_pfad, timeout=10, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def initialisieren(pfad: Path, playlist_id: str = "") -> None:
pfad.parent.mkdir(parents=True, exist_ok=True)
conn = verbinden(pfad)
try:
conn.execute("PRAGMA journal_mode = WAL")
with conn:
conn.executescript(SCHEMA)
standard = dict(STANDARD_EINSTELLUNGEN, playlist_id=playlist_id)
conn.executemany("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
[(k, json.dumps(v)) for k, v in standard.items()])
conn.execute("INSERT OR IGNORE INTO pi_state (id) VALUES (1)")
# Nachrüsten für bestehende Datenbanken: Bluetooth-Zustand des Players
spalten = {r[1] for r in conn.execute("PRAGMA table_info(pi_state)")}
if "bluetooth" not in spalten:
conn.execute("ALTER TABLE pi_state ADD COLUMN bluetooth TEXT NOT NULL DEFAULT '{}'")
if not json.loads(conn.execute("SELECT value FROM settings WHERE key = 'access_token'").fetchone()[0]):
conn.execute("UPDATE settings SET value = ? WHERE key = 'access_token'",
(json.dumps(neuer_zugangs_token()),))
finally:
conn.close()
def neuer_zugangs_token() -> str:
"""Kurz genug für einen gut scanbaren QR-Code, lang genug gegen Raten (72 Bit)."""
return secrets.token_urlsafe(9)
def get_db() -> Iterator[sqlite3.Connection]:
"""FastAPI-Dependency: eine Verbindung pro Anfrage."""
conn = verbinden()
try:
yield conn
finally:
conn.close()
# --- Einstellungen ---------------------------------------------------------
def einstellungen(conn: sqlite3.Connection) -> dict:
werte = dict(STANDARD_EINSTELLUNGEN)
werte.update({r["key"]: json.loads(r["value"]) for r in conn.execute("SELECT key, value FROM settings")})
return werte
def einstellung_setzen(conn: sqlite3.Connection, key: str, wert) -> None:
if key not in STANDARD_EINSTELLUNGEN:
raise KeyError(key)
with conn:
conn.execute("INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value", (key, json.dumps(wert)))
# --- Befehle an den Player -----------------------------------------------------
def befehl_anlegen(conn: sqlite3.Connection, typ: str, payload: dict | None = None) -> int:
with conn:
cur = conn.execute("INSERT INTO commands (type, payload, created_at) VALUES (?, ?, ?)",
(typ, json.dumps(payload or {}), jetzt()))
return cur.lastrowid
def offene_befehle(conn: sqlite3.Connection) -> list[dict]:
grenze = jetzt(-timedelta(seconds=BEFEHL_GUELTIG_S))
zeilen = conn.execute("SELECT id, type, payload, created_at FROM commands "
"WHERE acked_at IS NULL AND created_at >= ? ORDER BY id", (grenze,))
return [{"id": r["id"], "type": r["type"], "payload": json.loads(r["payload"]), "created_at": r["created_at"]}
for r in zeilen]
# --- Player-Zustand ------------------------------------------------------------
def lebenszeichen(conn: sqlite3.Connection) -> None:
with conn:
conn.execute("UPDATE pi_state SET last_seen = ? WHERE id = 1", (jetzt(),))
+335
View File
@@ -0,0 +1,335 @@
"""Audiofy-Web – Gastseite: Suche, Wünschen, "Läuft gerade" und Warteschlange.
Zugang nur mit Einladung: Der Link /z/<token> (auch als QR-Code im Admin-Bereich)
setzt ein Zugangs-Cookie. Ohne gültigen Zugang gibt es nur eine Hinweisseite, die
API antwortet mit 401. Zugang aus oder neuer Link → alle bisherigen Zugänge ungültig.
Gäste melden sich nie bei Spotify an. Ein Gerät wird über ein zufälliges Cookie
erkannt, zusätzlich über die gehashte IP (nie im Klartext gespeichert).
Schutzregeln beim Wünschen:
- Wunschannahme pausiert → abgelehnt
- Gast/Gerät gesperrt (Cookie-ID oder IP) → abgelehnt
- Limit: max. wish_limit_count Wünsche pro wish_limit_minutes (pro Cookie ODER IP)
- Titel/Interpret auf der Sperrliste, explizit (falls ausgeschaltet) → abgelehnt
- schon in der Warteschlange / zur Freigabe → abgelehnt (keine Duplikate)
- lief in den letzten repeat_block_minutes oder läuft gerade → abgelehnt
"""
import hashlib
import hmac
import re
import secrets
import sqlite3
import threading
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel, Field
from .config import konfig
from .db import einstellungen, get_db, jetzt
from .player_api import player_zustand
from . import turnstile
from .spotify import Spotify, SpotifyFehler, spotify
router = APIRouter()
STATIC = Path(__file__).parent / "static"
GAST_COOKIE = "audiofy_gast"
ZUGANG_COOKIE = "audiofy_zugang"
ZUGANG_TAGE = 30
GAST_RE = re.compile(r"^[A-Za-z0-9_-]{22}$")
PLAYER_OFFLINE_S = 30 # ohne Lebenszeichen gilt der Player als offline
MEINE_WUENSCHE_H = 6 # so lange sieht ein Gast seine eigenen Wünsche
_wunsch_lock = threading.Lock() # Prüfen + Eintragen atomar (keine Duplikate bei Doppelklick)
# --- Hilfen ----------------------------------------------------------------
class Drossel:
"""Einfache Ratenbegrenzung im Speicher (gleitendes Fenster), pro Schlüssel."""
def __init__(self, anzahl: int, sekunden: int):
self.anzahl, self.sekunden = anzahl, sekunden
self._zeiten: dict[str, deque] = defaultdict(deque)
self._lock = threading.Lock()
def erlaubt(self, schluessel: str) -> bool:
jetzt_s = time.monotonic()
with self._lock:
zeiten = self._zeiten[schluessel]
while zeiten and jetzt_s - zeiten[0] > self.sekunden:
zeiten.popleft()
if len(zeiten) >= self.anzahl:
return False
zeiten.append(jetzt_s)
if len(self._zeiten) > 10_000: # Speicher begrenzen
self._zeiten = defaultdict(deque, {schluessel: zeiten})
return True
suche_drossel = Drossel(30, 60)
wunsch_drossel = Drossel(10, 60)
einladung_drossel = Drossel(10, 60) # gegen Raten von Einladungslinks
def gast_id(request: Request) -> str | None:
wert = request.cookies.get(GAST_COOKIE, "")
return wert if GAST_RE.match(wert) else None
def gast_cookie_setzen(response: Response, gid: str) -> None:
response.set_cookie(GAST_COOKIE, gid, max_age=365 * 86400, httponly=True,
secure=konfig().cookie_secure, samesite="lax")
def _geheimnis() -> bytes:
return (konfig().session_secret or "audiofy").encode()
def zugangs_wert(token: str) -> str:
"""Cookie-Wert für den Zugang – hängt am aktuellen Token, ein neuer Link macht alte Cookies ungültig."""
return hmac.new(_geheimnis(), b"zugang:" + token.encode(), hashlib.sha256).hexdigest()[:32]
def hat_zugang(request: Request, e: dict) -> bool:
if not (e["access_enabled"] and e["access_token"]):
return False
return hmac.compare_digest(request.cookies.get(ZUGANG_COOKIE, ""), zugangs_wert(e["access_token"]))
def zugang_pflicht(request: Request, db: sqlite3.Connection = Depends(get_db)) -> None:
"""Dependency für die Gast-API."""
if not hat_zugang(request, einstellungen(db)):
raise HTTPException(401, "Kein Zugang – bitte den QR-Code vor Ort scannen.")
def ip_hash(request: Request) -> str:
"""IP des Gasts (von Caddy über X-Forwarded-For, Uvicorn --proxy-headers) als HMAC-Hash."""
ip = request.client.host if request.client else "unbekannt"
return hmac.new(_geheimnis(), ip.encode(), hashlib.sha256).hexdigest()[:24]
def gleiche_herkunft(request: Request) -> None:
"""CSRF-Schutz für Gast-POSTs: Origin (falls gesendet) muss zur eigenen Seite passen."""
origin = request.headers.get("origin")
if origin and urlsplit(origin).netloc != request.headers.get("host"):
raise HTTPException(403, "Ungültige Herkunft")
def zeitpunkt(iso: str | None) -> datetime | None:
return datetime.fromisoformat(iso) if iso else None
@dataclass
class Kontext:
"""Vorberechnete Mengen für die Sperrprüfung (eine DB-Runde für alle Suchtreffer)."""
aktiv: set[str] # in Warteschlange oder zur Freigabe
kuerzlich: set[str] # lief in der Sperrzeit oder läuft gerade
titel_gesperrt: set[str]
interpreten_gesperrt: set[str]
explizit_aus: bool
def kontext(db: sqlite3.Connection, e: dict) -> Kontext:
grenze = jetzt(-timedelta(minutes=e["repeat_block_minutes"]))
kuerzlich = {r[0] for r in db.execute("SELECT track_uri FROM history WHERE started_at >= ?", (grenze,))}
aktuell = db.execute("SELECT track_uri FROM pi_state WHERE id = 1").fetchone()
if aktuell and aktuell[0]:
kuerzlich.add(aktuell[0])
sperre = db.execute("SELECT kind, value FROM blocklist WHERE kind IN ('track', 'artist')").fetchall()
return Kontext(
aktiv={r[0] for r in db.execute("SELECT track_uri FROM wishes WHERE status IN ('pending', 'queued')")},
kuerzlich=kuerzlich,
titel_gesperrt={r["value"] for r in sperre if r["kind"] == "track"},
interpreten_gesperrt={r["value"] for r in sperre if r["kind"] == "artist"},
explizit_aus=bool(e["block_explicit"]),
)
def sperrgrund(titel: dict, ktx: Kontext) -> str | None:
if titel["track_uri"] in ktx.titel_gesperrt or ktx.interpreten_gesperrt.intersection(titel.get("artist_uris", [])):
return "Dieser Titel kann leider nicht gewünscht werden."
if ktx.explizit_aus and titel.get("explicit"):
return "Titel mit expliziten Inhalten sind ausgeschaltet."
if titel["track_uri"] in ktx.aktiv:
return "Steht schon auf der Wunschliste."
if titel["track_uri"] in ktx.kuerzlich:
return "Lief gerade erst – bitte später nochmal."
return None
def gast_gesperrt(db: sqlite3.Connection, gid: str | None, iph: str) -> bool:
return db.execute("SELECT 1 FROM blocklist WHERE (kind = 'guest' AND value = ?) OR (kind = 'ip' AND value = ?)",
(gid or "", iph)).fetchone() is not None
def limit_status(db: sqlite3.Connection, e: dict, gid: str | None, iph: str) -> tuple[int, int | None]:
"""(verbleibende Wünsche, Minuten bis zum nächsten möglichen Wunsch oder None)."""
grenze = jetzt(-timedelta(minutes=e["wish_limit_minutes"]))
zeilen = db.execute("SELECT created_at FROM wishes WHERE (guest_id = ? OR guest_ip = ?) AND created_at >= ? "
"ORDER BY created_at", (gid or "-", iph, grenze)).fetchall()
rest = max(0, e["wish_limit_count"] - len(zeilen))
if rest:
return rest, None
frei_ab = zeitpunkt(zeilen[0]["created_at"]) + timedelta(minutes=e["wish_limit_minutes"])
return 0, max(1, round((frei_ab - datetime.now(timezone.utc)).total_seconds() / 60))
# --- Seiten ----------------------------------------------------------------
def kein_zugang_seite(status_code: int = 403) -> FileResponse:
return FileResponse(STATIC / "zugang.html", status_code=status_code, headers={"Cache-Control": "no-store"})
@router.get("/", include_in_schema=False)
def startseite(request: Request, db: sqlite3.Connection = Depends(get_db)) -> FileResponse:
if not hat_zugang(request, einstellungen(db)):
return kein_zugang_seite()
antwort = FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-cache"})
if not gast_id(request):
gast_cookie_setzen(antwort, secrets.token_urlsafe(16))
return antwort
@router.get("/z/{token}", include_in_schema=False)
def einladung(token: str, request: Request, db: sqlite3.Connection = Depends(get_db)):
"""Einladungslink bzw. QR-Code: gültig → Zugangs-Cookie setzen und zur Wunschseite."""
if not einladung_drossel.erlaubt(ip_hash(request)):
return kein_zugang_seite(429)
e = einstellungen(db)
if not (e["access_enabled"] and e["access_token"] and len(token) <= 64
and hmac.compare_digest(token.encode(), e["access_token"].encode())):
return kein_zugang_seite()
antwort = RedirectResponse("/", status_code=303)
antwort.set_cookie(ZUGANG_COOKIE, zugangs_wert(e["access_token"]), max_age=ZUGANG_TAGE * 86400,
httponly=True, secure=konfig().cookie_secure, samesite="lax")
if not gast_id(request):
gast_cookie_setzen(antwort, secrets.token_urlsafe(16))
return antwort
# --- API -------------------------------------------------------------------
@router.get("/api/search", dependencies=[Depends(zugang_pflicht)])
def suche(request: Request, q: str = Query(min_length=2, max_length=100),
db: sqlite3.Connection = Depends(get_db), sp: Spotify = Depends(spotify)) -> dict:
if not suche_drossel.erlaubt(ip_hash(request)):
raise HTTPException(429, "Zu viele Suchanfragen – bitte kurz warten.")
if not sp.verfuegbar():
raise HTTPException(503, "Die Suche ist gerade nicht verfügbar.")
try:
treffer = sp.suche(q)
except SpotifyFehler:
raise HTTPException(503, "Die Suche ist gerade überlastet – bitte gleich nochmal versuchen.")
ktx = kontext(db, einstellungen(db))
return {"results": [{k: v for k, v in t.items() if k not in ("artist_uris", "artists")}
| {"blocked": sperrgrund(t, ktx)}
for t in treffer]}
@router.get("/api/turnstile")
def turnstile_konfig() -> dict:
"""Öffentlicher Site-Key für das Turnstile-Widget (null = Schutz aus)."""
return {"site_key": konfig().turnstile_site_key if turnstile.aktiv() else None}
class WunschAnfrage(BaseModel):
track_uri: str = Field(pattern=r"^spotify:track:[A-Za-z0-9]{22}$")
name: str | None = Field(default=None, max_length=30)
turnstile: str | None = Field(default=None, max_length=2048)
@router.post("/api/wish", dependencies=[Depends(zugang_pflicht)])
def wuenschen(anfrage: WunschAnfrage, request: Request, response: Response,
db: sqlite3.Connection = Depends(get_db), sp: Spotify = Depends(spotify)) -> dict:
gleiche_herkunft(request)
gid = gast_id(request)
if not gid:
gid = secrets.token_urlsafe(16)
gast_cookie_setzen(response, gid)
iph = ip_hash(request)
if not wunsch_drossel.erlaubt(iph):
raise HTTPException(429, "Zu viele Versuche – bitte kurz warten.")
e = einstellungen(db)
if e["wishes_paused"]:
raise HTTPException(403, "Die Wunschannahme ist gerade pausiert.")
if gast_gesperrt(db, gid, iph):
raise HTTPException(403, "Von diesem Gerät sind gerade keine Wünsche möglich.")
rest, minuten = limit_status(db, e, gid, iph)
if not rest:
raise HTTPException(429, f"Du hast dein Limit von {e['wish_limit_count']} Wünschen erreicht – "
f"nächster Wunsch in ca. {minuten} Min.")
turnstile.pruefen(anfrage.turnstile, request, "wish", bei_ausfall_erlauben=True)
try:
titel = sp.titel(anfrage.track_uri) # Titelinfos immer selbst holen, nie dem Browser glauben
except SpotifyFehler as fehler:
if fehler.status in (400, 404):
raise HTTPException(404, "Titel nicht gefunden.")
raise HTTPException(503, "Spotify ist gerade nicht erreichbar – bitte gleich nochmal versuchen.")
name = " ".join((anfrage.name or "").split()) or None
status = "pending" if e["moderation"] else "queued"
with _wunsch_lock:
grund = sperrgrund(titel, kontext(db, e))
if grund:
raise HTTPException(409, grund)
with db:
pos = db.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM wishes").fetchone()[0]
db.execute("INSERT INTO wishes (track_uri, title, artist, album, cover_url, duration_ms, explicit, "
"guest_id, guest_ip, guest_name, status, position, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(titel["track_uri"], titel["title"], titel["artist"], titel["album"], titel["cover_url"],
titel["duration_ms"], int(titel["explicit"]), gid, iph, name, status, pos, jetzt()))
return {"ok": True, "status": status,
"message": "Dein Wunsch wartet auf Freigabe." if status == "pending" else "Dein Wunsch ist auf der Liste!"}
@router.get("/api/status", dependencies=[Depends(zugang_pflicht)])
def status(request: Request, db: sqlite3.Connection = Depends(get_db)) -> dict:
gid = gast_id(request)
e = einstellungen(db)
pi = player_zustand(db)
zustand = pi["state"]
zuletzt = zeitpunkt(pi["last_seen"])
online = bool(zuletzt and (datetime.now(timezone.utc) - zuletzt).total_seconds() < PLAYER_OFFLINE_S)
jetzt_laeuft = None
if online and zustand.get("track"):
alter_ms = int((datetime.now(timezone.utc) - zeitpunkt(pi["updated_at"])).total_seconds() * 1000)
jetzt_laeuft = {**{k: zustand["track"].get(k) for k in ("title", "artist", "cover_url", "duration_ms")},
"progress_ms": zustand.get("progress_ms", 0), "is_playing": zustand.get("is_playing", False),
"age_ms": max(0, alter_ms), "is_wish": zustand.get("source") == "wish"}
warteschlange = [{"title": r["title"], "artist": r["artist"], "cover_url": r["cover_url"],
"name": r["guest_name"], "mine": bool(gid) and r["guest_id"] == gid}
for r in db.execute("SELECT * FROM wishes WHERE status = 'queued' AND priority = 0 "
"ORDER BY position, id LIMIT 30")]
meine = []
if gid:
grenze = jetzt(-timedelta(hours=MEINE_WUENSCHE_H))
meine = [{"title": r["title"], "artist": r["artist"], "status": r["status"]}
for r in db.execute("SELECT title, artist, status FROM wishes WHERE guest_id = ? AND created_at >= ? "
"ORDER BY created_at DESC LIMIT 10", (gid, grenze))]
rest, minuten = limit_status(db, e, gid, ip_hash(request))
return {
"now_playing": jetzt_laeuft,
"online": online,
"queue": warteschlange,
"pending_count": db.execute("SELECT COUNT(*) FROM wishes WHERE status = 'pending'").fetchone()[0],
"mine": meine,
"wishes_paused": e["wishes_paused"],
"moderation": e["moderation"],
"wish_every_n": e["wish_every_n"],
"limit": {"count": e["wish_limit_count"], "minutes": e["wish_limit_minutes"],
"remaining": rest, "next_in_minutes": minuten},
}
+50
View File
@@ -0,0 +1,50 @@
"""Audiofy-Web – FastAPI-Anwendung.
Start (Server): uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 1 --no-access-log
Caddy terminiert HTTPS und leitet an 127.0.0.1:8000 weiter.
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from . import admin, db, gaeste, player_api
from .config import konfig
# Streng: nur eigene Skripte/Styles, Cover-Bilder vom Spotify-CDN, Turnstile von Cloudflare
CSP = ("default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self'; "
"img-src 'self' https://i.scdn.co data:; frame-src https://challenges.cloudflare.com; "
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
@asynccontextmanager
async def lifespan(app: FastAPI):
k = konfig()
db.initialisieren(k.db_pfad, k.playlist_id)
yield
# Keine automatische API-Doku öffentlich ausliefern
app = FastAPI(title="Audiofy – Musikwünsche", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
@app.middleware("http")
async def sicherheits_header(request: Request, call_next):
antwort = await call_next(request)
antwort.headers.setdefault("Content-Security-Policy", CSP)
if request.url.path.startswith("/api/"):
antwort.headers.setdefault("Cache-Control", "no-store")
return antwort
app.include_router(player_api.router, prefix="/api/player")
app.include_router(player_api.router, prefix="/api/pi", include_in_schema=False) # alter Pfad, für ältere Player
app.include_router(gaeste.router)
app.include_router(admin.router)
app.mount("/static", StaticFiles(directory=gaeste.STATIC), name="static")
@app.get("/healthz")
def healthz() -> dict:
return {"ok": True}
+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"]}
+116
View File
@@ -0,0 +1,116 @@
"""Audiofy-Web – Spotify-Zugriff für Suche und Titelinfos.
Eigener Refresh-Token des Servers (auth.py --profil server, keine Scopes nötig).
Im Development Mode liefert die Suche höchstens 10 Treffer.
"""
import base64
import logging
import threading
import time
from functools import lru_cache
import httpx
from .config import Konfig, env_wert_setzen, konfig
log = logging.getLogger("audiofy.spotify")
API = "https://api.spotify.com/v1"
TOKEN_URL = "https://accounts.spotify.com/api/token"
CACHE_S = 300 # Suchergebnisse so lange zwischenspeichern
CACHE_MAX = 500
class SpotifyFehler(Exception):
def __init__(self, status: int, retry_after: int | None = None):
super().__init__(f"Spotify-Fehler {status}")
self.status = status
self.retry_after = retry_after
def titel_aus(t: dict) -> dict:
"""Spotify-Track-Objekt → schlankes Dict für Website und Datenbank."""
bilder = sorted((t.get("album") or {}).get("images") or [], key=lambda b: b.get("width") or 0)
# kleinstes Bild ab 200 px (gut für Listen), sonst das größte
cover = next((b["url"] for b in bilder if (b.get("width") or 0) >= 200), bilder[-1]["url"] if bilder else None)
return {
"track_uri": t["uri"],
"title": t.get("name") or "",
"artist": ", ".join(a.get("name", "") for a in t.get("artists") or []),
"artist_uris": [a["uri"] for a in t.get("artists") or [] if a.get("uri")],
"artists": [{"name": a.get("name", ""), "uri": a["uri"]} for a in t.get("artists") or [] if a.get("uri")],
"album": (t.get("album") or {}).get("name"),
"cover_url": cover,
"duration_ms": t.get("duration_ms"),
"explicit": bool(t.get("explicit")),
}
class Spotify:
def __init__(self, k: Konfig):
self._k = k
self._refresh_token = k.spotify_refresh_token
self._token: str | None = None
self._ablauf = 0.0
self._lock = threading.Lock()
self._http = httpx.Client(timeout=8)
self._cache: dict[str, tuple[float, list[dict]]] = {}
def verfuegbar(self) -> bool:
return bool(self._k.spotify_client_id and self._k.spotify_client_secret and self._refresh_token)
def _access_token(self) -> str:
with self._lock:
if self._token and time.monotonic() < self._ablauf - 60:
return self._token
basic = base64.b64encode(f"{self._k.spotify_client_id}:{self._k.spotify_client_secret}".encode()).decode()
r = self._http.post(TOKEN_URL, headers={"Authorization": f"Basic {basic}"},
data={"grant_type": "refresh_token", "refresh_token": self._refresh_token})
if r.status_code != 200:
log.error("Token-Erneuerung fehlgeschlagen (%s)", r.status_code)
raise SpotifyFehler(r.status_code)
daten = r.json()
self._token = daten["access_token"]
self._ablauf = time.monotonic() + daten.get("expires_in", 3600)
neu = daten.get("refresh_token")
if neu and neu != self._refresh_token:
self._refresh_token = neu
env_wert_setzen(self._k.env_pfad, "SPOTIFY_REFRESH_TOKEN", neu)
log.info("Neuer Refresh-Token von Spotify gespeichert")
return self._token
def _get(self, pfad: str, params: dict) -> dict:
for versuch in range(2):
r = self._http.get(API + pfad, params=params, headers={"Authorization": f"Bearer {self._access_token()}"})
if r.status_code == 401 and versuch == 0:
self._token = None
continue
if r.status_code == 429:
raise SpotifyFehler(429, int(r.headers.get("Retry-After", "5")))
if r.status_code >= 400:
raise SpotifyFehler(r.status_code)
return r.json()
raise SpotifyFehler(401)
def suche(self, q: str) -> list[dict]:
schluessel = " ".join(q.casefold().split())
treffer = self._cache.get(schluessel)
if treffer and time.monotonic() - treffer[0] < CACHE_S:
return treffer[1]
daten = self._get("/search", {"q": q, "type": "track", "limit": 10, "market": "from_token"})
ergebnis = [titel_aus(t) for t in (daten.get("tracks") or {}).get("items") or []
if t and t.get("uri", "").startswith("spotify:track:") and t.get("is_playable", True)]
if len(self._cache) >= CACHE_MAX:
self._cache.clear()
self._cache[schluessel] = (time.monotonic(), ergebnis)
return ergebnis
def titel(self, uri: str) -> dict:
return titel_aus(self._get(f"/tracks/{uri.rsplit(':', 1)[-1]}", {"market": "from_token"}))
@lru_cache
def spotify() -> Spotify:
"""FastAPI-Dependency (in Tests per dependency_overrides ersetzbar)."""
return Spotify(konfig())
+127
View File
@@ -0,0 +1,127 @@
/* Audiofy – Admin-Bereich (ergänzt app.css) */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 4px 0 12px; }
.kopf h1 { font-size: 1.4rem; }
.pillen { display: flex; flex-wrap: wrap; gap: 6px; }
.pille { font-size: .78rem; font-weight: 700; padding: 3px 10px; border-radius: 999px; background: #333; color: var(--leise); }
.pille.gut { background: #12391f; color: var(--akzent); }
.pille.schlecht { background: #3d1717; color: var(--fehler); }
.reiter {
position: sticky;
top: 0;
z-index: 5;
display: flex;
gap: 4px;
overflow-x: auto;
background: var(--bg);
padding: 8px 0;
margin-bottom: 8px;
scrollbar-width: none;
}
.reiter button {
flex: 1 0 auto;
font: inherit;
font-weight: 600;
font-size: .95rem;
min-height: 44px;
padding: 0 14px;
border: 0;
border-radius: 999px;
background: var(--karte);
color: var(--leise);
cursor: pointer;
}
.reiter button[aria-selected="true"] { background: var(--text); color: #111; }
.zahl { display: inline-block; min-width: 20px; margin-left: 4px; padding: 0 6px; border-radius: 999px; background: var(--warnung); color: #111; font-size: .78rem; }
button.gross, a.gross {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
font-weight: 700;
font-size: 1.05rem;
min-height: 52px;
padding: 0 18px;
border: 0;
border-radius: 14px;
background: #2f2f2f;
color: var(--text);
cursor: pointer;
text-decoration: none;
}
button.akzent { background: var(--akzent); color: var(--akzent-text); }
/* Symbol über dem Text */
.gross.mit-symbol { flex-direction: column; gap: 4px; min-height: 72px; padding: 8px 12px; }
.gross.mit-symbol .symbol { font-size: 1.6rem; line-height: 1; }
button.gefahr { background: #b3261e; color: #fff; }
button:disabled { opacity: .5; cursor: default; }
form#login button { width: 100%; margin-top: 14px; }
.knopfreihe { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; }
.knopfreihe.zwei { grid-template-columns: repeat(2, 1fr); }
.voll { width: 100%; margin-top: 12px; }
button.klein {
font: inherit;
font-size: .85rem;
font-weight: 600;
min-height: 40px;
min-width: 40px;
padding: 0 10px;
border: 0;
border-radius: 10px;
background: #2f2f2f;
color: var(--text);
cursor: pointer;
flex: 0 0 auto;
}
button.klein.akzent { background: var(--akzent); color: var(--akzent-text); }
button.klein.gefahr { background: #4a1c1c; color: var(--fehler); }
.aktionen { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.schalter-zeile { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-top: 1px solid var(--linie); }
.schalter-zeile:first-of-type { border-top: 0; }
.schalter-zeile .text .m { white-space: normal; }
.schalter { position: relative; width: 58px; height: 34px; flex: 0 0 auto; }
.schalter input { opacity: 0; width: 100%; height: 100%; margin: 0; position: absolute; cursor: pointer; z-index: 1; }
.schalter span { position: absolute; inset: 0; border-radius: 999px; background: #444; transition: background .15s; }
.schalter span::after { content: ""; position: absolute; top: 4px; left: 4px; width: 26px; height: 26px; border-radius: 50%; background: #fff; transition: transform .15s; }
.schalter input:checked + span { background: var(--akzent); }
.schalter input:checked + span::after { transform: translateX(24px); }
.schalter input:focus-visible + span { outline: 2px solid var(--text); outline-offset: 2px; }
.feld-zeile { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; }
.feld-zeile input[type="number"] {
width: 96px;
font: inherit;
font-size: 1.05rem;
color: var(--text);
background: #2a2a2a;
border: 2px solid transparent;
border-radius: 10px;
padding: 8px 10px;
text-align: right;
}
input[type="range"] { width: 100%; height: 36px; accent-color: var(--akzent); }
.lautstaerke { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
.lautstaerke output { min-width: 3ch; text-align: right; font-weight: 700; }
.notaus-aktiv { background: #3d1717; color: var(--fehler); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 16px; font-weight: 700; }
.qr { display: block; width: 100%; max-width: 280px; margin: 12px auto; background: #fff; border-radius: 12px; padding: 8px; }
.link-feld { width: 100%; font: inherit; font-size: .9rem; color: var(--text); background: #2a2a2a; border: 0; border-radius: 10px; padding: 10px 12px; }
.fehlertext { color: var(--fehler); }
/* Legende zu den Symbol-Knöpfen – nur auf Touch-Geräten (dort gibt es keine Hover-Beschriftung) */
.legende { display: none; flex-wrap: wrap; justify-content: center; text-align: center; gap: 4px 14px; margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--linie); color: var(--leise); font-size: .82rem; }
@media (hover: none) { .legende { display: flex; } }
.zeit { color: var(--leise); font-size: .8rem; white-space: nowrap; }
.marke.besitzer { background: #6a4cff; color: #fff; }
.marke.quelle { background: #333; color: var(--leise); }
.abstand { margin-top: 18px; }
+176
View File
@@ -0,0 +1,176 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<meta name="robots" content="noindex">
<title>Audiofy – Admin</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/admin.css">
<script src="/static/admin.js" defer></script>
</head>
<body>
<main>
<div class="kopf">
<h1>Audiofy Admin</h1>
<button id="abmelden" class="klein" type="button">Abmelden</button>
</div>
<div class="pillen" id="pillen"></div>
<nav class="reiter" role="tablist">
<button type="button" role="tab" data-reiter="wiedergabe" aria-selected="true">Wiedergabe</button>
<button type="button" role="tab" data-reiter="wuensche">Wünsche<span id="zahl-freigabe" class="zahl" hidden></span></button>
<button type="button" role="tab" data-reiter="gaeste">Gäste</button>
<button type="button" role="tab" data-reiter="einstellungen">Einstellungen</button>
<button type="button" role="tab" data-reiter="verlauf">Verlauf</button>
</nav>
<div id="notaus-hinweis" class="notaus-aktiv" hidden>Not-Aus aktiv – der Controller ist angehalten.</div>
<!-- ===== Wiedergabe ===== -->
<section data-seite="wiedergabe">
<div class="karte">
<h2>Läuft gerade</h2>
<div id="jetzt-inhalt" class="titel-zeile"></div>
<div class="fortschritt"><div id="fortschritt-balken"></div></div>
<p id="naechster" class="hinweis klein"></p>
<p id="pi-fehler" class="hinweis klein fehlertext" hidden></p>
<div class="knopfreihe">
<button class="gross mit-symbol" type="button" data-befehl="play"><span class="symbol" aria-hidden="true">▶</span><span>Play</span></button>
<button class="gross mit-symbol" type="button" data-befehl="pause"><span class="symbol" aria-hidden="true">⏸</span><span>Pause</span></button>
<button class="gross mit-symbol" type="button" data-befehl="skip"><span class="symbol" aria-hidden="true">⏭</span><span>Weiter</span></button>
</div>
<div class="lautstaerke">
<span aria-hidden="true">🔊</span>
<input id="lautstaerke" type="range" min="0" max="100" step="5" aria-label="Lautstärke">
<output id="lautstaerke-wert">–</output>
</div>
</div>
<div class="karte">
<h2>Als Nächstes spielen</h2>
<input id="naechstes-suche" type="search" placeholder="Titel suchen …" autocomplete="off" maxlength="100">
<ul id="naechstes-treffer" class="liste"></ul>
</div>
<div class="karte">
<h2>Not-Aus</h2>
<p class="hinweis klein">Stoppt die Wiedergabe und hält den Controller an, bis du wieder freigibst.</p>
<button id="notaus" class="gross mit-symbol gefahr voll" type="button"><span class="symbol" aria-hidden="true">⛔</span><span>Not-Aus</span></button>
</div>
<div class="karte">
<h2>Letzte Befehle</h2>
<ul id="befehle" class="liste kompakt"></ul>
</div>
</section>
<!-- ===== Wünsche ===== -->
<section data-seite="wuensche" hidden>
<div class="karte">
<div class="schalter-zeile">
<div class="text"><span class="t">Wünsche annehmen</span><span class="m">Aus = Gäste sehen „pausiert“</span></div>
<label class="schalter"><input type="checkbox" id="s-annahme"><span></span></label>
</div>
<div class="schalter-zeile">
<div class="text"><span class="t">Moderation</span><span class="m">Wünsche erst nach deiner Freigabe spielen</span></div>
<label class="schalter"><input type="checkbox" id="s-moderation"><span></span></label>
</div>
</div>
<div class="karte" id="freigabe-karte">
<h2>Zur Freigabe</h2>
<ul id="freigabe" class="liste"></ul>
<p id="freigabe-leer" class="hinweis">Nichts zu prüfen.</p>
<p class="legende"><span>✓ freigeben</span><span>✕ ablehnen</span><span>🚫 Gast sperren</span></p>
</div>
<div class="karte">
<h2>Warteschlange</h2>
<ul id="warteschlange" class="liste"></ul>
<p id="warteschlange-leer" class="hinweis">Keine Wünsche in der Warteschlange.</p>
<p class="legende"><span>↑ früher</span><span>↓ später</span><span>✕ löschen</span><span>🚫 Gast sperren</span></p>
</div>
</section>
<!-- ===== Gäste ===== -->
<section data-seite="gaeste" hidden>
<div class="karte">
<h2>Gastzugang</h2>
<div class="schalter-zeile">
<div class="text"><span class="t">Zugang offen</span><span class="m">Aus = niemand kommt auf die Wunschseite</span></div>
<label class="schalter"><input type="checkbox" id="s-zugang"><span></span></label>
</div>
<img id="qr" class="qr" alt="QR-Code zum Einladungslink">
<input id="einladungslink" class="link-feld" type="text" readonly aria-label="Einladungslink">
<div class="knopfreihe zwei">
<button id="link-kopieren" class="gross" type="button">Link kopieren</button>
<a id="qr-png" class="gross" href="/admin/api/access/qr?format=png" download>QR als Bild</a>
</div>
<button id="link-erneuern" class="gross voll" type="button">Neuen Link erzeugen</button>
<p class="hinweis klein">Ein neuer Link macht den alten und alle damit erteilten Zugänge ungültig.</p>
</div>
<div class="karte">
<h2>Limit pro Gast</h2>
<div class="feld-zeile"><span>Wünsche</span><input id="e-limit-anzahl" type="number" min="1" max="50"></div>
<div class="feld-zeile"><span>pro Minuten</span><input id="e-limit-minuten" type="number" min="1" max="1440"></div>
<button class="gross akzent voll" type="button" data-speichern="e-limit-anzahl:wish_limit_count,e-limit-minuten:wish_limit_minutes">Speichern</button>
</div>
<div class="karte">
<h2>Sperrliste</h2>
<div class="schalter-zeile">
<div class="text"><span class="t">Explizite Titel sperren</span></div>
<label class="schalter"><input type="checkbox" id="s-explizit"><span></span></label>
</div>
<input id="sperr-suche" type="search" placeholder="Titel oder Interpret zum Sperren suchen …" autocomplete="off" maxlength="100">
<ul id="sperr-treffer" class="liste"></ul>
<h2 class="abstand">Gesperrt</h2>
<ul id="sperrliste" class="liste"></ul>
<p id="sperrliste-leer" class="hinweis">Nichts gesperrt.</p>
</div>
</section>
<!-- ===== Einstellungen ===== -->
<section data-seite="einstellungen" hidden>
<div class="karte">
<h2>Ablauf</h2>
<div class="feld-zeile"><span>Jeder wievielte Titel ist ein Wunsch (N)</span><input id="e-n" type="number" min="1" max="20"></div>
<div class="feld-zeile"><span>Sperrzeit für Wiederholungen (Min.)</span><input id="e-sperrzeit" type="number" min="0" max="1440"></div>
<button class="gross akzent voll" type="button" data-speichern="e-n:wish_every_n,e-sperrzeit:repeat_block_minutes">Speichern</button>
</div>
<div class="karte">
<h2>Lautsprecher</h2>
<div id="bt-aktuell" class="titel-zeile"></div>
<p class="hinweis klein">Neuen Bluetooth-Lautsprecher vorher in den Kopplungsmodus versetzen (meist Bluetooth-Taste gedrückt halten, bis die LED schnell blinkt). Während der Suche kann die Musik kurz stocken – WLAN und Bluetooth teilen sich oft einen Funkchip.</p>
<button id="bt-suchen" class="gross voll" type="button">Nach Lautsprechern suchen</button>
<p id="bt-status" class="hinweis klein"></p>
<ul id="bt-geraete" class="liste"></ul>
</div>
<div class="karte">
<h2>Grund-Playlist</h2>
<p class="hinweis klein">Aktuell: <a id="playlist-link" target="_blank" rel="noopener noreferrer"></a></p>
<input id="playlist-neu" type="text" placeholder="Spotify-Playlist-Link einfügen" autocomplete="off" maxlength="300">
<div class="knopfreihe zwei">
<button id="playlist-wechseln" class="gross akzent" type="button">Wechseln</button>
<button id="neu-mischen" class="gross" type="button">Neu mischen</button>
</div>
</div>
</section>
<!-- ===== Verlauf ===== -->
<section data-seite="verlauf" hidden>
<div class="karte">
<h2>Verlauf</h2>
<ul id="verlauf" class="liste"></ul>
<p id="verlauf-leer" class="hinweis">Noch nichts gespielt.</p>
</div>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="assertive" hidden></div>
</body>
</html>
+450
View File
@@ -0,0 +1,450 @@
// Audiofy – Admin-Bereich. Daten nur per textContent einsetzen (kein innerHTML mit fremden Inhalten).
"use strict";
const ABFRAGE_MS = 3000;
const QUELLE = { playlist: "Playlist", wish: "Wunsch", owner: "Besitzer", fremd: "Spotify-App" };
const BEFEHL = { play: "Play", pause: "Pause", skip: "Weiter", volume: "Lautstärke",
bt_scan: "Lautsprecher suchen", bt_connect: "Lautsprecher verbinden" };
const $ = (id) => document.getElementById(id);
let csrf = "";
let zustand = null;
let fortschritt = null;
let aktiverReiter = "wiedergabe";
let lautstaerkeZiel = null; // {wert, bis}: gesendeter Wert, bis der Player ihn bestätigt
// --- Hilfen ------------------------------------------------------------------
function el(tag, klasse, text) {
const e = document.createElement(tag);
if (klasse) e.className = klasse;
if (text !== undefined && text !== null) e.textContent = text;
return e;
}
function cover(url, gross) {
const img = el("img", "cover");
img.alt = "";
img.loading = "lazy";
if (url && url.startsWith("https://i.scdn.co/")) img.src = url;
if (gross) img.width = img.height = 72;
return img;
}
function textBlock(titel, interpret, ...extra) {
const t = el("div", "text");
t.append(el("span", "t", titel), el("span", "a", interpret));
extra.filter(Boolean).forEach((x) => t.append(x));
return t;
}
function knopf(text, klasse, aktion, titel) {
const b = el("button", "klein " + (klasse || ""), text);
b.type = "button";
if (titel) b.title = b.ariaLabel = titel;
b.addEventListener("click", async () => {
b.disabled = true;
try { await aktion(); } finally { b.disabled = false; }
});
return b;
}
function uhrzeit(iso) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
let toastTimer = null;
function toast(text, fehler) {
const t = $("toast");
t.textContent = text;
t.className = "toast" + (fehler ? " fehler" : "");
t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.hidden = true; }, 3500);
}
async function api(pfad, daten, methode) {
const optionen = { method: methode || (daten === undefined ? "GET" : "POST"), credentials: "same-origin", headers: {} };
if (optionen.method !== "GET") {
optionen.headers["X-CSRF-Token"] = csrf;
optionen.headers["Content-Type"] = "application/json";
optionen.body = JSON.stringify(daten || {});
}
const antwort = await fetch("/admin/api" + pfad, optionen);
if (antwort.status === 401) { location.href = "/admin/login"; throw new Error("Abgemeldet"); }
let inhalt = null;
try { inhalt = await antwort.json(); } catch (_) { /* leer */ }
if (!antwort.ok) {
const meldung = inhalt && typeof inhalt.detail === "string" ? inhalt.detail : "Fehler " + antwort.status;
toast(meldung, true);
throw new Error(meldung);
}
return inhalt;
}
async function aktion(pfad, daten, meldung) {
await api(pfad, daten);
if (meldung) toast(meldung);
await laden();
}
function setzeWert(id, wert) {
const e = $(id);
if (document.activeElement !== e) e.value = wert; // Eingaben des Besitzers nicht überschreiben
}
function setzeSchalter(id, wert) {
const e = $(id);
if (document.activeElement !== e) e.checked = !!wert;
}
// --- Reiter --------------------------------------------------------------------
function zeigeReiter(name) {
aktiverReiter = name;
document.querySelectorAll("[data-reiter]").forEach((b) => b.setAttribute("aria-selected", String(b.dataset.reiter === name)));
document.querySelectorAll("[data-seite]").forEach((s) => { s.hidden = s.dataset.seite !== name; });
if (name === "verlauf") ladeVerlauf();
if (name === "gaeste") { ladeZugang(); ladeSperrliste(); }
}
// --- Darstellung des Zustands ------------------------------------------------------
function zeigePillen(z) {
const p = $("pillen");
p.replaceChildren();
const st = z.player.state || {};
const pille = (text, gut) => p.append(el("span", "pille " + (gut ? "gut" : "schlecht"), text));
if (z.player.online) pille("Player online", true);
else pille(z.player.last_seen_s === null ? "Player noch nie gesehen" : `Player offline (${Math.round(z.player.last_seen_s / 60)} min)`, false);
if (z.player.online) {
pille(st.device_ok === false ? "Gerät fehlt" : "Gerät ok", st.device_ok !== false);
pille(st.speaker_ok === false ? "Lautsprecher getrennt" : "Lautsprecher ok", st.speaker_ok !== false);
}
if (z.settings.wishes_paused) pille("Wünsche pausiert", false);
if (!z.settings.access_enabled) pille("Gastzugang zu", false);
}
function zeigeWiedergabe(z) {
const st = z.player.state || {};
const box = $("jetzt-inhalt");
box.replaceChildren();
if (z.player.online && st.track) {
const marke = st.source ? el("span", "marke quelle", QUELLE[st.source] || st.source) : null;
box.append(cover(st.track.cover_url, true),
textBlock(st.track.title, st.track.artist, marke, st.is_playing ? null : el("span", "m", "pausiert")));
fortschritt = st.track.duration_ms ? {
basis: (st.progress_ms || 0) + (st.is_playing ? z.player.age_ms || 0 : 0),
dauer: st.track.duration_ms, zeit: performance.now(), spielt: st.is_playing,
} : null;
} else {
box.append(el("span", "hinweis", z.player.online ? "Gerade läuft nichts." : "Der Player meldet sich nicht."));
fortschritt = null;
}
zeichneFortschritt();
$("naechster").textContent = st.next ? `Als Nächstes: ${st.next.title} – ${st.next.artist}` : "";
$("pi-fehler").hidden = !st.error;
$("pi-fehler").textContent = st.error || "";
if (typeof st.volume_percent === "number") {
// Nach einer Änderung den eigenen Wert halten, bis der Player ihn zurückmeldet (max. 15 s)
const ziel = lautstaerkeZiel;
const wartet = ziel && performance.now() < ziel.bis && st.volume_percent !== ziel.wert;
if (!wartet) {
lautstaerkeZiel = null;
setzeWert("lautstaerke", st.volume_percent);
if (document.activeElement !== $("lautstaerke")) $("lautstaerke-wert").textContent = st.volume_percent;
}
}
const stopp = z.settings.emergency_stop;
$("notaus-hinweis").hidden = !stopp;
const symbol = el("span", "symbol", stopp ? "✅" : "⛔");
symbol.setAttribute("aria-hidden", "true");
$("notaus").replaceChildren(symbol, el("span", null, stopp ? "Freigeben" : "Not-Aus"));
$("notaus").className = "gross mit-symbol voll " + (stopp ? "akzent" : "gefahr");
const liste = $("befehle");
liste.replaceChildren();
z.commands.forEach((b) => {
const li = el("li");
const status = { ok: "ausgeführt", fehler: "fehlgeschlagen", wartet: "wartet …", abgelaufen: "nicht abgeholt" }[b.status];
li.append(el("span", "zeit", uhrzeit(b.created_at)), textBlock(BEFEHL[b.type] || b.type, b.message || ""),
el("span", "status " + (b.status === "ok" ? "queued" : b.status === "wartet" ? "pending" : "rejected"), status));
liste.append(li);
});
if (!z.commands.length) liste.append(el("li", "hinweis", "Noch keine Befehle."));
}
function zeichneFortschritt() {
const balken = $("fortschritt-balken");
if (!fortschritt) { balken.style.width = "0"; return; }
const pos = fortschritt.basis + (fortschritt.spielt ? performance.now() - fortschritt.zeit : 0);
balken.style.width = Math.min(100, (pos / fortschritt.dauer) * 100) + "%";
}
function wunschZeile(w, istFreigabe) {
const li = el("li");
const extra = [];
if (w.priority) extra.push(el("span", "marke besitzer", "Besitzer"));
if (w.explicit) extra.push(el("span", "marke e", "E"));
const wer = w.priority ? null : el("span", "m", (w.guest_name ? w.guest_name + " · " : "") + uhrzeit(w.created_at));
const text = textBlock(w.title, w.artist, wer);
extra.forEach((x) => text.querySelector(".t").append(x));
const knoepfe = el("div", "aktionen");
if (istFreigabe) {
knoepfe.append(knopf("✓", "akzent", () => aktion(`/wish/${w.id}/approve`, {}, "Freigegeben"), "Freigeben"),
knopf("✕", "gefahr", () => aktion(`/wish/${w.id}/reject`, {}, "Abgelehnt"), "Ablehnen"));
} else {
knoepfe.append(knopf("↑", "", () => aktion(`/wish/${w.id}/up`, {}), "Nach oben"),
knopf("↓", "", () => aktion(`/wish/${w.id}/down`, {}), "Nach unten"),
knopf("✕", "gefahr", () => aktion(`/wish/${w.id}/delete`, {}, "Entfernt"), "Löschen"));
}
if (w.has_guest) {
knoepfe.append(knopf("🚫", "gefahr", async () => {
if (confirm(`Gast „${w.guest_name || "ohne Namen"}“ sperren und seine offenen Wünsche entfernen?`)) {
await aktion(`/wish/${w.id}/block-guest`, {}, "Gast gesperrt");
}
}, "Gast sperren"));
}
li.append(cover(w.cover_url), text, knoepfe);
return li;
}
function zeigeWuensche(z) {
setzeSchalter("s-annahme", !z.settings.wishes_paused);
setzeSchalter("s-moderation", z.settings.moderation);
const fl = $("freigabe");
fl.replaceChildren(...z.pending.map((w) => wunschZeile(w, true)));
$("freigabe-leer").hidden = z.pending.length > 0;
$("freigabe-karte").hidden = !z.settings.moderation && z.pending.length === 0;
const zahl = $("zahl-freigabe");
zahl.hidden = z.pending.length === 0;
zahl.textContent = z.pending.length;
const ws = $("warteschlange");
ws.replaceChildren(...z.queue.map((w) => wunschZeile(w, false)));
$("warteschlange-leer").hidden = z.queue.length > 0;
}
function zeigeLautsprecher(z) {
const bt = z.player.bluetooth || {};
const box = $("bt-aktuell");
box.replaceChildren();
if (bt.current) {
const status = el("span", "pille " + (bt.current.connected ? "gut" : "schlecht"), bt.current.connected ? "verbunden" : "getrennt");
box.append(textBlock(bt.current.name || "Unbekannter Lautsprecher", bt.current.mac), status);
} else {
box.append(el("span", "hinweis", z.player.online ? "Kein Bluetooth-Lautsprecher eingestellt." : "Player meldet sich nicht."));
}
const suchen = $("bt-suchen");
suchen.disabled = !!bt.scanning || !z.player.online;
suchen.textContent = bt.scanning ? "Läuft …" : "Nach Lautsprechern suchen";
$("bt-status").textContent = bt.scanning ? "Suche bzw. Kopplung läuft – das dauert bis zu 45 Sekunden." :
bt.scanned_at ? `Letzte Suche: ${uhrzeit(bt.scanned_at)} – ${(bt.devices || []).length} Audiogerät(e) gefunden.` : "";
const liste = $("bt-geraete");
liste.replaceChildren(...(bt.devices || []).map((g) => {
const li = el("li");
const istAktuell = bt.current && bt.current.mac === g.mac;
const info = [g.mac, g.paired ? "gekoppelt" : "neu"].join(" · ");
const knoepfe = el("div", "aktionen");
if (!istAktuell) {
const b = knopf("Verbinden", "akzent", async () => {
if (!confirm(`Auf „${g.name || g.mac}“ umstellen? Die Musik setzt dabei kurz aus.`)) return;
await aktion("/command", { type: "bt_connect", mac: g.mac }, "Verbinde – bitte warten …");
});
b.disabled = !!bt.scanning;
knoepfe.append(b);
} else {
knoepfe.append(el("span", "pille gut", "aktuell"));
}
li.append(textBlock(g.name || "Unbekannt", info), knoepfe);
return li;
}));
}
function zeigeEinstellungen(z) {
const e = z.settings;
setzeWert("e-n", e.wish_every_n);
setzeWert("e-sperrzeit", e.repeat_block_minutes);
setzeWert("e-limit-anzahl", e.wish_limit_count);
setzeWert("e-limit-minuten", e.wish_limit_minutes);
setzeSchalter("s-explizit", e.block_explicit);
setzeSchalter("s-zugang", e.access_enabled);
const link = $("playlist-link");
link.textContent = e.playlist_id || "keine";
if (e.playlist_id) link.href = "https://open.spotify.com/playlist/" + encodeURIComponent(e.playlist_id);
}
async function laden() {
try {
zustand = await api("/state");
} catch (_) { return; }
csrf = zustand.csrf;
zeigePillen(zustand);
zeigeWiedergabe(zustand);
zeigeWuensche(zustand);
zeigeEinstellungen(zustand);
zeigeLautsprecher(zustand);
}
// --- Gäste: Zugang & Sperrliste ------------------------------------------------------
async function ladeZugang() {
const z = await api("/access");
setzeSchalter("s-zugang", z.enabled);
$("einladungslink").value = z.link;
$("qr").src = "/admin/api/access/qr?t=" + Date.now();
}
async function ladeSperrliste() {
const daten = await api("/blocklist");
const art = { track: "Titel", artist: "Interpret", guest: "Gast", ip: "Gast (IP)" };
const liste = $("sperrliste");
liste.replaceChildren(...daten.items.map((s) => {
const li = el("li");
li.append(textBlock(s.label || "–", art[s.kind] || s.kind),
knopf("Entsperren", "", async () => { await api(`/blocklist/${s.id}/delete`, {}); toast("Entsperrt"); ladeSperrliste(); }));
return li;
}));
$("sperrliste-leer").hidden = daten.items.length > 0;
}
// --- Suche (Als Nächstes / Sperren) ---------------------------------------------------
function suchfeld(feldId, listeId, zeile) {
let timer = null;
let nr = 0;
$(feldId).addEventListener("input", (ereignis) => {
clearTimeout(timer);
const q = ereignis.target.value.trim();
if (q.length < 2) { nr++; $(listeId).replaceChildren(); return; }
timer = setTimeout(async () => {
const meine = ++nr;
try {
const daten = await api("/search?q=" + encodeURIComponent(q));
if (meine !== nr) return;
$(listeId).replaceChildren(...(daten.results.length ? daten.results.map(zeile) : [el("li", "hinweis", "Nichts gefunden.")]));
} catch (_) { /* Meldung kam schon */ }
}, 400);
});
}
function naechstesZeile(t) {
const li = el("li");
li.append(cover(t.cover_url), textBlock(t.title, t.artist),
knopf("Als Nächstes", "akzent", async () => {
await aktion("/play-next", { track_uri: t.track_uri }, `„${t.title}“ kommt als Nächstes`);
$("naechstes-suche").value = "";
$("naechstes-treffer").replaceChildren();
}));
return li;
}
function sperrZeile(t) {
const li = el("li");
const knoepfe = el("div", "aktionen");
knoepfe.append(knopf("Titel", "gefahr", async () => {
await api("/blocklist", { kind: "track", value: t.track_uri, label: `${t.title} – ${t.artist}` });
toast("Titel gesperrt"); ladeSperrliste();
}, "Titel sperren"));
(t.artists || []).forEach((a) => knoepfe.append(knopf(a.name, "gefahr", async () => {
await api("/blocklist", { kind: "artist", value: a.uri, label: a.name });
toast(`${a.name} gesperrt`); ladeSperrliste();
}, `Interpret ${a.name} sperren`)));
li.append(cover(t.cover_url), textBlock(t.title, t.artist), knoepfe);
return li;
}
// --- Verlauf -----------------------------------------------------------------------
async function ladeVerlauf() {
const daten = await api("/history?limit=200");
$("verlauf").replaceChildren(...daten.items.map((h) => {
const li = el("li");
const quelle = el("span", "marke quelle", QUELLE[h.source] || "?");
const wer = h.guest_name ? el("span", "m", "gewünscht von " + h.guest_name) : null;
const text = textBlock(h.title, h.artist, wer);
text.querySelector(".t").append(quelle);
li.append(el("span", "zeit", uhrzeit(h.started_at)), cover(h.cover_url), text);
return li;
}));
$("verlauf-leer").hidden = daten.items.length > 0;
}
// --- Start ---------------------------------------------------------------------------
function start() {
document.querySelectorAll("[data-reiter]").forEach((b) => b.addEventListener("click", () => zeigeReiter(b.dataset.reiter)));
document.querySelectorAll("[data-befehl]").forEach((b) => b.addEventListener("click", async () => {
b.disabled = true;
try { await aktion("/command", { type: b.dataset.befehl }, "Befehl gesendet"); } catch (_) { /* gemeldet */ }
b.disabled = false;
}));
const regler = $("lautstaerke");
regler.addEventListener("input", () => { $("lautstaerke-wert").textContent = regler.value; });
regler.addEventListener("change", () => {
lautstaerkeZiel = { wert: Number(regler.value), bis: performance.now() + 15000 };
aktion("/command", { type: "volume", percent: Number(regler.value) }, "Lautstärke gesendet")
.catch(() => { lautstaerkeZiel = null; });
});
$("notaus").addEventListener("click", async () => {
const stopp = !(zustand && zustand.settings.emergency_stop);
await aktion("/emergency", { stop: stopp }, stopp ? "Not-Aus ausgelöst" : "Freigegeben").catch(() => {});
});
const schalter = (id, key, umkehren) => $(id).addEventListener("change", (ereignis) => {
const wert = umkehren ? !ereignis.target.checked : ereignis.target.checked;
aktion("/settings", { [key]: wert }, "Gespeichert").catch(() => { ereignis.target.checked = !ereignis.target.checked; });
});
schalter("s-annahme", "wishes_paused", true);
schalter("s-moderation", "moderation");
schalter("s-explizit", "block_explicit");
document.querySelectorAll("[data-speichern]").forEach((b) => b.addEventListener("click", () => {
const werte = {};
b.dataset.speichern.split(",").forEach((paar) => {
const [feld, key] = paar.split(":");
werte[key] = Number($(feld).value);
});
aktion("/settings", werte, "Gespeichert").catch(() => {});
}));
$("s-zugang").addEventListener("change", (ereignis) => {
aktion("/access", { enabled: ereignis.target.checked }, ereignis.target.checked ? "Gastzugang offen" : "Gastzugang geschlossen")
.catch(() => { ereignis.target.checked = !ereignis.target.checked; });
});
$("link-kopieren").addEventListener("click", async () => {
try { await navigator.clipboard.writeText($("einladungslink").value); toast("Link kopiert"); }
catch (_) { $("einladungslink").select(); toast("Bitte manuell kopieren", true); }
});
$("link-erneuern").addEventListener("click", async () => {
if (!confirm("Neuen Link erzeugen? Der alte Link und alle bisherigen Gastzugänge werden ungültig.")) return;
await api("/access/renew", {});
toast("Neuer Link erzeugt");
ladeZugang();
});
$("playlist-wechseln").addEventListener("click", async () => {
await aktion("/playlist", { link: $("playlist-neu").value }, "Playlist gewechselt").catch(() => {});
$("playlist-neu").value = "";
});
$("bt-suchen").addEventListener("click", () => aktion("/command", { type: "bt_scan" }, "Suche gestartet").catch(() => {}));
$("neu-mischen").addEventListener("click", () => aktion("/reshuffle", {}, "Wird neu gemischt").catch(() => {}));
$("abmelden").addEventListener("click", async () => {
await api("/logout", {}).catch(() => {});
location.href = "/admin/login";
});
suchfeld("naechstes-suche", "naechstes-treffer", naechstesZeile);
suchfeld("sperr-suche", "sperr-treffer", sperrZeile);
laden();
setInterval(() => { if (!document.hidden) laden(); }, ABFRAGE_MS);
setInterval(zeichneFortschritt, 1000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) laden(); });
}
start();
+166
View File
@@ -0,0 +1,166 @@
/* Audiofy – Gastseite (mobil zuerst) */
:root {
--bg: #121212;
--karte: #1e1e1e;
--linie: #2c2c2c;
--text: #f2f2f2;
--leise: #a7a7a7;
--akzent: #1ed760;
--akzent-text: #0b0b0b;
--warnung: #f5a524;
--fehler: #ff6b6b;
--radius: 14px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main {
max-width: 640px;
margin: 0 auto;
padding: 16px 16px calc(32px + env(safe-area-inset-bottom));
}
header { text-align: center; margin: 8px 0 16px; }
h1 { margin: 0; font-size: 1.9rem; letter-spacing: .02em; }
.untertitel { margin: 0; color: var(--leise); }
h2 { margin: 0 0 12px; font-size: 1.05rem; color: var(--leise); font-weight: 600; text-transform: uppercase; letter-spacing: .06em; }
.karte {
background: var(--karte);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 16px;
}
.banner {
background: #3a2d10;
color: var(--warnung);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 16px;
font-weight: 600;
}
.banner.leise { background: var(--karte); color: var(--leise); font-weight: 400; }
.hinweis { color: var(--leise); margin: 8px 0 0; }
.klein { font-size: .875rem; }
input[type="search"], input[type="text"] {
width: 100%;
font: inherit;
font-size: 1.05rem; /* ≥ 16px verhindert Zoom auf iOS */
color: var(--text);
background: #2a2a2a;
border: 2px solid transparent;
border-radius: 999px;
padding: 12px 18px;
outline: none;
}
input:focus { border-color: var(--akzent); }
.name-zeile { display: block; margin-top: 10px; }
.turnstile { display: flex; justify-content: center; }
.turnstile:not(:empty) { margin-top: 10px; }
.name-zeile span { display: block; font-size: .875rem; color: var(--leise); margin: 0 0 4px 6px; }
.name-zeile input { font-size: 1rem; padding: 10px 16px; }
.liste { list-style: none; margin: 8px 0 0; padding: 0; }
.liste li {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--linie);
}
.liste li:first-child { border-top: 0; }
.liste.kompakt li { padding: 6px 0; }
.cover {
width: 52px;
height: 52px;
border-radius: 6px;
background: #333;
flex: 0 0 auto;
object-fit: cover;
}
.titel-zeile { display: flex; align-items: center; gap: 14px; }
.titel-zeile .cover { width: 72px; height: 72px; }
.text { min-width: 0; flex: 1; }
.text .t, .text .a {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.text .t { font-weight: 600; }
.text .a, .text .m { color: var(--leise); font-size: .9rem; }
.text .m { display: block; }
.marke {
display: inline-block;
font-size: .72rem;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
margin-left: 6px;
vertical-align: 2px;
background: var(--akzent);
color: var(--akzent-text);
}
.marke.e { background: #555; color: var(--text); }
button.wuenschen {
flex: 0 0 auto;
font: inherit;
font-weight: 700;
min-height: 44px;
min-width: 96px;
padding: 0 16px;
border: 0;
border-radius: 999px;
background: var(--akzent);
color: var(--akzent-text);
cursor: pointer;
}
button.wuenschen:disabled { background: #333; color: var(--leise); cursor: default; }
.gesperrt { color: var(--leise); font-size: .8rem; }
.fortschritt { height: 4px; background: #333; border-radius: 2px; margin-top: 12px; overflow: hidden; }
#fortschritt-balken { height: 100%; width: 0; background: var(--akzent); transition: width 1s linear; }
.status { font-size: .8rem; font-weight: 700; white-space: nowrap; }
.status.queued { color: var(--akzent); }
.status.pending { color: var(--warnung); }
.status.played { color: var(--leise); }
.status.rejected, .status.deleted { color: var(--fehler); }
.toast {
position: fixed;
left: 50%;
bottom: calc(24px + env(safe-area-inset-bottom));
transform: translateX(-50%);
max-width: calc(100% - 32px);
background: var(--text);
color: #111;
padding: 12px 18px;
border-radius: 12px;
font-weight: 600;
box-shadow: 0 6px 24px rgba(0, 0, 0, .5);
}
.toast.fehler { background: var(--fehler); color: #111; }
footer { text-align: center; color: #666; font-size: .8rem; margin-top: 24px; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); border: 0;
}
+237
View File
@@ -0,0 +1,237 @@
// Audiofy – Gastseite. Daten nur per textContent einsetzen (kein innerHTML mit fremden Inhalten).
"use strict";
const STATUS_ALLE_MS = 5000;
const SUCHE_VERZOEGERUNG_MS = 400;
const STATUS_TEXT = {
queued: "auf der Liste",
pending: "wartet auf Freigabe",
played: "gespielt",
rejected: "abgelehnt",
deleted: "entfernt",
};
const $ = (id) => document.getElementById(id);
let letzterStatus = null;
let schutz = { token: async () => null, reset() {}, bereit: () => true }; // Turnstile (falls aktiv)
let fortschrittBasis = null; // {progress, dauer, zeit, spielt}
function el(tag, klasse, text) {
const e = document.createElement(tag);
if (klasse) e.className = klasse;
if (text !== undefined && text !== null) e.textContent = text;
return e;
}
function cover(url, gross) {
const img = el("img", "cover");
img.alt = "";
img.loading = "lazy";
img.decoding = "async";
if (url && url.startsWith("https://i.scdn.co/")) img.src = url;
if (gross) img.width = img.height = 72;
return img;
}
function textBlock(titel, interpret, extra) {
const t = el("div", "text");
t.append(el("span", "t", titel), el("span", "a", interpret));
if (extra) t.append(extra);
return t;
}
let toastTimer = null;
function toast(text, fehler) {
const t = $("toast");
t.textContent = text;
t.className = "toast" + (fehler ? " fehler" : "");
t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.hidden = true; }, 4000);
}
async function api(pfad, optionen) {
const antwort = await fetch(pfad, Object.assign({ credentials: "same-origin" }, optionen));
let daten = null;
try { daten = await antwort.json(); } catch (_) { /* leer */ }
if (antwort.status === 401) { // Zugang abgelaufen/deaktiviert → Hinweisseite zeigen
location.reload();
throw new Error("Kein Zugang.");
}
if (!antwort.ok) {
const meldung = daten && typeof daten.detail === "string" ? daten.detail : "Da ist etwas schiefgelaufen.";
throw new Error(meldung);
}
return daten;
}
// --- Läuft gerade ---------------------------------------------------------
function zeigeJetzt(jetzt) {
const box = $("jetzt-inhalt");
box.replaceChildren();
if (!jetzt) {
box.className = "titel-zeile leer";
box.append(el("span", "hinweis", "Gerade läuft nichts."));
$("fortschritt").hidden = true;
fortschrittBasis = null;
return;
}
box.className = "titel-zeile";
const extra = jetzt.is_wish ? el("span", "marke", "Wunsch") : null;
const text = textBlock(jetzt.title, jetzt.artist);
if (extra) text.querySelector(".t").append(extra);
if (!jetzt.is_playing) text.append(el("span", "m", "pausiert"));
box.append(cover(jetzt.cover_url, true), text);
if (jetzt.duration_ms) {
fortschrittBasis = {
progress: jetzt.progress_ms + (jetzt.is_playing ? jetzt.age_ms : 0),
dauer: jetzt.duration_ms,
zeit: performance.now(),
spielt: jetzt.is_playing,
};
$("fortschritt").hidden = false;
zeichneFortschritt();
}
}
function zeichneFortschritt() {
if (!fortschrittBasis) return;
const b = fortschrittBasis;
const pos = b.progress + (b.spielt ? performance.now() - b.zeit : 0);
$("fortschritt-balken").style.width = Math.min(100, (pos / b.dauer) * 100) + "%";
}
// --- Status (Warteschlange, eigene Wünsche, Hinweise) -----------------------
function zeigeStatus(s) {
letzterStatus = s;
zeigeJetzt(s.now_playing);
$("hinweis-pausiert").hidden = !s.wishes_paused;
$("hinweis-moderation").hidden = !s.moderation || s.wishes_paused;
const l = s.limit;
$("limit-info").textContent = s.wishes_paused ? "" :
l.remaining > 0
? `Du kannst noch ${l.remaining} von ${l.count} Wünschen in ${l.minutes} Minuten abgeben.`
: `Limit erreicht – nächster Wunsch in ca. ${l.next_in_minutes} Min.`;
const ws = $("warteschlange");
ws.replaceChildren();
s.queue.forEach((w, i) => {
const li = el("li");
const wer = w.mine ? "dein Wunsch" : (w.name ? `gewünscht von ${w.name}` : null);
li.append(el("span", "status", String(i + 1) + "."), cover(w.cover_url),
textBlock(w.title, w.artist, wer ? el("span", "m", wer) : null));
ws.append(li);
});
$("warteschlange-leer").hidden = s.queue.length > 0;
const fi = $("freigabe-info");
fi.hidden = !s.pending_count;
fi.textContent = s.pending_count === 1 ? "1 Wunsch wartet auf Freigabe." : `${s.pending_count} Wünsche warten auf Freigabe.`;
const meine = $("meine");
meine.replaceChildren();
s.mine.forEach((w) => {
const li = el("li");
li.append(textBlock(w.title, w.artist), el("span", "status " + w.status, STATUS_TEXT[w.status] || w.status));
meine.append(li);
});
$("meine-karte").hidden = s.mine.length === 0;
aktualisiereKnoepfe();
}
async function ladeStatus() {
try {
zeigeStatus(await api("/api/status"));
} catch (_) { /* nächster Versuch beim nächsten Intervall */ }
}
// --- Suche & Wünschen --------------------------------------------------------
let sucheTimer = null;
let sucheNr = 0;
function aktualisiereKnoepfe() {
const aus = letzterStatus && (letzterStatus.wishes_paused || letzterStatus.limit.remaining === 0);
document.querySelectorAll("button.wuenschen").forEach((b) => {
if (!b.dataset.gesperrt && !b.dataset.erledigt) b.disabled = !!aus;
});
}
function zeigeTreffer(treffer) {
const liste = $("treffer");
liste.replaceChildren();
if (!treffer.length) {
liste.append(el("li", "hinweis", "Nichts gefunden."));
return;
}
treffer.forEach((t) => {
const li = el("li");
const text = textBlock(t.title, t.artist, t.blocked ? el("span", "gesperrt", t.blocked) : null);
if (t.explicit) text.querySelector(".t").append(el("span", "marke e", "E"));
const knopf = el("button", "wuenschen", "Wünschen");
knopf.type = "button";
if (t.blocked) { knopf.disabled = true; knopf.dataset.gesperrt = "1"; }
knopf.addEventListener("click", () => wuenschen(t, knopf));
li.append(cover(t.cover_url), text, knopf);
liste.append(li);
});
aktualisiereKnoepfe();
}
async function suchen(q) {
const nr = ++sucheNr;
try {
const daten = await api("/api/search?q=" + encodeURIComponent(q));
if (nr === sucheNr) zeigeTreffer(daten.results);
} catch (fehler) {
if (nr === sucheNr) toast(fehler.message, true);
}
}
async function wuenschen(titel, knopf) {
knopf.disabled = true;
const name = $("name").value.trim();
try { localStorage.setItem("audiofy-name", name); } catch (_) { /* privat */ }
try {
if (!schutz.bereit()) knopf.textContent = "Prüfung …";
const turnstile = await schutz.token();
const daten = await api("/api/wish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ track_uri: titel.track_uri, name: name || null, turnstile }),
}).finally(() => schutz.reset());
knopf.textContent = "✓ Gewünscht";
knopf.dataset.erledigt = "1";
toast(daten.message);
ladeStatus();
} catch (fehler) {
toast(fehler.message, true);
knopf.textContent = "Wünschen";
knopf.disabled = false;
}
}
function start() {
try { $("name").value = localStorage.getItem("audiofy-name") || ""; } catch (_) { /* privat */ }
$("suchfeld").addEventListener("input", (ereignis) => {
clearTimeout(sucheTimer);
const q = ereignis.target.value.trim();
if (q.length < 2) { sucheNr++; $("treffer").replaceChildren(); return; }
sucheTimer = setTimeout(() => suchen(q), SUCHE_VERZOEGERUNG_MS);
});
$("suchfeld").addEventListener("keydown", (ereignis) => {
if (ereignis.key === "Enter") { ereignis.target.blur(); } // Tastatur auf dem Handy schließen
});
window.AudiofyTurnstile.einrichten($("turnstile"), "wish").then((s) => { schutz = s; });
ladeStatus();
setInterval(() => { if (!document.hidden) ladeStatus(); }, STATUS_ALLE_MS);
setInterval(zeichneFortschritt, 1000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) ladeStatus(); });
}
start();
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<title>Audiofy – Musikwünsche</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="preconnect" href="https://challenges.cloudflare.com">
<script src="/static/turnstile.js" defer></script>
<script src="/static/app.js" defer></script>
</head>
<body>
<main>
<header>
<h1>Audiofy</h1>
<p class="untertitel">Musikwünsche</p>
</header>
<section id="jetzt" class="karte" aria-live="polite">
<h2>Läuft gerade</h2>
<div id="jetzt-inhalt" class="titel-zeile leer">
<span class="hinweis">Gerade läuft nichts.</span>
</div>
<div id="fortschritt" class="fortschritt" hidden><div id="fortschritt-balken"></div></div>
</section>
<div id="hinweis-pausiert" class="banner" hidden>Die Wunschannahme ist gerade pausiert.</div>
<div id="hinweis-moderation" class="banner leise" hidden>Wünsche werden vor dem Abspielen kurz geprüft.</div>
<section id="suche" class="karte">
<h2>Song wünschen</h2>
<label class="sr-only" for="suchfeld">Titel oder Interpret</label>
<input id="suchfeld" type="search" placeholder="Titel oder Interpret suchen …"
autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="100" enterkeyhint="search">
<label class="name-zeile">
<span>Dein Name (optional)</span>
<input id="name" type="text" maxlength="30" autocomplete="nickname" placeholder="z. B. Tante Erna">
</label>
<p id="limit-info" class="hinweis klein"></p>
<div id="turnstile" class="turnstile"></div>
<ul id="treffer" class="liste"></ul>
</section>
<section id="meine-karte" class="karte" hidden>
<h2>Deine Wünsche</h2>
<ul id="meine" class="liste kompakt"></ul>
</section>
<section class="karte">
<h2>Als Nächstes gewünscht</h2>
<p id="freigabe-info" class="hinweis klein" hidden></p>
<ul id="warteschlange" class="liste"></ul>
<p id="warteschlange-leer" class="hinweis">Noch keine Wünsche – sei die oder der Erste!</p>
</section>
<footer>Suche und Titelinfos: Spotify</footer>
</main>
<div id="toast" class="toast" role="status" aria-live="assertive" hidden></div>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<meta name="robots" content="noindex">
<title>Audiofy – Admin</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/admin.css">
<link rel="preconnect" href="https://challenges.cloudflare.com">
<script src="/static/turnstile.js" defer></script>
<script src="/static/login.js" defer></script>
</head>
<body>
<main>
<header>
<h1>Audiofy</h1>
<p class="untertitel">Admin-Bereich</p>
</header>
<form id="login" class="karte">
<h2>Anmelden</h2>
<label class="name-zeile">
<span>Passwort</span>
<input id="passwort" type="password" autocomplete="current-password" required maxlength="200">
</label>
<div id="turnstile" class="turnstile"></div>
<button class="gross akzent" type="submit">Anmelden</button>
<p id="fehler" class="hinweis fehlertext" role="alert"></p>
</form>
</main>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
// Audiofy – Admin-Login
"use strict";
let schutz = { token: async () => null, reset() {}, bereit: () => true }; // Turnstile (falls aktiv)
window.AudiofyTurnstile.einrichten(document.getElementById("turnstile"), "login").then((s) => { schutz = s; });
document.getElementById("login").addEventListener("submit", async (ereignis) => {
ereignis.preventDefault();
const fehler = document.getElementById("fehler");
const knopf = ereignis.target.querySelector("button");
fehler.textContent = "";
knopf.disabled = true;
try {
if (!schutz.bereit()) knopf.textContent = "Sicherheitsprüfung …";
const turnstile = await schutz.token();
knopf.textContent = "Anmelden …";
const antwort = await fetch("/admin/login", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: document.getElementById("passwort").value, turnstile }),
}).finally(() => schutz.reset());
if (antwort.ok) {
location.href = "/admin";
return;
}
let meldung = "Anmeldung fehlgeschlagen.";
try { meldung = (await antwort.json()).detail || meldung; } catch (_) { /* leer */ }
fehler.textContent = meldung;
} catch (_) {
fehler.textContent = "Keine Verbindung zum Server.";
}
knopf.textContent = "Anmelden";
knopf.disabled = false;
});
+73
View File
@@ -0,0 +1,73 @@
// Audiofy – Cloudflare Turnstile (Bot-Schutz). Lädt das Widget nur, wenn der Server einen Site-Key liefert.
"use strict";
window.AudiofyTurnstile = (() => {
let ladeVersprechen = null;
function skriptLaden() {
if (!ladeVersprechen) {
ladeVersprechen = new Promise((fertig, fehler) => {
const s = document.createElement("script");
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
s.async = true;
s.onload = () => fertig(window.turnstile);
s.onerror = () => fehler(new Error("Turnstile konnte nicht geladen werden"));
document.head.append(s);
});
}
return ladeVersprechen;
}
const LEER = { token: async () => null, reset() {}, bereit: () => true };
// Liefert {token(): Promise<string|null>, reset(), bereit()} – ohne Site-Key ein "leerer" Schutz.
async function einrichten(container, aktion) {
// Site-Key und Cloudflare-Skript gleichzeitig holen (spart einen Umlauf)
const skript = skriptLaden().catch(() => null);
let siteKey = null;
try {
siteKey = (await (await fetch("/api/turnstile", { credentials: "same-origin" })).json()).site_key;
} catch (_) { /* ohne Site-Key weiter */ }
if (!siteKey) return LEER;
let aktuell = null;
let warten = [];
const melden = (wert) => { aktuell = wert; warten.forEach((f) => f(wert)); warten = []; };
let ts = null;
let id = null;
try {
ts = await skript;
if (!ts) return LEER; // Skript nicht ladbar → Server entscheidet
id = ts.render(container, {
sitekey: siteKey,
action: aktion,
appearance: "interaction-only", // nur sichtbar, wenn Cloudflare eine Bestätigung braucht
language: "de",
callback: (t) => melden(t),
"expired-callback": () => { aktuell = null; },
"error-callback": () => { melden(null); },
});
} catch (_) {
return LEER; // Server entscheidet dann
}
return {
bereit: () => !!aktuell,
// wartet bis zu 15 s auf ein Token
token() {
if (aktuell) return Promise.resolve(aktuell);
return new Promise((fertig) => {
warten.push(fertig);
setTimeout(() => fertig(null), 15000);
});
},
// jedes Token gilt nur einmal → nach jeder Anfrage ein neues holen
reset() {
aktuell = null;
try { ts.reset(id); } catch (_) { /* egal */ }
},
};
}
return { einrichten };
})();
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<meta name="robots" content="noindex">
<title>Audiofy – Musikwünsche</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<main>
<header>
<h1>Audiofy</h1>
<p class="untertitel">Musikwünsche</p>
</header>
<section class="karte">
<h2>Nur für Gäste</h2>
<p>Musikwünsche sind nur mit Einladung möglich.</p>
<p class="hinweis">Bitte scanne den QR-Code vor Ort oder öffne den Einladungslink, den du bekommen hast.
Falls das nicht klappt, ist die Wunschannahme vielleicht gerade geschlossen.</p>
</section>
</main>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
"""Audiofy-Web – Cloudflare Turnstile: Bot-Schutz für Admin-Login und Wünsche.
Kein Domain-Umzug nötig: Der Browser holt sich bei Cloudflare ein Token, der
Server prüft es per siteverify. Ohne TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY in
der .env ist der Schutz aus (z. B. in Tests).
Ist Cloudflare nicht erreichbar, bleibt der Admin-Login gesperrt (sicher),
Wünsche gehen dagegen durch (die Party soll nicht stillstehen) – mit Logeintrag.
"""
import logging
from urllib.parse import urlsplit
import httpx
from fastapi import HTTPException, Request
from .config import konfig
log = logging.getLogger("audiofy.turnstile")
VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
def aktiv() -> bool:
k = konfig()
return bool(k.turnstile_site_key and k.turnstile_secret)
def _siteverify(daten: dict) -> dict:
r = httpx.post(VERIFY_URL, data=daten, timeout=8)
r.raise_for_status()
return r.json()
def pruefen(token: str | None, request: Request, aktion: str, bei_ausfall_erlauben: bool) -> None:
"""Wirft HTTPException, wenn das Turnstile-Token fehlt oder ungültig ist."""
if not aktiv():
return
if not token:
raise HTTPException(400, "Bitte warte kurz, bis die Sicherheitsprüfung abgeschlossen ist.")
daten = {"secret": konfig().turnstile_secret, "response": token}
if request.client:
daten["remoteip"] = request.client.host
try:
ergebnis = _siteverify(daten)
except (httpx.HTTPError, ValueError) as e:
if bei_ausfall_erlauben:
log.warning("Turnstile nicht erreichbar (%s) – %s ausnahmsweise ohne Prüfung erlaubt",
type(e).__name__, aktion)
return
log.error("Turnstile nicht erreichbar (%s) – %s abgelehnt", type(e).__name__, aktion)
raise HTTPException(503, "Die Sicherheitsprüfung ist gerade nicht erreichbar – bitte gleich nochmal versuchen.")
erwarteter_host = urlsplit(konfig().public_url).hostname
if (not ergebnis.get("success")
or (erwarteter_host and ergebnis.get("hostname") not in (None, erwarteter_host))
or (ergebnis.get("action") and ergebnis["action"] != aktion)):
log.info("Turnstile abgelehnt (%s): %s", aktion, ergebnis.get("error-codes"))
raise HTTPException(403, "Die Sicherheitsprüfung ist fehlgeschlagen – bitte nochmal versuchen.")
+5
View File
@@ -0,0 +1,5 @@
argon2-cffi==25.1.0
fastapi==0.143.3
httpx==0.28.1
segno==1.6.6
uvicorn==0.54.0
View File
+232
View File
@@ -0,0 +1,232 @@
"""Tests für den Admin-Bereich: Login, Sitzung, CSRF und alle Steuerfunktionen."""
import pytest
from argon2 import PasswordHasher
from fastapi.testclient import TestClient
from app import admin, config, db, gaeste
from app.spotify import spotify
from tests.test_gaeste import URI, FakeSpotify, sql
PASSWORT = "richtig-langes-passwort"
@pytest.fixture
def client(tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_text(f"DB_PATH={tmp_path / 'audiofy.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n"
f"PUBLIC_URL=https://musik.example.de\nADMIN_PASSWORD_HASH={PasswordHasher().hash(PASSWORT)}\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
config.konfig.cache_clear()
for name in ("suche_drossel", "wunsch_drossel", "einladung_drossel"):
monkeypatch.setattr(gaeste, name, gaeste.Drossel(1000, 60))
monkeypatch.setattr(admin, "login_drossel", gaeste.Drossel(5, 900))
from app.main import app
app.dependency_overrides[spotify] = FakeSpotify
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
config.konfig.cache_clear()
def anmelden(client) -> dict:
assert client.post("/admin/login", json={"password": PASSWORT}).status_code == 200
return {"X-CSRF-Token": client.get("/admin/api/state").json()["csrf"]}
def einstellung(key):
conn = db.verbinden()
wert = db.einstellungen(conn)[key]
conn.close()
return wert
def wunsch(status="queued", position=1.0, priority=0, guest_id=None, guest_ip=None, k="A"):
return sql("INSERT INTO wishes (track_uri, title, artist, status, priority, position, guest_id, guest_ip, created_at) "
"VALUES (?, ?, 'X', ?, ?, ?, ?, ?, ?) RETURNING id",
URI[k], f"Lied {k}", status, priority, position, guest_id, guest_ip, db.jetzt())[0]["id"]
# --- Zugang ----------------------------------------------------------------------
def test_ohne_login_gesperrt(client):
assert client.get("/admin", follow_redirects=False).headers["location"] == "/admin/login"
assert client.get("/admin/api/state").status_code == 401
assert client.post("/admin/api/command", json={"type": "skip"}).status_code == 401
def test_login_falsch_und_drossel(client):
for _ in range(5):
assert client.post("/admin/login", json={"password": "falsch"}).status_code == 401
assert client.post("/admin/login", json={"password": PASSWORT}).status_code == 429 # 6. Versuch
def test_login_cookie_und_seite(client):
r = client.post("/admin/login", json={"password": PASSWORT})
cookie = r.headers["set-cookie"].lower()
assert "httponly" in cookie and "samesite=strict" in cookie
assert client.get("/admin").status_code == 200
assert client.get("/admin/login", follow_redirects=False).headers["location"] == "/admin"
# in der Datenbank steht nur der Hash
wert = client.cookies.get(admin.ADMIN_COOKIE)
assert not sql("SELECT 1 FROM admin_sessions WHERE id = ?", wert)
def test_csrf_pflicht(client):
anmelden(client)
assert client.post("/admin/api/command", json={"type": "skip"}).status_code == 403
assert client.post("/admin/api/command", json={"type": "skip"},
headers={"X-CSRF-Token": "falsch"}).status_code == 403
def test_logout(client):
h = anmelden(client)
assert client.post("/admin/api/logout", headers=h).status_code == 200
assert client.get("/admin/api/state").status_code == 401
def test_kein_passwort_gesetzt(client, tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_text(env.read_text().replace("ADMIN_PASSWORD_HASH", "#"))
config.konfig.cache_clear()
assert client.post("/admin/login", json={"password": "egal"}).status_code == 503
# --- Wiedergabe ---------------------------------------------------------------------
def test_befehle_gehen_an_den_pi(client):
h = anmelden(client)
assert client.post("/admin/api/command", json={"type": "skip"}, headers=h).status_code == 200
assert client.post("/admin/api/command", json={"type": "volume", "percent": 40}, headers=h).status_code == 200
assert client.post("/admin/api/command", json={"type": "volume"}, headers=h).status_code == 422
assert client.post("/admin/api/command", json={"type": "rm -rf"}, headers=h).status_code == 422
befehle = sql("SELECT type, payload FROM commands ORDER BY id")
assert [(b["type"], b["payload"]) for b in befehle] == [("skip", "{}"), ("volume", '{"percent": 40}')]
assert [b["status"] for b in client.get("/admin/api/state").json()["commands"]] == ["wartet", "wartet"]
def test_not_aus(client):
h = anmelden(client)
client.post("/admin/api/emergency", json={"stop": True}, headers=h)
assert einstellung("emergency_stop") is True
client.post("/admin/api/emergency", json={"stop": False}, headers=h)
assert einstellung("emergency_stop") is False
assert [r["type"] for r in sql("SELECT type FROM commands ORDER BY id")] == ["pause", "play"]
def test_als_naechstes_vor_allen_wuenschen(client):
h = anmelden(client)
wunsch(position=1, k="A")
assert client.post("/admin/api/play-next", json={"track_uri": URI["B"]}, headers=h).status_code == 200
queue = client.get("/admin/api/state").json()["queue"]
assert [(w["title"], w["priority"]) for w in queue] == [("Lied B", 1), ("Lied A", 0)]
# --- Wünsche ------------------------------------------------------------------------
def test_freigabe_ablehnen_loeschen(client):
h = anmelden(client)
a, b, c = wunsch("pending", k="A"), wunsch("pending", k="B"), wunsch("queued", k="C")
client.post(f"/admin/api/wish/{a}/approve", headers=h)
client.post(f"/admin/api/wish/{b}/reject", headers=h)
client.post(f"/admin/api/wish/{c}/delete", headers=h)
status = {r["id"]: r["status"] for r in sql("SELECT id, status FROM wishes")}
assert status == {a: "queued", b: "rejected", c: "deleted"}
assert client.post("/admin/api/wish/999/approve", headers=h).status_code == 404
def test_umsortieren(client):
h = anmelden(client)
a, b, c = wunsch(position=1, k="A"), wunsch(position=2, k="B"), wunsch(position=3, k="C")
client.post(f"/admin/api/wish/{c}/up", headers=h)
client.post(f"/admin/api/wish/{a}/down", headers=h)
reihenfolge = [w["id"] for w in client.get("/admin/api/state").json()["queue"]]
assert reihenfolge == [c, a, b]
client.post(f"/admin/api/wish/{c}/up", headers=h) # ganz oben – bleibt
assert [w["id"] for w in client.get("/admin/api/state").json()["queue"]] == [c, a, b]
def test_gast_sperren(client):
h = anmelden(client)
a = wunsch(guest_id="gast1", guest_ip="hash1", k="A")
b = wunsch(guest_id="gast1", guest_ip="hash1", k="B", position=2)
andere = wunsch(guest_id="gast2", guest_ip="hash2", k="C", position=3)
client.post(f"/admin/api/wish/{a}/block-guest", headers=h)
sperren = {(r["kind"], r["value"]) for r in sql("SELECT kind, value FROM blocklist")}
assert sperren == {("guest", "gast1"), ("ip", "hash1")}
status = {r["id"]: r["status"] for r in sql("SELECT id, status FROM wishes")}
assert status == {a: "deleted", b: "deleted", andere: "queued"}
# --- Sperrliste & Einstellungen -------------------------------------------------------
def test_sperrliste(client):
h = anmelden(client)
assert client.post("/admin/api/blocklist", headers=h,
json={"kind": "artist", "value": "spotify:artist:" + "Z" * 22, "label": "Band Z"}).status_code == 200
assert client.post("/admin/api/blocklist", headers=h,
json={"kind": "track", "value": "spotify:artist:" + "Z" * 22, "label": "x"}).status_code == 422
eintraege = client.get("/admin/api/blocklist").json()["items"]
assert [(e["kind"], e["label"]) for e in eintraege] == [("artist", "Band Z")]
client.post(f"/admin/api/blocklist/{eintraege[0]['id']}/delete", headers=h)
assert client.get("/admin/api/blocklist").json()["items"] == []
def test_einstellungen_validiert(client):
h = anmelden(client)
assert client.post("/admin/api/settings", json={"wish_every_n": 4, "moderation": True}, headers=h).status_code == 200
assert (einstellung("wish_every_n"), einstellung("moderation")) == (4, True)
for falsch in ({"wish_every_n": 0}, {"wish_every_n": "4"}, {"moderation": 1}, {"access_token": "x"},
{"emergency_stop": True}):
assert client.post("/admin/api/settings", json=falsch, headers=h).status_code == 422, falsch
def test_playlist_wechseln(client):
h = anmelden(client)
link = "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc"
r = client.post("/admin/api/playlist", json={"link": link}, headers=h)
assert r.json()["playlist_id"] == "37i9dQZF1DXcBWIGoYBM5M"
assert einstellung("reshuffle_token") == 1
assert client.post("/admin/api/playlist", json={"link": "quatsch"}, headers=h).status_code == 422
client.post("/admin/api/reshuffle", headers=h)
assert einstellung("reshuffle_token") == 2
# --- Gastzugang -----------------------------------------------------------------------
def test_gastzugang_und_qr(client):
h = anmelden(client)
z = client.get("/admin/api/access").json()
assert z["enabled"] is False and z["link"].startswith("https://musik.example.de/z/")
client.post("/admin/api/access", json={"enabled": True}, headers=h)
assert einstellung("access_enabled") is True
alt = z["link"]
client.post("/admin/api/access/renew", headers=h)
assert client.get("/admin/api/access").json()["link"] != alt
svg = client.get("/admin/api/access/qr")
assert svg.headers["content-type"].startswith("image/svg+xml") and b"<svg" in svg.content
png = client.get("/admin/api/access/qr?format=png")
assert png.content[:8] == b"\x89PNG\r\n\x1a\n" and "attachment" in png.headers["content-disposition"]
def test_verlauf(client):
anmelden(client)
w = wunsch(k="A")
sql("UPDATE wishes SET guest_name = 'Erna' WHERE id = ?", w)
sql("INSERT INTO history (track_uri, title, artist, source, wish_id, started_at) VALUES (?, 'Lied A', 'X', 'wish', ?, ?)",
URI["A"], w, db.jetzt())
eintrag = client.get("/admin/api/history").json()["items"][0]
assert (eintrag["title"], eintrag["source"], eintrag["guest_name"]) == ("Lied A", "wish", "Erna")
def test_lautsprecher_befehle(client):
h = anmelden(client)
assert client.post("/admin/api/command", json={"type": "bt_scan"}, headers=h).status_code == 200
assert client.post("/admin/api/command", json={"type": "bt_connect"}, headers=h).status_code == 422
assert client.post("/admin/api/command", json={"type": "bt_connect", "mac": "falsch"}, headers=h).status_code == 422
assert client.post("/admin/api/command", json={"type": "bt_connect", "mac": "AA:BB:CC:DD:EE:FF"},
headers=h).status_code == 200
befehle = sql("SELECT type, payload FROM commands ORDER BY id")
assert [(b["type"], b["payload"]) for b in befehle] == [("bt_scan", "{}"),
("bt_connect", '{"mac": "AA:BB:CC:DD:EE:FF"}')]
assert "bluetooth" in client.get("/admin/api/state").json()["player"]
+257
View File
@@ -0,0 +1,257 @@
"""Tests für die Gastseite: Einladungszugang, Suche, Wünschen mit Schutzregeln, Status."""
import pytest
from fastapi.testclient import TestClient
from app import config, db, gaeste
from app.spotify import SpotifyFehler, spotify
URI = {k: "spotify:track:" + k * 22 for k in "ABCDE"}
KUENSTLER_GESPERRT = "spotify:artist:" + "Z" * 22
def titel(k, explicit=False, kuenstler="spotify:artist:" + "X" * 22):
return {"track_uri": URI[k], "title": f"Lied {k}", "artist": f"Band {k}", "artist_uris": [kuenstler],
"album": "Album", "cover_url": "https://i.scdn.co/image/x", "duration_ms": 200_000, "explicit": explicit}
class FakeSpotify:
def __init__(self):
self.titel_db = {URI["A"]: titel("A"), URI["B"]: titel("B"), URI["C"]: titel("C", explicit=True),
URI["D"]: titel("D", kuenstler=KUENSTLER_GESPERRT), URI["E"]: titel("E")}
def verfuegbar(self):
return True
def suche(self, q):
return list(self.titel_db.values())
def titel(self, uri):
if uri not in self.titel_db:
raise SpotifyFehler(404)
return dict(self.titel_db[uri])
@pytest.fixture
def client(tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_text(f"DB_PATH={tmp_path / 'audiofy.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
config.konfig.cache_clear()
for name in ("suche_drossel", "wunsch_drossel", "einladung_drossel"):
monkeypatch.setattr(gaeste, name, gaeste.Drossel(1000, 60))
from app.main import app
app.dependency_overrides[spotify] = FakeSpotify
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
config.konfig.cache_clear()
def setzen(**werte):
conn = db.verbinden()
for k, v in werte.items():
db.einstellung_setzen(conn, k, v)
conn.close()
def token():
conn = db.verbinden()
t = db.einstellungen(conn)["access_token"]
conn.close()
return t
def eintreten(client):
"""Zugang einschalten und Einladungslink öffnen."""
setzen(access_enabled=True)
r = client.get(f"/z/{token()}", follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/"
return client
def sql(abfrage, *params):
conn = db.verbinden()
with conn:
zeilen = conn.execute(abfrage, params).fetchall()
conn.close()
return zeilen
# --- Einladungszugang ------------------------------------------------------
def test_ohne_einladung_kein_zugang(client):
r = client.get("/")
assert r.status_code == 403 and "Nur für Gäste" in r.text
assert client.get("/api/status").status_code == 401
assert client.get("/api/search?q=ab").status_code == 401
assert client.post("/api/wish", json={"track_uri": URI["A"]}).status_code == 401
def test_token_vorhanden_und_zugang_anfangs_aus(client):
assert len(token()) >= 12
assert client.get(f"/z/{token()}", follow_redirects=False).status_code == 403 # access_enabled = False
def test_einladung_gibt_zugang(client):
eintreten(client)
r = client.get("/")
assert r.status_code == 200 and "Song wünschen" in r.text
assert client.get("/api/status").status_code == 200
def test_falscher_link(client):
setzen(access_enabled=True)
assert client.get("/z/falsch", follow_redirects=False).status_code == 403
assert client.get("/api/status").status_code == 401
def test_zugang_aus_sperrt_alle(client):
eintreten(client)
setzen(access_enabled=False)
assert client.get("/api/status").status_code == 401
assert client.get("/").status_code == 403
def test_neuer_link_macht_alte_zugaenge_ungueltig(client):
eintreten(client)
alt = token()
setzen(access_token=db.neuer_zugangs_token())
assert client.get("/api/status").status_code == 401
assert client.get(f"/z/{alt}", follow_redirects=False).status_code == 403
# --- Suche -------------------------------------------------------------------
def test_suche_markiert_gesperrte(client):
eintreten(client)
setzen(block_explicit=True)
sql("INSERT INTO blocklist (kind, value, created_at) VALUES ('artist', ?, 'x')", KUENSTLER_GESPERRT)
client.post("/api/wish", json={"track_uri": URI["B"]})
treffer = {t["track_uri"][-1]: t["blocked"] for t in client.get("/api/search?q=lied").json()["results"]}
assert treffer["A"] is None
assert "Wunschliste" in treffer["B"]
assert "explizit" in treffer["C"]
assert "nicht gewünscht" in treffer["D"]
assert "artist_uris" not in client.get("/api/search?q=lied").json()["results"][0]
def test_suche_mindestlaenge(client):
eintreten(client)
assert client.get("/api/search?q=a").status_code == 422
# --- Wünschen ----------------------------------------------------------------
def test_wunsch_landet_in_warteschlange(client):
eintreten(client)
r = client.post("/api/wish", json={"track_uri": URI["A"], "name": " Tante Erna "})
assert r.status_code == 200 and r.json()["status"] == "queued"
w = sql("SELECT title, guest_name, guest_ip, guest_id FROM wishes")[0]
assert (w["title"], w["guest_name"]) == ("Lied A", "Tante Erna")
assert w["guest_ip"] != "testclient" and len(w["guest_ip"]) == 24 # nur Hash
s = client.get("/api/status").json()
assert [q["title"] for q in s["queue"]] == ["Lied A"] and s["queue"][0]["mine"] is True
assert s["mine"][0]["status"] == "queued" and s["limit"]["remaining"] == 1
def test_titelinfos_kommen_vom_server(client):
eintreten(client)
client.post("/api/wish", json={"track_uri": URI["A"], "title": "Gefälscht"})
assert sql("SELECT title FROM wishes")[0]["title"] == "Lied A"
def test_moderation(client):
eintreten(client)
setzen(moderation=True)
r = client.post("/api/wish", json={"track_uri": URI["A"]})
assert r.json()["status"] == "pending"
s = client.get("/api/status").json()
assert s["queue"] == [] and s["pending_count"] == 1
def test_pausiert(client):
eintreten(client)
setzen(wishes_paused=True)
r = client.post("/api/wish", json={"track_uri": URI["A"]})
assert r.status_code == 403 and "pausiert" in r.json()["detail"]
def test_limit_pro_gast(client):
eintreten(client)
assert client.post("/api/wish", json={"track_uri": URI["A"]}).status_code == 200
assert client.post("/api/wish", json={"track_uri": URI["B"]}).status_code == 200
r = client.post("/api/wish", json={"track_uri": URI["E"]})
assert r.status_code == 429 and "Limit" in r.json()["detail"]
def test_keine_duplikate(client):
eintreten(client)
setzen(wish_limit_count=10)
client.post("/api/wish", json={"track_uri": URI["A"]})
r = client.post("/api/wish", json={"track_uri": URI["A"]})
assert r.status_code == 409 and "Wunschliste" in r.json()["detail"]
def test_kuerzlich_gespielt_und_laeuft_gerade(client):
eintreten(client)
setzen(wish_limit_count=10)
sql("INSERT INTO history (track_uri, title, artist, started_at) VALUES (?, 'x', 'y', ?)", URI["A"], db.jetzt())
sql("UPDATE pi_state SET track_uri = ? WHERE id = 1", URI["B"])
assert client.post("/api/wish", json={"track_uri": URI["A"]}).status_code == 409
assert client.post("/api/wish", json={"track_uri": URI["B"]}).status_code == 409
alt = "2020-01-01T00:00:00+00:00" # außerhalb der Sperrzeit → wieder erlaubt
sql("UPDATE history SET started_at = ?", alt)
assert client.post("/api/wish", json={"track_uri": URI["A"]}).status_code == 200
def test_explizit_und_sperrliste(client):
eintreten(client)
setzen(block_explicit=True, wish_limit_count=10)
assert client.post("/api/wish", json={"track_uri": URI["C"]}).status_code == 409
sql("INSERT INTO blocklist (kind, value, created_at) VALUES ('artist', ?, 'x')", KUENSTLER_GESPERRT)
assert client.post("/api/wish", json={"track_uri": URI["D"]}).status_code == 409
sql("INSERT INTO blocklist (kind, value, created_at) VALUES ('track', ?, 'x')", URI["E"])
assert client.post("/api/wish", json={"track_uri": URI["E"]}).status_code == 409
def test_gesperrter_gast(client):
eintreten(client)
gid = client.cookies.get(gaeste.GAST_COOKIE)
sql("INSERT INTO blocklist (kind, value, created_at) VALUES ('guest', ?, 'x')", gid)
r = client.post("/api/wish", json={"track_uri": URI["A"]})
assert r.status_code == 403 and "Gerät" in r.json()["detail"]
def test_ungueltige_eingaben(client):
eintreten(client)
assert client.post("/api/wish", json={"track_uri": "spotify:album:x"}).status_code == 422
assert client.post("/api/wish", json={"track_uri": URI["A"], "name": "x" * 31}).status_code == 422
unbekannt = "spotify:track:" + "Q" * 22
assert client.post("/api/wish", json={"track_uri": unbekannt}).status_code == 404
def test_fremde_herkunft(client):
eintreten(client)
r = client.post("/api/wish", json={"track_uri": URI["A"]}, headers={"Origin": "https://boese.example"})
assert r.status_code == 403
# --- Status ------------------------------------------------------------------
def test_laeuft_gerade_und_offline(client):
eintreten(client)
assert client.get("/api/status").json()["now_playing"] is None
sql("UPDATE pi_state SET payload = ?, updated_at = ?, last_seen = ? WHERE id = 1",
'{"track": {"track_uri": "%s", "title": "Lied A", "artist": "Band"}, "is_playing": true, '
'"progress_ms": 5000, "source": "wish"}' % URI["A"], db.jetzt(), db.jetzt())
jetzt = client.get("/api/status").json()["now_playing"]
assert jetzt["title"] == "Lied A" and jetzt["is_wish"] is True
sql("UPDATE pi_state SET last_seen = '2020-01-01T00:00:00+00:00'") # Player seit Langem still → offline
s = client.get("/api/status").json()
assert s["online"] is False and s["now_playing"] is None
def test_sicherheits_header(client):
r = client.get("/")
assert "default-src 'self'" in r.headers["content-security-policy"]
+164
View File
@@ -0,0 +1,164 @@
"""Tests für die Player-API (Wegwerf-Datenbank pro Test)."""
import pytest
from fastapi.testclient import TestClient
from app import config, db
TOKEN = "test-token-0123456789"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
URI_A = "spotify:track:" + "A" * 22
URI_B = "spotify:track:" + "B" * 22
URI_C = "spotify:track:" + "C" * 22
@pytest.fixture
def client(tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_text(f"PLAYER_API_TOKEN={TOKEN}\nDB_PATH={tmp_path / 'audiofy.db'}\nSPOTIFY_PLAYLIST_ID=startliste\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
config.konfig.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
config.konfig.cache_clear()
def wunsch(uri, status="queued", position=1.0, priority=0, title="Titel"):
conn = db.verbinden()
with conn:
cur = conn.execute("INSERT INTO wishes (track_uri, title, artist, status, priority, position, created_at) "
"VALUES (?, ?, 'Interpret', ?, ?, ?, ?)", (uri, title, status, priority, position, db.jetzt()))
conn.close()
return cur.lastrowid
def test_token_pflicht(client):
assert client.get("/api/player/config").status_code == 401
assert client.get("/api/player/config", headers={"Authorization": "Bearer falsch"}).status_code == 401
assert client.get("/api/player/config", headers=AUTH).status_code == 200
def test_config_standardwerte(client):
daten = client.get("/api/player/config", headers=AUTH).json()
assert daten == {"wish_every_n": 3, "playlist_id": "startliste", "reshuffle_token": 0,
"stopped": False, "commands": []}
def test_next_wish_leer_und_reihenfolge(client):
assert client.get("/api/player/next-wish", headers=AUTH).json() == {"wish": None}
wunsch(URI_A, position=2)
b = wunsch(URI_B, position=1)
wunsch(URI_C, status="pending", position=0) # zur Freigabe – zählt nicht
assert client.get("/api/player/next-wish", headers=AUTH).json()["wish"]["id"] == b
besitzer = wunsch(URI_C, position=9, priority=1) # "Als Nächstes" schlägt alles
w = client.get("/api/player/next-wish", headers=AUTH).json()["wish"]
assert w["id"] == besitzer and w["priority"] is True
def test_wunsch_gespielt_idempotent(client):
a = wunsch(URI_A)
assert client.post(f"/api/player/wish/{a}/played", headers=AUTH).json() == {"ok": True, "status": "played"}
assert client.post(f"/api/player/wish/{a}/played", headers=AUTH).json() == {"ok": True, "status": "played"}
assert client.get("/api/player/next-wish", headers=AUTH).json() == {"wish": None}
assert client.post("/api/player/wish/9999/played", headers=AUTH).status_code == 404
def test_now_playing_schreibt_verlauf_nur_bei_wechsel(client):
a = wunsch(URI_A)
titel = {"track_uri": URI_A, "title": "Lied A", "artist": "Band"}
for _ in range(3): # wiederholte Meldungen desselben Titels → ein Verlaufseintrag
r = client.post("/api/player/now-playing", headers=AUTH,
json={"track": titel, "is_playing": True, "source": "wish", "wish_id": a})
assert r.status_code == 200
client.post("/api/player/now-playing", headers=AUTH, json={"track": titel, "is_playing": True, "new_track": True})
client.post("/api/player/now-playing", headers=AUTH,
json={"track": {"track_uri": URI_B, "title": "Lied B", "artist": "X"}, "is_playing": True})
conn = db.verbinden()
verlauf = conn.execute("SELECT track_uri, source, wish_id FROM history ORDER BY id").fetchall()
conn.close()
assert [(v["track_uri"], v["source"], v["wish_id"]) for v in verlauf] == [
(URI_A, "wish", a), (URI_A, None, None), (URI_B, None, None)]
def test_pausierter_titel_nicht_im_verlauf(client):
titel = {"track_uri": URI_A, "title": "Lied A", "artist": "Band"}
client.post("/api/player/now-playing", headers=AUTH, json={"track": titel, "is_playing": False})
conn = db.verbinden()
assert conn.execute("SELECT COUNT(*) FROM history").fetchone()[0] == 0
conn.close()
client.post("/api/player/now-playing", headers=AUTH, json={"track": titel, "is_playing": True})
conn = db.verbinden()
assert conn.execute("SELECT COUNT(*) FROM history").fetchone()[0] == 1
conn.close()
def test_now_playing_validiert(client):
r = client.post("/api/player/now-playing", headers=AUTH,
json={"track": {"track_uri": "kaputt", "title": "x", "artist": "y"}})
assert r.status_code == 422
def test_befehle_abholen_quittieren_verfallen(client):
conn = db.verbinden()
skip = db.befehl_anlegen(conn, "skip")
vol = db.befehl_anlegen(conn, "volume", {"percent": 40})
with conn: # ein uralter Befehl darf nicht mehr ausgeliefert werden
conn.execute("INSERT INTO commands (type, created_at) VALUES ('skip', '2020-01-01T00:00:00+00:00')")
conn.close()
befehle = client.get("/api/player/config", headers=AUTH).json()["commands"]
assert [(b["id"], b["type"], b["payload"]) for b in befehle] == [(skip, "skip", {}), (vol, "volume", {"percent": 40})]
assert client.post(f"/api/player/commands/{skip}/ack", headers=AUTH, json={"ok": True}).status_code == 200
assert [b["id"] for b in client.get("/api/player/config", headers=AUTH).json()["commands"]] == [vol]
assert client.post("/api/player/commands/9999/ack", headers=AUTH, json={}).status_code == 404
def test_not_aus(client):
wunsch(URI_A)
conn = db.verbinden()
db.einstellung_setzen(conn, "emergency_stop", True)
conn.close()
assert client.get("/api/player/config", headers=AUTH).json()["stopped"] is True
assert client.get("/api/player/next-wish", headers=AUTH).json() == {"wish": None}
def test_lebenszeichen(client):
client.get("/api/player/config", headers=AUTH)
conn = db.verbinden()
assert conn.execute("SELECT last_seen FROM pi_state").fetchone()["last_seen"] is not None
conn.close()
def test_bluetooth_melden(client):
daten = {"current": {"mac": "AA:11:22:33:44:55", "name": "Box", "paired": True, "connected": True},
"devices": [{"mac": "AA:BB:CC:DD:EE:FF", "name": "Neu"}], "scanning": False,
"scanned_at": "2026-10-17T18:00:00+00:00"}
assert client.post("/api/player/bluetooth", headers=AUTH, json=daten).status_code == 200
from app.player_api import player_zustand
conn = db.verbinden()
bt = player_zustand(conn)["bluetooth"]
conn.close()
assert bt["current"]["name"] == "Box" and bt["devices"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
falsch = {"devices": [{"mac": "kein-mac"}]}
assert client.post("/api/player/bluetooth", headers=AUTH, json=falsch).status_code == 422
assert client.post("/api/player/bluetooth", json=daten).status_code == 401
def test_alte_datenbank_wird_nachgeruestet(tmp_path):
import sqlite3
pfad = tmp_path / "alt.db"
alt = sqlite3.connect(pfad)
alt.execute("CREATE TABLE pi_state (id INTEGER PRIMARY KEY CHECK (id = 1), track_uri TEXT, "
"payload TEXT NOT NULL DEFAULT '{}', updated_at TEXT, last_seen TEXT)")
alt.commit()
alt.close()
db.initialisieren(pfad)
conn = sqlite3.connect(pfad)
assert "bluetooth" in {r[1] for r in conn.execute("PRAGMA table_info(pi_state)")}
conn.close()
def test_alter_pfad_api_pi_bleibt_nutzbar(client):
"""Ältere Player sprechen noch /api/pi/… an."""
assert client.get("/api/pi/config", headers=AUTH).status_code == 200
assert client.get("/api/pi/config").status_code == 401
+122
View File
@@ -0,0 +1,122 @@
"""Tests für Cloudflare Turnstile (siteverify wird durch einen Platzhalter ersetzt)."""
import httpx
import pytest
from argon2 import PasswordHasher
from fastapi.testclient import TestClient
from app import admin, config, gaeste, turnstile
from app.spotify import spotify
from tests.test_gaeste import URI, FakeSpotify, eintreten
PASSWORT = "richtig-langes-passwort"
@pytest.fixture
def antworten(monkeypatch):
"""Steuert, was siteverify antwortet; merkt sich die Anfragen."""
zustand = {"ergebnis": {"success": True, "hostname": "musik.example.de"}, "anfragen": []}
def siteverify(daten):
zustand["anfragen"].append(daten)
if isinstance(zustand["ergebnis"], Exception):
raise zustand["ergebnis"]
return zustand["ergebnis"]
monkeypatch.setattr(turnstile, "_siteverify", siteverify)
return zustand
@pytest.fixture
def client(tmp_path, monkeypatch, antworten):
env = tmp_path / ".env"
env.write_text(f"DB_PATH={tmp_path / 'audiofy.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n"
f"PUBLIC_URL=https://musik.example.de\nADMIN_PASSWORD_HASH={PasswordHasher().hash(PASSWORT)}\n"
"TURNSTILE_SITE_KEY=site-oeffentlich\nTURNSTILE_SECRET_KEY=secret-geheim\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
config.konfig.cache_clear()
for name in ("suche_drossel", "wunsch_drossel", "einladung_drossel"):
monkeypatch.setattr(gaeste, name, gaeste.Drossel(1000, 60))
monkeypatch.setattr(admin, "login_drossel", gaeste.Drossel(1000, 900))
from app.main import app
app.dependency_overrides[spotify] = FakeSpotify
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
config.konfig.cache_clear()
def test_site_key_oeffentlich(client):
assert client.get("/api/turnstile").json() == {"site_key": "site-oeffentlich"}
def test_wunsch_ohne_token_abgelehnt(client):
eintreten(client)
r = client.post("/api/wish", json={"track_uri": URI["A"]})
assert r.status_code == 400 and "Sicherheitsprüfung" in r.json()["detail"]
def test_wunsch_mit_gueltigem_token(client, antworten):
eintreten(client)
assert client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "tok"}).status_code == 200
anfrage = antworten["anfragen"][0]
assert anfrage["secret"] == "secret-geheim" and anfrage["response"] == "tok"
def test_wunsch_ungueltiges_token(client, antworten):
eintreten(client)
antworten["ergebnis"] = {"success": False, "error-codes": ["invalid-input-response"]}
assert client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "tok"}).status_code == 403
def test_falscher_hostname(client, antworten):
eintreten(client)
antworten["ergebnis"] = {"success": True, "hostname": "boese.example"}
assert client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "tok"}).status_code == 403
def test_falsche_aktion(client, antworten):
"""Ein Token vom Login-Formular darf keinen Wunsch abschicken (und umgekehrt)."""
eintreten(client)
antworten["ergebnis"] = {"success": True, "hostname": "musik.example.de", "action": "login"}
assert client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "tok"}).status_code == 403
def test_wunsch_bei_cloudflare_ausfall_erlaubt(client, antworten):
eintreten(client)
antworten["ergebnis"] = httpx.ConnectError("weg")
assert client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "tok"}).status_code == 200
def test_limit_greift_vor_turnstile(client, antworten):
"""Abgewiesene Anfragen (z. B. Limit) kosten keine Turnstile-Prüfung."""
eintreten(client)
client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "t1"})
client.post("/api/wish", json={"track_uri": URI["B"], "turnstile": "t2"})
antworten["anfragen"].clear()
assert client.post("/api/wish", json={"track_uri": URI["E"], "turnstile": "t3"}).status_code == 429
assert antworten["anfragen"] == []
def test_login_ohne_token(client):
assert client.post("/admin/login", json={"password": PASSWORT}).status_code == 400
def test_login_mit_token(client):
assert client.post("/admin/login", json={"password": PASSWORT, "turnstile": "tok"}).status_code == 200
def test_login_ungueltiges_token(client, antworten):
antworten["ergebnis"] = {"success": False}
assert client.post("/admin/login", json={"password": PASSWORT, "turnstile": "tok"}).status_code == 403
def test_login_bei_cloudflare_ausfall_gesperrt(client, antworten):
antworten["ergebnis"] = httpx.ConnectError("weg")
assert client.post("/admin/login", json={"password": PASSWORT, "turnstile": "tok"}).status_code == 503
def test_csp_erlaubt_turnstile(client):
csp = client.get("/admin/login").headers["content-security-policy"]
assert "script-src 'self' https://challenges.cloudflare.com" in csp
assert "frame-src https://challenges.cloudflare.com" in csp
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Audiofy-Web – Verwaltung auf der Kommandozeile.
geheimnisse SESSION_SECRET und PLAYER_API_TOKEN erzeugen (nur falls noch nicht vorhanden)
passwort Admin-Passwort setzen (verdeckte Eingabe, gespeichert wird nur der argon2-Hash)
turnstile Cloudflare-Turnstile-Schlüssel setzen (Secret-Key verdeckt)
Auf dem Server als Dienst-Benutzer ausführen, damit die .env ihm gehört:
sudo -u audiofy /opt/audiofy/.venv/bin/python /opt/audiofy/app/verwaltung.py passwort
"""
import argparse
import getpass
import os
import secrets
import sys
from pathlib import Path
from argon2 import PasswordHasher
sys.path.insert(0, str(Path(__file__).resolve().parent))
from app.config import env_lesen, env_wert_setzen # noqa: E402
MIN_LAENGE = 12
def geheimnisse(env: Path) -> None:
werte = env_lesen(env) if env.is_file() else {}
if werte.get("PI_API_TOKEN") and not werte.get("PLAYER_API_TOKEN"):
# alten Namen übernehmen, damit ein bereits eingerichteter Player weiterläuft
env_wert_setzen(env, "PLAYER_API_TOKEN", werte["PI_API_TOKEN"])
werte["PLAYER_API_TOKEN"] = werte["PI_API_TOKEN"]
print("PLAYER_API_TOKEN: aus PI_API_TOKEN übernommen")
for key in ("SESSION_SECRET", "PLAYER_API_TOKEN"):
if werte.get(key):
print(f"{key}: schon vorhanden – unverändert")
else:
env_wert_setzen(env, key, secrets.token_urlsafe(48))
print(f"{key}: neu erzeugt")
def passwort(env: Path) -> None:
erstes = getpass.getpass("Neues Admin-Passwort: ")
if len(erstes) < MIN_LAENGE:
sys.exit(f"Zu kurz – mindestens {MIN_LAENGE} Zeichen.")
if getpass.getpass("Wiederholen: ") != erstes:
sys.exit("Die Eingaben stimmen nicht überein.")
env_wert_setzen(env, "ADMIN_PASSWORD_HASH", PasswordHasher().hash(erstes))
print(f"Passwort-Hash in {env} gespeichert. Bitte den Dienst neu starten (audiofy-web.service).")
def turnstile(env: Path) -> None:
site_key = input("Turnstile Site-Key (öffentlich): ").strip()
secret = getpass.getpass("Turnstile Secret-Key (verdeckt): ").strip()
if not site_key or not secret:
sys.exit("Beide Schlüssel sind nötig.")
env_wert_setzen(env, "TURNSTILE_SITE_KEY", site_key)
env_wert_setzen(env, "TURNSTILE_SECRET_KEY", secret)
print(f"Turnstile-Schlüssel in {env} gespeichert. Bitte den Dienst neu starten (audiofy-web.service).")
def main() -> None:
parser = argparse.ArgumentParser(description="Audiofy-Web – Verwaltung")
parser.add_argument("aktion", choices=("geheimnisse", "passwort", "turnstile"))
parser.add_argument("--env", type=Path, default=Path(os.environ.get("AUDIOFY_ENV", "/opt/audiofy/.env")))
args = parser.parse_args()
{"geheimnisse": geheimnisse, "passwort": passwort, "turnstile": turnstile}[args.aktion](args.env)
if __name__ == "__main__":
main()