Audiofy – Musikwünsche für Partys
Server (Website, Admin-Bereich, Player-API) und Player (raspotify + Controller) mit Install-Skripten für Debian-Systeme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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},
|
||||
}
|
||||
Reference in New Issue
Block a user