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 @@
|
||||
"""Audiofy-Web – Website, Admin-Bereich und Player-API (läuft auf dem Server)."""
|
||||
@@ -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)
|
||||
@@ -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", ""),
|
||||
)
|
||||
@@ -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(),))
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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"]}
|
||||
@@ -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())
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user