Audiofy – Musikwünsche für Partys

Server (Website, Admin-Bereich, Player-API) und Player (raspotify +
Controller) mit Install-Skripten für Debian-Systeme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 19:16:39 +01:00
co-authored by Claude Opus 5.5
commit 3610de7bae
45 changed files with 5666 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Geheimnisse – nie einchecken
.env
.env.tmp
*.env
zustand.json*
# Laufzeit / Umgebung
__pycache__/
*.pyc
.venv/
.pytest_cache/
*.db
*.db-*
# Persönliches, nur lokal
CLAUDE.md
lokal/
+163
View File
@@ -0,0 +1,163 @@
# Audiofy – Musikwünsche für Partys
Gäste scannen einen QR-Code und wünschen sich Songs. Ein **Player** spielt über Spotify
zufällige Titel aus einer Grund-Playlist – jeder N-te Titel ist ein Wunsch. Gibt es keine
Wünsche, läuft einfach die Playlist weiter. Der Gastgeber steuert alles über einen
Admin-Bereich, auch vom Handy.
- **Gäste:** Spotify-Suche, Wünschen (optional mit Namen), „Läuft gerade“, Warteschlange – ohne Spotify-Konto
- **Schutz:** Zugang nur per Einladungslink/QR-Code, Limit pro Gast, keine Duplikate, Sperrzeit für Wiederholungen, Sperrliste, optional Moderation und Cloudflare Turnstile
- **Admin:** Play/Pause/Weiter/Lautstärke, „Als Nächstes spielen“, Not-Aus, Wünsche freigeben/umsortieren/löschen, Gäste sperren, Playlist wechseln, Bluetooth-Lautsprecher suchen und wechseln, Verlauf
- **Robust:** Ohne Server spielt der Player die Playlist weiter; Bedienung über die normale Spotify-App bleibt möglich; Pause des Gastgebers wird auch über Neustarts respektiert
## Aufbau
```
Gast-Handy ──HTTPS──▶ Server: Caddy → audiofy-web (FastAPI + SQLite)
▲ Player-API (Bearer-Token)
│ nur ausgehend vom Player
Player: raspotify (Spotify Connect) + audiofy-controller
└─▶ Bluetooth-Lautsprecher (BlueALSA) oder Kabel/HDMI/USB (ALSA)
```
| Rolle | Läuft auf | Braucht |
|---|---|---|
| **Server** | VPS, Heimserver, VM … | Debian 12+, öffentliche (Sub-)Domain, Ports 80/443 |
| **Player** | Raspberry Pi, Mini-PC, VM mit Audio … | Debian 12+ (arm64/armhf/amd64), Lautsprecher, Internet |
Beide Rollen können auch auf demselben Gerät laufen. Der Player braucht **keine**
Portfreigabe am Router.
## Voraussetzungen
1. **Spotify Premium** für das Konto, das abspielt.
2. **Spotify-App** auf <https://developer.spotify.com/dashboard>:
- App anlegen (Web API), Redirect-URI **`http://127.0.0.1:8888/callback`** eintragen
- unter „User Management“ das eigene Spotify-Konto eintragen
- Client ID und Client Secret bereithalten
3. Eine **eigene Playlist** als Grund-Playlist (im Development Mode lassen sich nur eigene Playlists lesen).
4. Für den Server eine **Domain**, deren A/AAAA-Record auf den Server zeigt.
## Installation
Repository auf das jeweilige Gerät holen (`git clone …`) und als Benutzer mit sudo-Rechten
(oder als root) ausführen.
**1. Server**
```
install/install-server.sh
```
Fragt Domain, E-Mail für Let's Encrypt, Spotify-Zugang und Playlist ab, richtet Dienst-Benutzer,
Python-Umgebung, systemd-Dienst und Caddy (HTTPS) ein und optional Firewall (ufw), fail2ban und
automatische Sicherheitsupdates. Danach folgen der Spotify-Login (nur für die Suche) und das
Admin-Passwort.
Am Ende zeigt das Skript, wie du den **`PLAYER_API_TOKEN`** für den Player abrufst.
**2. Player**
```
install/install-player.sh
```
Fragt Gerätename, Audio-Ausgabe (Bluetooth oder ALSA), Server-Adresse, `PLAYER_API_TOKEN`,
Spotify-Zugang und Playlist ab, installiert raspotify, koppelt auf Wunsch gleich den
Bluetooth-Lautsprecher und richtet den Controller als Dienst ein. Danach folgt der
Spotify-Login für die Wiedergabe-Steuerung.
**3. Einmalig in der Spotify-App** das neue Gerät (Standard: „Audiofy“) auswählen – im selben
Netz wie der Player. Damit speichert raspotify seine Anmeldung und ist nach jedem Neustart
von selbst wieder da.
**4. Admin-Bereich** unter `https://<domain>/admin` öffnen, unter **Gäste** den Gastzugang
einschalten und den QR-Code zeigen oder ausdrucken.
## Betrieb
| Aufgabe | Befehl |
|---|---|
| Status | `systemctl status audiofy-web` (Server) · `systemctl status audiofy-controller raspotify` (Player) |
| Logs live | `journalctl -u audiofy-web -f` · `journalctl -u audiofy-controller -f` |
| Neustart | `sudo systemctl restart audiofy-web` bzw. `audiofy-controller` |
| Update | `git pull && install/install-server.sh --update` bzw. `install/install-player.sh --update` |
`--update` ersetzt nur Code, Python-Umgebung und Dienstdatei; Konfiguration, Datenbank,
Caddy, Firewall und raspotify bleiben unverändert.
**Dateien**
| Pfad | Inhalt |
|---|---|
| `/opt/audiofy/app` | Code (gehört root, der Dienst darf nur lesen) |
| `/opt/audiofy/.env` (Server) | Geheimnisse: Spotify, Admin-Passwort-Hash, Turnstile, `PLAYER_API_TOKEN` |
| `/opt/audiofy/data/audiofy.db` (Server) | Wünsche, Verlauf, Einstellungen |
| `/opt/audiofy/config/.env` (Player) | Geheimnisse: Spotify, `SERVER_URL`, `PLAYER_API_TOKEN`, Lautsprecher |
Die `.env`-Dateien gehören dem Dienst-Benutzer `audiofy` (Rechte 600). **Sichere sie an einem
sicheren Ort** (z. B. Passwort-Manager) – sie stehen bewusst nicht im Repository.
**Verwaltung** (interaktiv, im eigenen Terminal):
```
# Server: Admin-Passwort bzw. Turnstile-Schlüssel ändern
sudo -u audiofy /opt/audiofy/.venv/bin/python /opt/audiofy/app/verwaltung.py passwort --env /opt/audiofy/.env
sudo -u audiofy /opt/audiofy/.venv/bin/python /opt/audiofy/app/verwaltung.py turnstile --env /opt/audiofy/.env
# Spotify-Login erneuern – Server bzw. Player
sudo -u audiofy /opt/audiofy/.venv/bin/python /opt/audiofy/app/auth.py --profil server --env /opt/audiofy/.env
sudo -u audiofy /opt/audiofy/.venv/bin/python /opt/audiofy/app/auth.py --profil player --env /opt/audiofy/config/.env
```
Danach den jeweiligen Dienst neu starten.
**Datenbank sichern** (Server):
```
sudo -u audiofy sqlite3 /opt/audiofy/data/audiofy.db ".backup /opt/audiofy/data/sicherung.db"
```
## Server härten (empfohlen, von Hand)
Das Install-Skript ändert die SSH-Konfiguration bewusst **nicht** – damit sperrt man sich
leicht aus. Vorlage: `install/vorlagen/sshd-10-audiofy.conf` (anderer Port, nur Schlüssel,
kein root, nur ein Admin-Benutzer). Vorgehen:
1. Eigenen Admin-Benutzer mit sudo anlegen, SSH-Schlüssel hinterlegen, Login testen.
2. Neuen Port in ufw **und** einer eventuellen Cloud-Firewall öffnen.
3. Vorlage ausgefüllt nach `/etc/ssh/sshd_config.d/10-audiofy.conf` legen, `sudo sshd -t`, `sudo systemctl restart ssh`.
4. **In einer zweiten Sitzung** testen – die alte bleibt offen, bis es klappt.
5. Erst dann den alten Port in ufw/Cloud-Firewall schließen und fail2ban auf den neuen Port umstellen.
## Fehlerbehebung
| Problem | Ursache / Lösung |
|---|---|
| Keine Musik, Log: „Lautsprecher nicht verbunden“ | Lautsprecher einschalten – der Player verbindet ihn neu und startet die Musik von selbst. |
| Lautsprecher verbindet sich nicht mehr („Permission denied“) | Kopplung verloren. Im Admin unter Einstellungen → Lautsprecher neu suchen und verbinden (Lautsprecher im Kopplungsmodus). |
| „Gerät nicht gefunden“ | `systemctl status raspotify`. Nach einer Neuinstallation einmal in der Spotify-App das Gerät auswählen. |
| Admin: „Player offline“ | `systemctl status audiofy-controller`, Internet des Players prüfen. Die Musik läuft trotzdem mit der Playlist weiter. |
| Admin: „Grund-Playlist nicht lesbar“ | Nur eigene Playlists funktionieren (Spotify Development Mode). |
| Befehle „No active device“ | Spotify hatte kein aktives Gerät; „Play“ startet dann die Playlist auf dem Player. |
| Nach Neustart bleibt es still | Gewollt, wenn vorher pausiert oder Not-Aus aktiv war – im Admin „Play“ bzw. „Freigeben“. |
| Musik stockt während der Lautsprecher-Suche | Normal: WLAN und Bluetooth teilen sich oft einen Funkchip. |
| Gäste: „Kein Zugang“ | Gastzugang im Admin eingeschaltet? Nach „Neuen Link erzeugen“ gilt nur der neue QR-Code. |
| Zu viele Admin-Login-Versuche | 15 Minuten warten (max. 5 Versuche pro IP). |
## Spotify-Hinweise
Audiofy nutzt die Spotify Web API im **Development Mode** (nicht kommerziell). Dort gilt:
Premium für den App-Besitzer, Nutzer-Allowlist, höchstens 10 Suchtreffer, nur eigene
Playlists lesbar. Gäste melden sich nie bei Spotify an; Server und Player nutzen je einen
eigenen Refresh-Token desselben Kontos.
## Entwicklung
```
cd server && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt pytest
.venv/bin/python -m pytest -q
```
## Lizenz
Noch nicht festgelegt.
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# Audiofy – Installation der Rolle "Player": Spotify-Connect-Gerät (raspotify) und Controller.
#
# install/install-player.sh Neuinstallation (fragt alles Nötige ab)
# install/install-player.sh --update nur Code, Python-Umgebung und Dienst aktualisieren
#
# Läuft auf Raspberry Pi, Mini-PC oder VM mit Debian 12+ (arm64, armhf oder amd64).
# Ausgabe wahlweise über einen Bluetooth-Lautsprecher (BlueALSA) oder einen ALSA-Ausgang
# (Klinke, HDMI, USB-DAC). Der Player baut nur ausgehende Verbindungen auf.
set -euo pipefail
source "$(dirname "$0")/lib.sh"
UPDATE=0
BLUETOOTH=0
for arg in "$@"; do
case "$arg" in
--update) UPDATE=1 ;;
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
*) abbruch "Unbekannte Option: $arg" ;;
esac
done
KONFIG="$AUDIOFY_DIR/config"
ENV_DATEI="$KONFIG/.env"
RASPOTIFY_CONF=/etc/raspotify/conf
export AUDIOFY_DIR AUDIOFY_USER
schritt "Audiofy-Player: $([ $UPDATE = 1 ] && echo Update || echo Neuinstallation)"
pruefe_system
ARCH="$(dpkg --print-architecture)"
case "$ARCH" in arm64|armhf|amd64) ;; *) abbruch "raspotify gibt es nicht für $ARCH (nur arm64, armhf, amd64)." ;; esac
if [ $UPDATE = 1 ] && ! $SUDO test -f "$ENV_DATEI"; then
abbruch "Keine bestehende Installation in $AUDIOFY_DIR gefunden – ohne --update ausführen."
fi
# ------------------------------------------------------------------ Abfragen
if [ $UPDATE = 0 ]; then
schritt "Angaben"
frage GERAETENAME "Name des Spotify-Connect-Geräts" "Audiofy"
echo " Audio-Ausgabe: 1) Bluetooth-Lautsprecher 2) Kabel/HDMI/USB (ALSA)"
frage AUSGABE "Auswahl" "1"
if [ "$AUSGABE" = 2 ]; then
BLUETOOTH=0
if command -v aplay >/dev/null; then
info "Verfügbare ALSA-Ausgänge:"
aplay -L 2>/dev/null | grep -E '^(default|sysdefault|plughw|hdmi)' | sed 's/^/ /' | head -20 || true
fi
frage ALSA_GERAET "ALSA-Gerät" "default"
else
BLUETOOTH=1
ALSA_GERAET="bluealsa:DEV=00:00:00:00:00:00,PROFILE=a2dp" # = zuletzt verbundener Lautsprecher
fi
frage SERVER_URL "Adresse des Audiofy-Servers (leer = ohne Wünsche, nur Playlist)" ""
SERVER_URL="${SERVER_URL%/}"
PLAYER_TOKEN=""
[ -n "$SERVER_URL" ] && frage_geheim PLAYER_TOKEN "PLAYER_API_TOKEN vom Server (verdeckt)"
info "Spotify-App (developer.spotify.com): Redirect-URI http://127.0.0.1:8888/callback eintragen."
frage SPOTIFY_CLIENT_ID "Spotify Client ID"
frage_geheim SPOTIFY_CLIENT_SECRET "Spotify Client Secret (verdeckt)"
frage PLAYLIST "Grund-Playlist (Link oder ID; mit Server wird sie dort eingestellt)"
PLAYLIST_ID="$(sed -E 's#.*playlist[/:]([A-Za-z0-9]{22}).*#\1#' <<<"$PLAYLIST")"
[[ "$PLAYLIST_ID" =~ ^[A-Za-z0-9]{22}$ ]] || { warnung "Keine gültige Playlist – bitte später eintragen."; PLAYLIST_ID=""; }
fi
# ------------------------------------------------------------------ Pakete
schritt "Pakete"
$SUDO apt-get update -qq
PAKETE=(python3-venv curl ca-certificates alsa-utils)
if [ $UPDATE = 0 ] && [ "$BLUETOOTH" = 1 ]; then
PAKETE+=(bluez bluez-alsa-utils libasound2-plugin-bluez)
fi
pakete "${PAKETE[@]}"
if ! dpkg -s raspotify >/dev/null 2>&1; then
schritt "raspotify (Spotify Connect)"
$SUDO curl -sSfL https://dtcooper.github.io/raspotify/key.asc -o /usr/share/keyrings/raspotify_key.asc
$SUDO chmod 644 /usr/share/keyrings/raspotify_key.asc
echo "deb [signed-by=/usr/share/keyrings/raspotify_key.asc] https://dtcooper.github.io/raspotify raspotify main" \
| $SUDO tee /etc/apt/sources.list.d/raspotify.list >/dev/null
$SUDO apt-get update -qq
pakete raspotify
fi
# ------------------------------------------------------------------ raspotify einstellen (nur Neuinstallation)
if [ $UPDATE = 0 ]; then
schritt "raspotify einstellen"
$SUDO test -f "$RASPOTIFY_CONF.orig" || $SUDO cp "$RASPOTIFY_CONF" "$RASPOTIFY_CONF.orig"
setze_raspotify() { # KEY WERT – vorhandene (auch auskommentierte) Zeile ersetzen, sonst anhängen
if $SUDO grep -qE "^#?\s*$1=" "$RASPOTIFY_CONF"; then
$SUDO sed -i -E "0,/^#?\s*$1=.*/s||$1=\"$2\"|" "$RASPOTIFY_CONF"
else
echo "$1=\"$2\"" | $SUDO tee -a "$RASPOTIFY_CONF" >/dev/null
fi
}
setze_raspotify LIBRESPOT_NAME "$GERAETENAME"
setze_raspotify LIBRESPOT_DEVICE_TYPE "speaker"
setze_raspotify LIBRESPOT_BACKEND "alsa"
setze_raspotify LIBRESPOT_DEVICE "$ALSA_GERAET"
setze_raspotify LIBRESPOT_BITRATE "320"
# Anmeldedaten speichern (sonst nach jedem Neustart unsichtbar) und normal protokollieren
$SUDO sed -i -E 's/^(LIBRESPOT_DISABLE_CREDENTIAL_CACHE=|LIBRESPOT_QUIET=)/#\1/' "$RASPOTIFY_CONF"
info "Name „$GERAETENAME“, Ausgabe $ALSA_GERAET"
fi
$SUDO install -d /etc/systemd/system/raspotify.service.d
vorlage raspotify-10-audiofy.conf /etc/systemd/system/raspotify.service.d/10-audiofy.conf
$SUDO systemctl daemon-reload
$SUDO systemctl enable raspotify >/dev/null 2>&1
[ $UPDATE = 0 ] && $SUDO systemctl restart raspotify
# ------------------------------------------------------------------ Bluetooth (nur Neuinstallation)
BT_MAC=""
if [ $UPDATE = 0 ] && [ "$BLUETOOTH" = 1 ]; then
schritt "Bluetooth-Lautsprecher"
# Der Player soll nicht selbst als Bluetooth-Empfänger für fremde Handys dienen
$SUDO systemctl disable --now bluealsa-aplay >/dev/null 2>&1 || true
info "Lautsprecher jetzt in den Kopplungsmodus versetzen (meist Bluetooth-Taste halten, bis die LED blinkt)."
if ja_nein "Jetzt suchen und koppeln?" j; then
BT_MAC="$(cd "$REPO/player" && python3 - <<'PY'
import sys, lautsprecher as l
print(" Suche 15 s …", file=sys.stderr)
geraete = l.suchen()
if not geraete:
print(" Kein Audiogerät gefunden.", file=sys.stderr); sys.exit(0)
for i, g in enumerate(geraete, 1):
print(f" {i}) {g['name'] or 'Unbekannt'} ({g['mac']})", file=sys.stderr)
print(" Nummer: ", end="", file=sys.stderr, flush=True)
wahl = open("/dev/tty").readline().strip()
if not wahl.isdigit() or not 1 <= int(wahl) <= len(geraete):
sys.exit(0)
mac = geraete[int(wahl) - 1]["mac"]
ok, meldung = l.koppeln(mac)
print(f" {meldung}", file=sys.stderr)
if ok:
print(mac)
PY
)" || true
[ -n "$BT_MAC" ] || warnung "Kein Lautsprecher gekoppelt – später im Admin-Bereich unter Einstellungen → Lautsprecher."
fi
fi
# ------------------------------------------------------------------ Benutzer, Ordner, Code
schritt "Dienst-Benutzer, Ordner und Code"
dienst_benutzer
$SUDO install -d -o root -g "$AUDIOFY_USER" -m 750 "$AUDIOFY_DIR"
$SUDO install -d -o "$AUDIOFY_USER" -g "$AUDIOFY_USER" -m 700 "$KONFIG"
code_kopieren "$REPO/player" "$AUDIOFY_DIR/app"
venv_einrichten "$AUDIOFY_DIR/app/requirements.txt"
info "Code in $AUDIOFY_DIR/app, Konfiguration in $KONFIG"
# ------------------------------------------------------------------ Konfiguration
schritt "Konfiguration ($ENV_DATEI)"
if [ $UPDATE = 0 ]; then
$SUDO test -f "$ENV_DATEI" || $SUDO install -o "$AUDIOFY_USER" -g "$AUDIOFY_USER" -m 600 /dev/null "$ENV_DATEI"
env_setzen "$ENV_DATEI" SPOTIFY_CLIENT_ID "$SPOTIFY_CLIENT_ID"
env_setzen "$ENV_DATEI" SPOTIFY_CLIENT_SECRET "$SPOTIFY_CLIENT_SECRET"
env_setzen "$ENV_DATEI" SPOTIFY_REDIRECT_URI "http://127.0.0.1:8888/callback"
env_setzen "$ENV_DATEI" DEVICE_NAME "$GERAETENAME"
env_setzen "$ENV_DATEI" WISH_EVERY_N "3"
[ -n "$PLAYLIST_ID" ] && env_setzen "$ENV_DATEI" SPOTIFY_PLAYLIST_ID "$PLAYLIST_ID"
[ -n "$BT_MAC" ] && env_setzen "$ENV_DATEI" BT_SPEAKER_MAC "$BT_MAC"
if [ -n "$SERVER_URL" ]; then
env_setzen "$ENV_DATEI" SERVER_URL "$SERVER_URL"
env_setzen "$ENV_DATEI" PLAYER_API_TOKEN "$PLAYER_TOKEN"
fi
unset SPOTIFY_CLIENT_SECRET PLAYER_TOKEN
schritt "Spotify-Login für die Wiedergabe-Steuerung"
info "Öffne gleich die angezeigte Adresse im Browser, melde dich an und füge danach die"
info "Adresse aus der Adresszeile (http://127.0.0.1:8888/callback?...) hier ein."
"${ALS_DIENST[@]}" "$AUDIOFY_DIR/.venv/bin/python" "$AUDIOFY_DIR/app/auth.py" --profil player --env "$ENV_DATEI" \
|| warnung "Login fehlgeschlagen – später nachholen (siehe README)."
else
# alte Schlüsselnamen übernehmen
env_umbenennen "$ENV_DATEI" VPS_URL SERVER_URL
env_umbenennen "$ENV_DATEI" PI_API_TOKEN PLAYER_API_TOKEN
fi
# ------------------------------------------------------------------ Dienst
schritt "Dienst audiofy-controller"
vorlage audiofy-controller.service /etc/systemd/system/audiofy-controller.service
$SUDO systemctl daemon-reload
$SUDO systemctl enable audiofy-controller >/dev/null 2>&1
$SUDO systemctl restart audiofy-controller
sleep 5
systemctl is-active --quiet audiofy-controller || abbruch "audiofy-controller läuft nicht – siehe: journalctl -u audiofy-controller"
info "audiofy-controller läuft"
# ------------------------------------------------------------------ Zusammenfassung
schritt "Fertig"
info "Logs: journalctl -u audiofy-controller -f · journalctl -u raspotify -f"
if [ $UPDATE = 0 ]; then
info ""
info "Wichtig: Wähle das Gerät „$GERAETENAME“ EINMAL in der Spotify-App aus (im selben Netz)."
info "Danach sind die Anmeldedaten gespeichert, und der Player startet nach jedem Neustart selbst."
fi
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# Audiofy – Installation der Rolle "Server": Website für Gäste, Admin-Bereich, Player-API.
#
# install/install-server.sh Neuinstallation (fragt alles Nötige ab)
# install/install-server.sh --update nur Code, Python-Umgebung und Dienst aktualisieren
#
# Voraussetzungen: Debian 12+ (oder abgeleitet), eine (Sub-)Domain, deren A/AAAA-Record
# auf diesen Rechner zeigt, und offene Ports 80/443 (auch in einer Cloud-Firewall).
set -euo pipefail
source "$(dirname "$0")/lib.sh"
UPDATE=0
for arg in "$@"; do
case "$arg" in
--update) UPDATE=1 ;;
-h|--help) sed -n '2,9p' "$0"; exit 0 ;;
*) abbruch "Unbekannte Option: $arg" ;;
esac
done
ENV_DATEI="$AUDIOFY_DIR/.env"
export AUDIOFY_DIR AUDIOFY_USER
export APP_PORT="${APP_PORT:-8000}"
schritt "Audiofy-Server: $([ $UPDATE = 1 ] && echo Update || echo Neuinstallation)"
pruefe_system
if [ $UPDATE = 1 ] && ! $SUDO test -f "$ENV_DATEI"; then
abbruch "Keine bestehende Installation in $AUDIOFY_DIR gefunden – ohne --update ausführen."
fi
# ------------------------------------------------------------------ Abfragen
if [ $UPDATE = 0 ]; then
schritt "Angaben"
frage DOMAIN "Domain der Website (z. B. musik.example.org)"
[ -n "$DOMAIN" ] || abbruch "Ohne Domain geht es nicht (HTTPS über Let's Encrypt)."
frage ACME_EMAIL "E-Mail für Let's Encrypt (Ablaufwarnungen)"
info "Spotify-App (developer.spotify.com): Redirect-URI http://127.0.0.1:8888/callback eintragen."
frage SPOTIFY_CLIENT_ID "Spotify Client ID"
frage_geheim SPOTIFY_CLIENT_SECRET "Spotify Client Secret (verdeckt)"
frage PLAYLIST "Grund-Playlist (Link oder ID, später im Admin änderbar)"
PLAYLIST_ID="$(sed -E 's#.*playlist[/:]([A-Za-z0-9]{22}).*#\1#' <<<"$PLAYLIST")"
if [ -n "$PLAYLIST_ID" ] && ! [[ "$PLAYLIST_ID" =~ ^[A-Za-z0-9]{22}$ ]]; then
warnung "Das sieht nicht nach einer Playlist aus – bitte später im Admin-Bereich eintragen."
PLAYLIST_ID=""
fi
SSH_PORT="$(${SUDO} sshd -T 2>/dev/null | awk '/^port /{print $2; exit}')"
SSH_PORT="${SSH_PORT:-22}"
ja_nein "Firewall (ufw) einrichten? Erlaubt SSH (Port $SSH_PORT), 80 und 443" j && FIREWALL=1 || FIREWALL=0
ja_nein "fail2ban für SSH einrichten?" j && FAIL2BAN=1 || FAIL2BAN=0
ja_nein "Automatische Sicherheitsupdates aktivieren?" j && AUTOUPDATE=1 || AUTOUPDATE=0
export DOMAIN ACME_EMAIL SSH_PORT
fi
# ------------------------------------------------------------------ Pakete
schritt "Pakete"
$SUDO apt-get update -qq
PAKETE=(python3-venv sqlite3 caddy)
if [ $UPDATE = 0 ]; then
[ $FIREWALL = 1 ] && PAKETE+=(ufw)
[ $FAIL2BAN = 1 ] && PAKETE+=(fail2ban)
[ $AUTOUPDATE = 1 ] && PAKETE+=(unattended-upgrades)
fi
pakete "${PAKETE[@]}"
# ------------------------------------------------------------------ Benutzer, Ordner, Code
schritt "Dienst-Benutzer, Ordner und Code"
dienst_benutzer
$SUDO install -d -o root -g "$AUDIOFY_USER" -m 750 "$AUDIOFY_DIR"
$SUDO install -d -o "$AUDIOFY_USER" -g "$AUDIOFY_USER" -m 700 "$AUDIOFY_DIR/data"
code_kopieren "$REPO/server" "$AUDIOFY_DIR/app"
$SUDO install -o root -g "$AUDIOFY_USER" -m 640 "$REPO/player/auth.py" "$AUDIOFY_DIR/app/auth.py"
venv_einrichten "$AUDIOFY_DIR/app/requirements.txt"
info "Code in $AUDIOFY_DIR/app, Python-Umgebung in $AUDIOFY_DIR/.venv"
# ------------------------------------------------------------------ Konfiguration
schritt "Konfiguration ($ENV_DATEI)"
$SUDO test -f "$ENV_DATEI" || $SUDO install -o "$AUDIOFY_USER" -g "$AUDIOFY_USER" -m 600 /dev/null "$ENV_DATEI"
VERWALTUNG=("${ALS_DIENST[@]}" "$AUDIOFY_DIR/.venv/bin/python" "$AUDIOFY_DIR/app/verwaltung.py")
"${VERWALTUNG[@]}" geheimnisse --env "$ENV_DATEI" | sed 's/^/ /'
if [ $UPDATE = 0 ]; then
env_setzen "$ENV_DATEI" DB_PATH "$AUDIOFY_DIR/data/audiofy.db"
env_setzen "$ENV_DATEI" PUBLIC_URL "https://$DOMAIN"
env_setzen "$ENV_DATEI" SPOTIFY_CLIENT_ID "$SPOTIFY_CLIENT_ID"
env_setzen "$ENV_DATEI" SPOTIFY_CLIENT_SECRET "$SPOTIFY_CLIENT_SECRET"
[ -n "$PLAYLIST_ID" ] && env_setzen "$ENV_DATEI" SPOTIFY_PLAYLIST_ID "$PLAYLIST_ID"
unset SPOTIFY_CLIENT_SECRET
schritt "Spotify-Login für die Suche"
info "Öffne gleich die angezeigte Adresse im Browser, melde dich an und füge danach die"
info "Adresse aus der Adresszeile (http://127.0.0.1:8888/callback?...) hier ein."
if ja_nein "Jetzt anmelden?" j; then
"${ALS_DIENST[@]}" "$AUDIOFY_DIR/.venv/bin/python" "$AUDIOFY_DIR/app/auth.py" --profil server --env "$ENV_DATEI" \
|| warnung "Login fehlgeschlagen – später nachholen (siehe README)."
fi
schritt "Admin-Passwort"
"${VERWALTUNG[@]}" passwort --env "$ENV_DATEI" || warnung "Kein Passwort gesetzt – später nachholen (siehe README)."
if ja_nein "Cloudflare Turnstile (Bot-Schutz für Login und Wünsche) einrichten?" n; then
"${VERWALTUNG[@]}" turnstile --env "$ENV_DATEI" || warnung "Turnstile nicht eingerichtet."
fi
fi
# ------------------------------------------------------------------ Dienst
schritt "Dienst audiofy-web"
vorlage audiofy-web.service /etc/systemd/system/audiofy-web.service
$SUDO systemctl daemon-reload
$SUDO systemctl enable audiofy-web >/dev/null 2>&1
$SUDO systemctl restart audiofy-web
sleep 3
systemctl is-active --quiet audiofy-web || abbruch "audiofy-web läuft nicht – siehe: journalctl -u audiofy-web"
curl -fsS "http://127.0.0.1:$APP_PORT/healthz" >/dev/null && info "audiofy-web läuft"
# ------------------------------------------------------------------ Caddy & Schutz (nur Neuinstallation)
if [ $UPDATE = 0 ]; then
schritt "Caddy (HTTPS)"
if $SUDO grep -q "Audiofy" /etc/caddy/Caddyfile 2>/dev/null || $SUDO grep -q "/usr/share/caddy" /etc/caddy/Caddyfile 2>/dev/null; then
vorlage Caddyfile /etc/caddy/Caddyfile
$SUDO caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1 || abbruch "Caddyfile ungültig"
$SUDO systemctl enable caddy >/dev/null 2>&1
$SUDO systemctl reload-or-restart caddy
info "Caddy holt das Zertifikat für $DOMAIN (dauert ein paar Sekunden)."
else
warnung "/etc/caddy/Caddyfile enthält schon eine eigene Konfiguration – nicht verändert."
warnung "Bitte einen Block für $DOMAIN mit 'reverse_proxy 127.0.0.1:$APP_PORT' ergänzen (Vorlage: install/vorlagen/Caddyfile)."
fi
if [ $FIREWALL = 1 ]; then
schritt "Firewall (ufw)"
$SUDO ufw default deny incoming >/dev/null
$SUDO ufw default allow outgoing >/dev/null
$SUDO ufw allow "$SSH_PORT/tcp" comment "SSH" >/dev/null
$SUDO ufw allow 80/tcp comment "HTTP (Caddy/ACME)" >/dev/null
$SUDO ufw allow 443/tcp comment "HTTPS (Caddy)" >/dev/null
$SUDO ufw --force enable >/dev/null
info "ufw aktiv: $SSH_PORT, 80, 443"
fi
if [ $FAIL2BAN = 1 ]; then
schritt "fail2ban"
vorlage fail2ban-audiofy.conf /etc/fail2ban/jail.d/audiofy.conf
$SUDO systemctl enable fail2ban >/dev/null 2>&1
$SUDO systemctl restart fail2ban
info "SSH-Schutz auf Port $SSH_PORT aktiv"
fi
if [ $AUTOUPDATE = 1 ]; then
schritt "Automatische Sicherheitsupdates"
vorlage 20auto-upgrades /etc/apt/apt.conf.d/20auto-upgrades
info "aktiviert"
fi
schritt "Logs begrenzen"
$SUDO install -d /etc/systemd/journald.conf.d
vorlage journald-10-audiofy.conf /etc/systemd/journald.conf.d/10-audiofy.conf
$SUDO systemctl restart systemd-journald
info "journald max. 200 MB"
fi
# ------------------------------------------------------------------ Zusammenfassung
schritt "Fertig"
URL="$(env_wert "$ENV_DATEI" PUBLIC_URL)"
info "Website: $URL (Gäste nur mit Einladungslink/QR-Code)"
info "Admin: $URL/admin"
info "Logs: journalctl -u audiofy-web -f"
if [ $UPDATE = 0 ]; then
info ""
info "Für die Player-Installation brauchst du:"
info " Server-Adresse: $URL"
info " Player-Token: sudo -u $AUDIOFY_USER grep PLAYER_API_TOKEN $ENV_DATEI"
info "Gastzugang ist anfangs aus – im Admin unter „Gäste“ einschalten."
fi
+146
View File
@@ -0,0 +1,146 @@
# Audiofy – gemeinsame Hilfsfunktionen für die Install-Skripte (wird per "source" geladen)
# shellcheck shell=bash
AUDIOFY_DIR="${AUDIOFY_DIR:-/opt/audiofy}"
AUDIOFY_USER="${AUDIOFY_USER:-audiofy}"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VORLAGEN="$REPO/install/vorlagen"
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
ALS_DIENST=(runuser -u "$AUDIOFY_USER" --)
else
SUDO="sudo"
ALS_DIENST=(sudo -u "$AUDIOFY_USER")
fi
# --- Ausgabe ------------------------------------------------------------------
schritt() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warnung() { printf '\033[33m ! %s\033[0m\n' "$*"; }
abbruch() { printf '\033[31m ✗ %s\033[0m\n' "$*" >&2; exit 1; }
# --- Abfragen -----------------------------------------------------------------
# frage VAR "Text" [Standard]
frage() {
local antwort
read -r -p " $2${3:+ [$3]}: " antwort
printf -v "$1" '%s' "${antwort:-${3:-}}"
}
# frage_geheim VAR "Text" – Eingabe wird nicht angezeigt
frage_geheim() {
local antwort
read -r -s -p " $2: " antwort
echo
printf -v "$1" '%s' "$antwort"
}
# ja_nein "Frage" [j|n] – Rückgabewert 0 = ja
ja_nein() {
local standard="${2:-j}" antwort
read -r -p " $1 [$([ "$standard" = j ] && echo J/n || echo j/N)]: " antwort
antwort="${antwort:-$standard}"
[[ "$antwort" =~ ^[jJyY] ]]
}
# --- System -------------------------------------------------------------------
pruefe_system() {
[ -f /etc/debian_version ] || abbruch "Nur für Debian und Debian-basierte Systeme (z. B. Raspberry Pi OS)."
command -v systemctl >/dev/null || abbruch "systemd wird benötigt."
command -v apt-get >/dev/null || abbruch "apt wird benötigt."
if [ -n "$SUDO" ]; then
command -v sudo >/dev/null || abbruch "Bitte als root ausführen oder sudo installieren."
info "Für die Einrichtung sind root-Rechte nötig – sudo fragt ggf. nach deinem Passwort."
sudo true || abbruch "sudo nicht möglich."
fi
python3 -c 'import sys; sys.exit(sys.version_info < (3, 11))' 2>/dev/null \
|| abbruch "Python 3.11 oder neuer wird benötigt (Debian 12 oder neuer)."
}
pakete() {
info "Installiere: $*"
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" >/dev/null
}
dienst_benutzer() {
if ! id "$AUDIOFY_USER" >/dev/null 2>&1; then
$SUDO useradd --system --home-dir "$AUDIOFY_DIR" --no-create-home --shell /usr/sbin/nologin "$AUDIOFY_USER"
info "Dienst-Benutzer '$AUDIOFY_USER' angelegt (ohne Login, ohne sudo)"
fi
}
# code_kopieren QUELLE ZIEL – ersetzt den Inhalt von ZIEL (gehört root, Dienst darf nur lesen)
code_kopieren() {
local quelle="$1" ziel="$2"
$SUDO install -d -o root -g "$AUDIOFY_USER" -m 750 "$ziel"
$SUDO find "$ziel" -mindepth 1 -delete
tar -C "$quelle" --exclude='__pycache__' --exclude='.venv' --exclude='.pytest_cache' --exclude='tests' -cf - . \
| $SUDO tar -C "$ziel" --no-same-owner -xf -
$SUDO chown -R root:"$AUDIOFY_USER" "$ziel"
$SUDO find "$ziel" -type d -exec chmod 750 {} +
$SUDO find "$ziel" -type f -exec chmod 640 {} +
}
# venv_einrichten REQUIREMENTS
venv_einrichten() {
local venv="$AUDIOFY_DIR/.venv"
[ -x "$venv/bin/python" ] || $SUDO python3 -m venv "$venv"
$SUDO "$venv/bin/pip" install -q --upgrade pip
$SUDO "$venv/bin/pip" install -q -r "$1"
$SUDO chown -R root:"$AUDIOFY_USER" "$venv"
$SUDO chmod -R g+rX,o-rwx "$venv"
}
# vorlage DATEI ZIEL – ersetzt {{NAME}} durch den Wert der Shell-Variablen NAME
vorlage() {
local inhalt
inhalt="$(python3 - "$VORLAGEN/$1" <<'PY'
import os, re, sys
text = open(sys.argv[1]).read()
def ersetzen(m):
wert = os.environ.get(m.group(1))
if wert is None:
sys.exit(f"Vorlage {sys.argv[1]}: {m.group(1)} ist nicht gesetzt")
return wert
print(re.sub(r"\{\{([A-Z_]+)\}\}", ersetzen, text), end="")
PY
)" || abbruch "Vorlage $1 konnte nicht ausgefüllt werden"
printf '%s\n' "$inhalt" | $SUDO tee "$2" >/dev/null
}
# --- .env (gehört dem Dienst-Benutzer, Rechte 600) ------------------------------
# env_setzen DATEI KEY WERT – der Wert läuft über stdin (nicht in der Prozessliste sichtbar)
env_setzen() {
printf '%s' "$3" | "${ALS_DIENST[@]}" python3 -c '
import os, sys
pfad, key = sys.argv[1], sys.argv[2]
wert = sys.stdin.read()
zeilen = open(pfad).read().splitlines() if os.path.exists(pfad) else []
zeilen = [z for z in zeilen if not z.strip().startswith(key + "=")] + [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(zeilen) + "\n")
' "$1" "$2"
}
# env_wert DATEI KEY – gibt den Wert aus (leer, wenn nicht vorhanden)
env_wert() {
"${ALS_DIENST[@]}" sh -c 'grep -m1 "^$2=" "$1" 2>/dev/null | cut -d= -f2-' _ "$1" "$2" || true
}
# env_umbenennen DATEI ALT NEU – übernimmt alte Schlüsselnamen (für Updates)
env_umbenennen() {
local wert
if [ -z "$(env_wert "$1" "$3")" ]; then
wert="$(env_wert "$1" "$2")"
if [ -n "$wert" ]; then
env_setzen "$1" "$3" "$wert"
info "$2 → $3 übernommen"
fi
fi
}
+2
View File
@@ -0,0 +1,2 @@
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
+19
View File
@@ -0,0 +1,19 @@
# Audiofy – Caddy-Konfiguration (erzeugt von install-server.sh)
{
email {{ACME_EMAIL}}
servers {
protocols h1 h2
}
}
{{DOMAIN}} {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
-Server
}
reverse_proxy 127.0.0.1:{{APP_PORT}}
}
@@ -0,0 +1,31 @@
[Unit]
Description=Audiofy – Controller (Spotify-Steuerung, Wünsche vom Server)
After=network-online.target bluetooth.target raspotify.service
Wants=network-online.target raspotify.service
# nie aufgeben, immer wieder neu starten
StartLimitIntervalSec=0
[Service]
User={{AUDIOFY_USER}}
Group={{AUDIOFY_USER}}
WorkingDirectory={{AUDIOFY_DIR}}/app
ExecStart={{AUDIOFY_DIR}}/.venv/bin/python -u {{AUDIOFY_DIR}}/app/controller.py --env {{AUDIOFY_DIR}}/config/.env
Restart=always
RestartSec=5
UMask=0077
# Härtung
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{AUDIOFY_DIR}}/config
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_BLUETOOTH
LockPersonality=yes
MemoryMax=200M
[Install]
WantedBy=multi-user.target
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=Audiofy – Website, Admin-Bereich und Player-API
After=network-online.target
Wants=network-online.target
[Service]
User={{AUDIOFY_USER}}
Group={{AUDIOFY_USER}}
WorkingDirectory={{AUDIOFY_DIR}}/app
Environment=AUDIOFY_ENV={{AUDIOFY_DIR}}/.env
ExecStart={{AUDIOFY_DIR}}/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port {{APP_PORT}} --workers 1 --no-access-log --proxy-headers --forwarded-allow-ips 127.0.0.1
Restart=always
RestartSec=3
UMask=0077
# Härtung
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{AUDIOFY_DIR}}/data {{AUDIOFY_DIR}}/.env
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
LockPersonality=yes
MemoryMax=300M
[Install]
WantedBy=multi-user.target
+8
View File
@@ -0,0 +1,8 @@
# Audiofy – SSH gegen Passwort-Raten schützen
[sshd]
enabled = true
port = {{SSH_PORT}}
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
@@ -0,0 +1,2 @@
[Journal]
SystemMaxUse=200M
@@ -0,0 +1,8 @@
# Audiofy: raspotify nie endgültig aufgeben (z. B. wenn der Lautsprecher kurz fehlt
# oder das Netz beim Start noch nicht da ist)
[Unit]
StartLimitIntervalSec=0
[Service]
Restart=always
RestartSec=10
+8
View File
@@ -0,0 +1,8 @@
# Audiofy – SSH-Härtung (NICHT automatisch installiert, siehe README „Server härten“)
# Erst einsetzen, wenn der Login per Schlüssel für {{ADMIN_USER}} nachweislich klappt!
Port {{SSH_PORT}}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers {{ADMIN_USER}}
Executable
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Audiofy – einmaliger Spotify-Login (Authorization Code Flow).
Holt einen Refresh-Token für das eigene Spotify-Konto und schreibt ihn in die
.env-Datei. Funktioniert ohne Browser auf dem Gerät selbst:
1. Die ausgegebene Login-Adresse in einem beliebigen Browser öffnen.
2. Bei Spotify anmelden und zustimmen.
3. Der Browser landet auf http://127.0.0.1:8888/callback?... – die Seite lädt
nicht, das ist normal. Die komplette Adresse aus der Adresszeile kopieren
und hier einfügen.
Aufruf:
python3 auth.py --env /opt/audiofy/config/.env # Player (Wiedergabe steuern)
python3 auth.py --profil server --env /opt/audiofy/.env # Server (nur Suche)
Benötigte Einträge in der .env: SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET,
optional SPOTIFY_REDIRECT_URI. Nur Python-Standardbibliothek, kein venv nötig.
"""
import argparse
import base64
import getpass
import json
import os
import secrets
import stat
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
TOKEN_URL = "https://accounts.spotify.com/api/token"
ME_URL = "https://api.spotify.com/v1/me"
STANDARD_REDIRECT = "http://127.0.0.1:8888/callback"
# Player: Wiedergabe steuern + Playlist lesen; user-read-private nur, damit /me das
# Feld "product" (Premium-Check) liefert.
# Server: Suche braucht keinen Scope. ("pi"/"vps" = alte Namen)
SCOPES = {
"player": "user-read-playback-state user-modify-playback-state "
"user-read-currently-playing playlist-read-private user-read-private",
"server": "",
"pi": None, # alter Name für "player"
"vps": None, # alter Name für "server"
}
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
def env_setzen(pfad: Path, key: str, wert: str) -> None:
"""Setzt KEY=wert in der .env (ersetzt oder hängt an), atomar und mit Rechten 600."""
zeilen = pfad.read_text().splitlines()
neu, gefunden = [], False
for zeile in zeilen:
if zeile.strip().startswith(f"{key}="):
neu.append(f"{key}={wert}")
gefunden = True
else:
neu.append(zeile)
if not gefunden:
neu.append(f"{key}={wert}")
tmp = pfad.with_name(pfad.name + ".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write("\n".join(neu) + "\n")
os.replace(tmp, pfad)
def token_anfragen(client_id: str, client_secret: str, daten: dict) -> dict:
body = urllib.parse.urlencode(daten).encode()
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
req = urllib.request.Request(TOKEN_URL, data=body, headers={
"Authorization": f"Basic {basic}",
"Content-Type": "application/x-www-form-urlencoded",
})
try:
with urllib.request.urlopen(req, timeout=15) as antwort:
return json.load(antwort)
except urllib.error.HTTPError as e:
# Spotify-Fehlertexte enthalten keine Geheimnisse
sys.exit(f"Token-Anfrage fehlgeschlagen ({e.code}): {e.read().decode(errors='replace')[:300]}")
def me_abfragen(access_token: str) -> dict:
req = urllib.request.Request(ME_URL, headers={"Authorization": f"Bearer {access_token}"})
try:
with urllib.request.urlopen(req, timeout=15) as antwort:
return json.load(antwort)
except urllib.error.HTTPError as e:
sys.exit(f"/me fehlgeschlagen ({e.code}): {e.read().decode(errors='replace')[:300]}")
def main() -> None:
parser = argparse.ArgumentParser(description="Audiofy – Spotify-Login, schreibt den Refresh-Token in die .env")
parser.add_argument("--profil", choices=SCOPES, default="player", help="Scope-Satz (Standard: player)")
parser.add_argument("--env", type=Path, default=Path(__file__).resolve().parent / ".env",
help="Pfad zur .env (Standard: neben dem Skript)")
args = parser.parse_args()
args.profil = {"pi": "player", "vps": "server"}.get(args.profil, args.profil) # alte Namen
if not args.env.is_file():
sys.exit(f"{args.env} fehlt. Bitte anlegen (Rechte 600) mit SPOTIFY_CLIENT_ID und SPOTIFY_CLIENT_SECRET.")
if stat.S_IMODE(args.env.stat().st_mode) & 0o077:
print(f"Warnung: {args.env} ist für andere lesbar – bitte 'chmod 600 {args.env}'.", file=sys.stderr)
env = env_lesen(args.env)
client_id = env.get("SPOTIFY_CLIENT_ID", "")
client_secret = env.get("SPOTIFY_CLIENT_SECRET", "")
redirect_uri = env.get("SPOTIFY_REDIRECT_URI") or STANDARD_REDIRECT
if not client_id or not client_secret:
sys.exit(f"SPOTIFY_CLIENT_ID und SPOTIFY_CLIENT_SECRET müssen in {args.env} gesetzt sein.")
state = secrets.token_urlsafe(16)
parameter = {
"client_id": client_id,
"response_type": "code",
"redirect_uri": redirect_uri,
"state": state,
"show_dialog": "true",
}
if SCOPES[args.profil]:
parameter["scope"] = SCOPES[args.profil]
print(f"Profil: {args.profil}")
print("\n1) Diese Adresse im Browser öffnen und bei Spotify anmelden:\n")
print(f" {AUTHORIZE_URL}?{urllib.parse.urlencode(parameter)}\n")
print(f"2) Danach die komplette Adresse aus der Adresszeile ({redirect_uri}?...) einfügen.")
print(" (Eingabe wird nicht angezeigt.)\n")
eingabe = getpass.getpass("Adresse: ").strip()
url = urllib.parse.urlparse(eingabe)
query = urllib.parse.parse_qs(url.query)
if "error" in query:
sys.exit(f"Spotify meldet: {query['error'][0]}")
if query.get("state", [""])[0] != state:
sys.exit("State passt nicht – bitte die Adresse aus genau diesem Durchlauf einfügen.")
code = query.get("code", [""])[0]
if not code:
sys.exit("Kein 'code' in der Adresse gefunden.")
tokens = token_anfragen(client_id, client_secret, {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
})
refresh_token = tokens.get("refresh_token")
if not refresh_token:
sys.exit("Antwort enthält keinen Refresh-Token.")
env_setzen(args.env, "SPOTIFY_REFRESH_TOKEN", refresh_token)
print(f"\nRefresh-Token in {args.env} gespeichert.")
erhalten = set(tokens.get("scope", "").split())
fehlend = set(SCOPES[args.profil].split()) - erhalten
if fehlend:
print(f"Warnung: Diese Scopes wurden nicht gewährt: {' '.join(sorted(fehlend))}")
me = me_abfragen(tokens["access_token"])
print(f"Angemeldet als: {me.get('display_name') or me.get('id')}")
if "product" in me:
print(f"Abo: {me['product']}" + (" ✅" if me["product"] == "premium" else " ❌ (Premium nötig!)"))
if __name__ == "__main__":
main()
+716
View File
@@ -0,0 +1,716 @@
#!/usr/bin/env python3
"""Audiofy – Controller (Player).
Spielt zufällige Titel aus der Grund-Playlist auf dem Spotify-Connect-Gerät
"Audiofy" und schiebt nach jeweils N-1 Playlist-Titeln einen Wunsch ein.
Spotify bekommt immer nur den jeweils nächsten Titel (POST /me/player/queue,
ca. 15 s vor Titelende); die Reihenfolge verwaltet ausschließlich der Controller.
Wünsche, Einstellungen (N, Playlist, Neu mischen, Not-Aus) und Befehle des
Besitzers (Play, Pause, Weiter, Lautstärke) kommen vom Server (Player-API, ca. alle 3 s).
"Als Nächstes" des Besitzers wird sofort eingeplant und zählt nicht im Wunsch-Takt.
Ist der Server nicht erreichbar, läuft einfach die Grund-Playlist weiter.
Bedienung über die Spotify-App bleibt möglich: Fremde Titel werden erkannt und
geduldet, eine Pause durch den Besitzer (App oder Admin-Bereich) wird respektiert.
Aufruf: .venv/bin/python controller.py
"""
import argparse
import json
import logging
import os
import random
import re
import subprocess
import threading
import time
from collections import deque
from datetime import datetime, timezone
from dataclasses import dataclass
from pathlib import Path
import httpx
import lautsprecher
from auth import env_lesen, env_setzen
from spotify_client import SpotifyClient, SpotifyFehler
from server_client import ServerVerbindung, Wunsch
log = logging.getLogger("audiofy.controller")
ORDNER = Path(__file__).resolve().parent
POLL_S = 3 # Abfrageintervall /me/player und Server
VORLAUF_MS = 15_000 # so lange vor Titelende den Nächsten einreihen
ENDE_MS = 10_000 # Restzeit, ab der ein Stopp als "Titel zu Ende" gilt
START_TIMEOUT_S = 15 # spielt es nach einem Start so lange nicht, erneut versuchen
BT_PRUEF_S = 30 # Bluetooth-Verbindung so oft prüfen
BT_NEU_S = 10 # bei getrenntem Lautsprecher so oft neu verbinden
MAX_BACKOFF_S = 15 # nach Netzausfall schnell wieder dran sein
PAUSE_MIN_MS = 5_000 # pausierter Titel ab dieser Position gilt beim Start als Besitzer-Pause
BT_MELDEN_S = 30 # Lautsprecher-Zustand so oft an den Server melden
BT_BEREIT_S = 3 # nach dem Verbinden so lange warten, bis BlueALSA das Audiogerät anbietet
def kennung(item: dict) -> str:
"""Vergleichsschlüssel aus Titel + Hauptinterpret, unempfindlich für Zusätze wie "- 2022 Remaster"."""
name = re.sub(r"\s+-\s+.*$|\s*[(\[].*?[)\]]", "", item.get("name") or "").casefold().strip()
interpret = ((item.get("artists") or [{}])[0].get("name") or "").casefold().strip()
return f"{name}|{interpret}"
def titel_info(item: dict) -> dict | None:
"""Spotify-Track → Titelinfo für den Server (None für lokale Dateien, Podcasts usw.)."""
if not (item.get("uri") or "").startswith("spotify:track:"):
return None
bilder = sorted((item.get("album") or {}).get("images") or [], key=lambda b: b.get("width") or 0)
return {
"track_uri": item["uri"],
"title": (item.get("name") or "")[:300],
"artist": ", ".join(a.get("name", "") for a in item.get("artists") or [])[:300],
"album": ((item.get("album") or {}).get("name") or "")[:300] or None,
"cover_url": next((b["url"] for b in bilder if (b.get("width") or 0) >= 200),
bilder[-1]["url"] if bilder else None),
"duration_ms": item.get("duration_ms"),
}
@dataclass
class Eintrag:
"""Ein vom Controller geplanter Titel."""
uri: str
quelle: str = "playlist" # playlist, wish oder owner ("Als Nächstes" des Besitzers)
wunsch: Wunsch | None = None
direkt: bool = False # wurde schon einmal per PUT /me/player/play gestartet
kennung: str | None = None # Titel|Interpret, um Ersatzversionen zu erkennen
titel: dict | None = None # Titelinfo für die Anzeige "Als Nächstes"
class Playlist:
"""Grund-Playlist gemischt; ohne Wiederholung, bis alle Titel durch sind."""
def __init__(self, sp: SpotifyClient, playlist_id: str):
self.sp = sp
self.id = playlist_id
self._stapel: list[str] = []
self._letzter: str | None = None
def wechseln(self, playlist_id: str) -> None:
self.id = playlist_id
self._stapel = []
def neu_mischen(self) -> None:
self._stapel = [] # beim nächsten Titel neu laden und mischen
def naechster(self) -> str:
if not self._stapel:
titel = self.sp.playlist_titel(self.id)
if not titel:
raise RuntimeError("Grund-Playlist ist leer oder nicht lesbar")
random.shuffle(titel)
# Nicht denselben Titel direkt zweimal hintereinander (an der Mischgrenze)
if len(titel) > 1 and titel[-1] == self._letzter:
titel[0], titel[-1] = titel[-1], titel[0]
self._stapel = titel
log.info("Grund-Playlist geladen und gemischt: %d Titel", len(titel))
self._letzter = self._stapel.pop()
return self._letzter
class Controller:
def __init__(self, sp: SpotifyClient, playlist: Playlist, server: ServerVerbindung | None,
n: int, geraetename: str, bt_mac: str | None, zustand_pfad: Path | None = None,
env_pfad: Path | None = None):
self.sp = sp
self.env_pfad = env_pfad # hier wird ein neu gewählter Lautsprecher gespeichert
self.zustand_pfad = zustand_pfad # merkt sich Besitzer-Pause/Not-Aus über Neustarts hinweg
self.playlist = playlist
self.server = server
self.n = max(1, n)
self.geraetename = geraetename
self.bt_mac = bt_mac
self.seit_wunsch = 0 # Playlist-Titel seit dem letzten Wunsch
self.eingereiht: Eintrag | None = None # an Spotify übergeben, aber noch nicht gestartet
self.fremdwechsel = 0 # Titelwechsel, seit eingereiht wurde, ohne dass er kam
self.aktuell_uri: str | None = None
self.aktuell_quelle: str | None = None # Quelle des laufenden Titels (für den Server)
self.aktuell_wunsch_id: int | None = None
self.neuer_titel = False # Titelwechsel noch nicht an den Server gemeldet
self.vorher: dict | None = None # Zustand der letzten Abfrage
self.st: dict | None = None # letzte Antwort von /me/player
self.besitzer_pause = False
self.bt_pause = False # Wiedergabe stockt, weil der Lautsprecher weg ist
self.stopp = False # Not-Aus vom Admin-Bereich
self.neustart = False
self.direktstart = False # nächster Titel stammt aus starten()
self.gestartet_um: float | None = None
self.geraet_ok = True
self.fehler: str | None = None # z. B. Playlist nicht lesbar (wird gemeldet)
self._reshuffle: int | None = None
self._erledigt: deque[int] = deque(maxlen=200) # ausgeführte Befehls-IDs (nie doppelt)
self._bt_geprueft = 0.0
self._bt_verbunden: bool | None = None
self._bt_verbinden: subprocess.Popen | None = None # laufender Verbindungsversuch
self._bt_versuch = 0.0
self._geraet_fehlt_log = 0.0
self._bt_verbunden_seit = 0.0
self._lautsprecher_log = 0.0
self._gespeichert: dict | None = None
self._bt_aufgabe: threading.Thread | None = None # Suche/Kopplung im Hintergrund
self._bt_geraete: list[dict] = [] # Ergebnis der letzten Suche
self._bt_gesucht_um: str | None = None
self._bt_gemeldet = 0.0
self._zustand_laden()
# --- dauerhafter Zustand ---------------------------------------------
def _zustand_laden(self) -> None:
"""Pause/Not-Aus von vor dem Neustart übernehmen (sonst würde nach einem Reboot einfach losgespielt)."""
if not self.zustand_pfad or not self.zustand_pfad.is_file():
return
try:
daten = json.loads(self.zustand_pfad.read_text())
except (OSError, ValueError) as e:
log.warning("Zustandsdatei nicht lesbar (%s) – wird ignoriert", e)
return
self.besitzer_pause = bool(daten.get("besitzer_pause"))
self.stopp = bool(daten.get("stopp"))
self._gespeichert = {"besitzer_pause": self.besitzer_pause, "stopp": self.stopp}
if self.stopp:
log.warning("Vor dem Neustart war Not-Aus aktiv – bleibt aktiv, bis er im Admin-Bereich aufgehoben wird")
elif self.besitzer_pause:
log.info("Vor dem Neustart war pausiert – bleibt pausiert, bis Play gedrückt wird")
def zustand_sichern(self) -> None:
"""Schreibt nur bei Änderungen (schont die SD-Karte)."""
if not self.zustand_pfad:
return
aktuell = {"besitzer_pause": self.besitzer_pause, "stopp": self.stopp}
if aktuell == self._gespeichert:
return
try:
tmp = self.zustand_pfad.with_name(self.zustand_pfad.name + ".tmp")
tmp.write_text(json.dumps(aktuell))
os.replace(tmp, self.zustand_pfad)
self._gespeichert = aktuell
except OSError as e:
log.error("Zustand konnte nicht gespeichert werden: %s", e)
# --- Planung ----------------------------------------------------------
def naechster_eintrag(self) -> Eintrag:
wunsch = self.server.naechster_wunsch() if self.server else None
if wunsch and (wunsch.priority or self.seit_wunsch >= self.n - 1):
eintrag = Eintrag(wunsch.uri, "owner" if wunsch.priority else "wish", wunsch)
else:
# kein (fälliger) Wunsch → Playlist weiter, Zähler bleibt "fällig"
try:
eintrag = Eintrag(self.playlist.naechster())
self.fehler = None
except (RuntimeError, SpotifyFehler) as e:
self.fehler = "Grund-Playlist nicht lesbar – Link im Admin-Bereich prüfen"
raise RuntimeError(f"{self.fehler} ({e})") from e
try:
t = self.sp.titel(eintrag.uri)
eintrag.kennung = kennung(t)
eintrag.titel = titel_info(t)
except SpotifyFehler as e:
log.warning("Metadaten für %s nicht abrufbar (%s) – Vergleich nur per URI", eintrag.uri, e)
return eintrag
def geraet_id(self) -> str | None:
for geraet in self.sp.geraete():
if geraet.get("name") == self.geraetename:
self.geraet_ok = True
return geraet["id"]
self.geraet_ok = False
return None
def starten(self) -> None:
"""Wiedergabe auf dem Gerät "Audiofy" (neu) starten."""
gid = self.geraet_id()
if not gid:
# Warnung beim ersten Mal, danach höchstens einmal pro Minute
jetzt = time.monotonic()
if jetzt - self._geraet_fehlt_log >= 60:
log.warning("Gerät '%s' nicht gefunden – versuche es weiter alle %d s", self.geraetename, POLL_S)
self._geraet_fehlt_log = jetzt
return
self._geraet_fehlt_log = 0.0
# Ein bereits eingereihter Titel wird direkt gestartet. Liegt er noch in der
# Spotify-Queue, kann er später ein zweites Mal kommen (Queue nicht leerbar).
eintrag = self.eingereiht
if eintrag and eintrag.direkt:
# Schon einmal direkt gestartet – nicht in einer Schleife wiederholen
log.warning("Eintrag %s wurde bereits gestartet – wird übersprungen", eintrag.uri)
eintrag = None
eintrag = eintrag or self.naechster_eintrag()
self.sp.abspielen([eintrag.uri], gid)
eintrag.direkt = True
self.eingereiht = eintrag
self.fremdwechsel = 0
self.neustart = False
self.direktstart = True
self.aktuell_uri = None
self.gestartet_um = time.monotonic()
log.info("Wiedergabe auf '%s' gestartet: %s", self.geraetename, eintrag.uri)
# --- Abgleich mit dem Server ----------------------------------------------
def server_abgleich(self) -> list[dict]:
"""Konfiguration vom Server übernehmen; liefert die offenen Befehle."""
if not self.server:
return []
k = self.server.konfiguration()
if not k:
return [] # offline → mit den letzten bekannten Werten weiter
n = max(1, int(k.get("wish_every_n") or 3))
if n != self.n:
log.info("N geändert: jeder %d. Titel ist ein Wunsch", n)
self.n = n
if k.get("playlist_id") and k["playlist_id"] != self.playlist.id:
log.info("Grund-Playlist gewechselt: %s", k["playlist_id"])
self.playlist.wechseln(k["playlist_id"])
token = k.get("reshuffle_token")
if self._reshuffle is not None and token != self._reshuffle:
log.info("Grund-Playlist wird neu gemischt")
self.playlist.neu_mischen()
self._reshuffle = token
stopp = bool(k.get("stopped"))
if stopp != self.stopp:
log.warning("Not-Aus %s", "AKTIV – Controller angehalten" if stopp else "aufgehoben")
self.stopp = stopp
return k.get("commands") or []
def befehle_ausfuehren(self, befehle: list[dict]) -> None:
for b in befehle:
if b["id"] in self._erledigt:
continue
if b["type"] in ("bt_scan", "bt_connect"):
self._erledigt.append(b["id"])
self._bt_befehl(b["id"], b["type"], b.get("payload") or {})
continue
try:
ok, meldung = self._befehl(b["type"], b.get("payload") or {})
except SpotifyFehler as e:
ok, meldung = False, str(e)[:200]
self._erledigt.append(b["id"])
log.info("Befehl %s: %s%s", b["type"], "ausgeführt" if ok else "fehlgeschlagen",
f" ({meldung})" if meldung else "")
if self.server:
self.server.quittieren(b["id"], ok, meldung)
# --- Lautsprecher wechseln ------------------------------------------------
def _bt_befehl(self, befehl_id: int, typ: str, payload: dict) -> None:
"""Suche/Kopplung dauert bis zu 45 s → eigener Thread, Quittung kommt danach."""
fehler = None
if not lautsprecher.verfuegbar():
fehler = "Kein Bluetooth auf diesem Player"
elif self._bt_aufgabe and self._bt_aufgabe.is_alive():
fehler = "Es läuft schon eine Suche oder Kopplung"
elif typ == "bt_connect" and not lautsprecher.MAC_RE.match(payload.get("mac") or ""):
fehler = "Ungültige Lautsprecher-Adresse"
if fehler:
log.info("Befehl %s: fehlgeschlagen (%s)", typ, fehler)
if self.server:
self.server.quittieren(befehl_id, False, fehler)
return
log.info("Befehl %s: läuft im Hintergrund", typ)
self._bt_aufgabe = threading.Thread(target=self._bt_arbeit, args=(befehl_id, typ, payload.get("mac")),
daemon=True)
self._bt_aufgabe.start()
def _bt_arbeit(self, befehl_id: int, typ: str, mac: str | None) -> None:
ok, meldung = False, "Unbekannter Fehler"
try:
self.bluetooth_melden(laeuft=True)
if typ == "bt_scan":
self._bt_geraete = lautsprecher.suchen()
self._bt_gesucht_um = datetime.now(timezone.utc).isoformat(timespec="seconds")
ok, meldung = True, f"{len(self._bt_geraete)} Audiogerät(e) gefunden"
else:
alt = self.bt_mac
if alt and alt != mac:
lautsprecher.trennen(alt) # sonst bleibt BlueALSA beim alten Lautsprecher
ok, meldung = lautsprecher.koppeln(mac)
if ok:
self.bt_mac = mac
self._bt_verbunden = None # beim nächsten Durchlauf frisch prüfen
if self.env_pfad:
env_setzen(self.env_pfad, "BT_SPEAKER_MAC", mac)
self._bt_geraete = [lautsprecher.info(g["mac"]) for g in self._bt_geraete]
elif alt and alt != mac:
meldung += " – alter Lautsprecher wird wieder verbunden"
lautsprecher.verbinden(alt)
except Exception as e: # Hintergrund-Thread darf nie still sterben
log.exception("Lautsprecher-Vorgang fehlgeschlagen")
ok, meldung = False, f"Fehler: {type(e).__name__}"
finally:
log.info("Befehl %s: %s (%s)", typ, "ausgeführt" if ok else "fehlgeschlagen", meldung)
if self.server:
self.server.quittieren(befehl_id, ok, meldung[:500])
self.bluetooth_melden(laeuft=False)
def bluetooth_melden(self, laeuft: bool | None = None) -> None:
"""Lautsprecher und Suchergebnis an den Server (Admin → Einstellungen → Lautsprecher)."""
if not self.server or not lautsprecher.verfuegbar():
return
if laeuft is None: # Aufruf aus der Hauptschleife: nur alle BT_MELDEN_S Sekunden
if time.monotonic() - self._bt_gemeldet < BT_MELDEN_S:
return
laeuft = bool(self._bt_aufgabe and self._bt_aufgabe.is_alive())
self._bt_gemeldet = time.monotonic()
aktuell = lautsprecher.oeffentlich(lautsprecher.info(self.bt_mac)) if self.bt_mac else None
self.server.bluetooth_melden({
"current": aktuell,
"devices": [lautsprecher.oeffentlich(g) for g in self._bt_geraete],
"scanning": laeuft,
"scanned_at": self._bt_gesucht_um,
})
def _befehl(self, typ: str, payload: dict) -> tuple[bool, str | None]:
gid = self.geraet_id()
if not gid:
return False, f"Gerät '{self.geraetename}' nicht gefunden"
if typ == "pause":
try:
self.sp.anfrage("PUT", "/me/player/pause", params={"device_id": gid})
except SpotifyFehler as e:
if e.status != 403: # 403 = war schon pausiert
raise
self.besitzer_pause = True
return True, None
if typ == "volume":
prozent = max(0, min(100, int(payload.get("percent", 50))))
self.sp.anfrage("PUT", "/me/player/volume", params={"volume_percent": prozent, "device_id": gid})
if self.st and self.st.get("device"):
self.st["device"]["volume_percent"] = prozent # gleich so melden, nicht erst nach der nächsten Abfrage
return True, f"{prozent} %"
if self.stopp:
return False, "Not-Aus ist aktiv"
if typ in ("play", "skip") and not self.lautsprecher_bereit():
return False, "Lautsprecher nicht verbunden"
if typ == "play":
st = self.sp.wiedergabe()
self.besitzer_pause = False
if st and (st.get("device") or {}).get("name") == self.geraetename and st.get("item") \
and (st.get("progress_ms") or 0) >= 2000:
self.sp.fortsetzen(gid) # pausierten Titel an derselben Stelle fortsetzen
else:
self.starten() # nichts Sinnvolles zum Fortsetzen → nächster Titel
return True, None
if typ == "skip":
if self.eingereiht:
self.sp.anfrage("POST", "/me/player/next", params={"device_id": gid})
else:
self.starten() # Nächster noch nicht eingereiht → direkt starten
self.besitzer_pause = False
return True, None
return False, f"Unbekannter Befehl: {typ}"
def melden(self) -> None:
"""Aktuellen Zustand an den Server ("Läuft gerade", Status für den Admin-Bereich)."""
if not self.server:
return
st = self.st or {}
geraet = st.get("device") or {}
unser = geraet.get("name") == self.geraetename
track = titel_info(st.get("item") or {}) if unser else None
naechster = self.eingereiht.titel if self.eingereiht else None
zustand = {
"track": track,
"progress_ms": (st.get("progress_ms") or 0) if track else 0,
"is_playing": bool(track and st.get("is_playing")),
"new_track": self.neuer_titel,
"source": self.aktuell_quelle if track else None,
"wish_id": self.aktuell_wunsch_id if track else None,
"next": naechster,
"device_ok": self.geraet_ok or unser,
"speaker_ok": self._bt_verbunden is not False,
"stopped": self.stopp,
"volume_percent": geraet.get("volume_percent") if unser else None,
"error": self.fehler,
}
if self.server.melden(zustand):
self.neuer_titel = False
# --- Hauptschritt -----------------------------------------------------
def schritt(self) -> None:
st = self.sp.wiedergabe()
self.st = st
geraet = (st or {}).get("device") or {}
item = (st or {}).get("item") or {}
unser = geraet.get("name") == self.geraetename
spielt = bool(st and st.get("is_playing") and item)
uri = item.get("uri")
fortschritt = (st or {}).get("progress_ms") or 0
rest = (item.get("duration_ms") or 0) - fortschritt
if unser:
self.geraet_ok = True
vorher = self.vorher
self.vorher = {"spielt": spielt, "unser": unser, "uri": uri, "rest": rest, "fortschritt": fortschritt,
"zeit": time.monotonic()}
if self.stopp:
# Not-Aus: anhalten und nichts selbst starten oder einreihen
if spielt and unser:
self.sp.anfrage("PUT", "/me/player/pause", params={"device_id": geraet["id"]})
log.warning("Not-Aus: Wiedergabe angehalten")
self.neustart = False
self.besitzer_pause = True # nach dem Aufheben nicht eigenmächtig starten
return
if spielt and not unser:
if not vorher or vorher["unser"] or not vorher["spielt"]:
log.info("Wiedergabe läuft auf '%s' – Controller wartet", geraet.get("name"))
return
if spielt:
self.besitzer_pause = False
self.bt_pause = False
self.gestartet_um = None
self._titelwechsel_pruefen(uri, fortschritt, item, vorher)
if self.eingereiht is None and rest < VORLAUF_MS:
eintrag = self.naechster_eintrag()
self.sp.einreihen(eintrag.uri, geraet["id"])
self.eingereiht = eintrag
self.fremdwechsel = 0
log.info("Als Nächstes eingereiht: %s [%s]", eintrag.uri, eintrag.quelle)
return
# --- es läuft nichts (auf Audiofy) ---
if self.bt_pause:
# librespot pausiert bei Audiofehlern selbst – nach Neuverbindung fortsetzen
if self.lautsprecher_bereit():
gid = self.geraet_id()
if gid:
self.sp.fortsetzen(gid)
self.bt_pause = False
log.info("Lautsprecher wieder da – Wiedergabe fortgesetzt")
return
if vorher is None:
# Controller-Start: pausierter Titel auf Audiofy = Besitzer-Pause, sonst starten
if st and unser and item and fortschritt >= PAUSE_MIN_MS:
if not self._bt_pruefen_jetzt():
log.warning("Audiofy pausiert und Lautsprecher getrennt – geht nach Neuverbindung weiter")
self.bt_pause = True
else:
log.info("Audiofy ist pausiert – warte, bis der Besitzer fortsetzt")
self.besitzer_pause = True
else:
self.neustart = True
elif vorher["spielt"] and vorher["unser"]:
# Titelende: rechnerisch erreicht (auch über einen Netzausfall hinweg) oder
# Spotify hat die Position nach dem Ende auf ~0 zurückgesetzt
vergangen_ms = (time.monotonic() - vorher["zeit"]) * 1000
titel_zu_ende = (vorher["rest"] - vergangen_ms <= ENDE_MS
or (uri == vorher["uri"] and fortschritt < 2000 and vorher["fortschritt"] > fortschritt + 2000))
if titel_zu_ende:
log.warning("Wiedergabe nach Titelende gestoppt – starte nächsten Titel")
self.neustart = True
elif st and unser and not self._bt_pruefen_jetzt():
log.warning("Lautsprecher getrennt – Wiedergabe geht nach Neuverbindung weiter")
self.bt_pause = True
elif st and unser:
log.info("Pausiert (Besitzer) – wird respektiert")
self.besitzer_pause = True
else:
log.warning("Gerät '%s' nicht mehr aktiv – starte Wiedergabe neu", self.geraetename)
self.neustart = True
elif self.gestartet_um and time.monotonic() - self.gestartet_um > START_TIMEOUT_S:
log.warning("Start hat nicht gegriffen – neuer Versuch")
self.neustart = True
if self.neustart and not self.besitzer_pause and self.lautsprecher_bereit():
self.starten()
def lautsprecher_bereit(self) -> bool:
"""Ohne verbundenen Lautsprecher nichts starten: librespot stürzt sonst ab (kein Audiogerät)."""
if not self.bt_mac:
return True
if self._bt_verbunden is None:
self._bt_verbunden = self._bt_pruefen_jetzt()
if self._bt_verbunden:
self._bt_verbunden_seit = time.monotonic() - BT_BEREIT_S
bereit = self._bt_verbunden and time.monotonic() - self._bt_verbunden_seit >= BT_BEREIT_S
if not bereit and time.monotonic() - self._lautsprecher_log >= 60:
log.warning("Lautsprecher nicht verbunden – Wiedergabe startet, sobald er wieder da ist")
self._lautsprecher_log = time.monotonic()
if bereit:
self._lautsprecher_log = 0.0
return bool(bereit)
def _titelwechsel_pruefen(self, uri: str, fortschritt: int, item: dict, vorher: dict | None) -> None:
gleicher_titel_neu = (vorher is not None and vorher["uri"] == uri and vorher["rest"] <= ENDE_MS
and fortschritt + 5000 < vorher["fortschritt"])
if uri == self.aktuell_uri and not gleicher_titel_neu:
return
self.aktuell_uri = uri
self.neuer_titel = True
# Spotify spielt manchmal eine Ersatzversion mit anderer ID (z. B. Remaster),
# ohne linked_from. Deshalb zusätzlich über Titel+Interpret vergleichen.
# Alte Reste in der Spotify-Queue gelten so als "fremd"; unser Eintrag bleibt
# offen und kommt direkt danach.
eintrag = self.eingereiht
passt = eintrag is not None and (eintrag.uri == uri or self.direktstart
or (eintrag.kennung is not None and eintrag.kennung == kennung(item)))
self.direktstart = False
self.aktuell_wunsch_id = None
if passt:
if eintrag.uri != uri:
log.info("Spotify spielt %s statt %s (Ersatzversion)", uri, eintrag.uri)
self.eingereiht = None
self.aktuell_quelle = eintrag.quelle
if eintrag.wunsch:
self.aktuell_wunsch_id = eintrag.wunsch.id
if self.server:
self.server.wunsch_gespielt(eintrag.wunsch)
if eintrag.quelle == "wish":
self.seit_wunsch = 0
elif eintrag.quelle == "playlist":
self.seit_wunsch += 1
# "owner" (Als Nächstes des Besitzers) zählt nicht im Wunsch-Takt
else:
self.aktuell_quelle = "fremd"
if eintrag:
self.fremdwechsel += 1
if self.fremdwechsel >= 3:
# Ein Wunsch bleibt auf dem Server offen und wird später erneut angeboten
log.warning("Eingereihter Titel %s kam nicht – wird verworfen", eintrag.uri)
self.eingereiht = None
self.fremdwechsel = 0
interpreten = ", ".join(a["name"] for a in item.get("artists", []))
bis_wunsch = max(0, self.n - 1 - self.seit_wunsch)
log.info("▶ %s – %s [%s] (Wunsch fällig in %d)", item.get("name"), interpreten, self.aktuell_quelle, bis_wunsch)
# --- Bluetooth --------------------------------------------------------
def _bt_pruefen_jetzt(self) -> bool:
"""Sofortige Prüfung, ob der Lautsprecher verbunden ist (ohne Lautsprecher: immer True)."""
if not self.bt_mac:
return True
try:
info = subprocess.run(["bluetoothctl", "info", self.bt_mac], capture_output=True, text=True, timeout=10)
verbunden = "Connected: yes" in info.stdout
except (OSError, subprocess.SubprocessError):
return True # im Zweifel als Besitzer-Pause behandeln
if not verbunden and self._bt_verbunden is not False:
log.warning("Bluetooth-Lautsprecher %s nicht verbunden – verbinde neu", self.bt_mac)
self._bt_verbunden = False
return verbunden
def bluetooth_pruefen(self) -> None:
"""Lautsprecher bei Verbindungsverlust neu verbinden (bluetoothctl, ohne sudo).
Der Verbindungsversuch läuft als Hintergrundprozess, damit die Hauptschleife
nicht blockiert. Bei getrenntem Lautsprecher wird in jedem Durchlauf geprüft.
"""
if not self.bt_mac or (self._bt_aufgabe and self._bt_aufgabe.is_alive()):
return
if self._bt_verbinden and self._bt_verbinden.poll() is not None:
self._bt_verbinden = None # Versuch beendet (Erfolg oder nicht)
jetzt = time.monotonic()
if self._bt_verbunden is not False and jetzt - self._bt_geprueft < BT_PRUEF_S:
return
self._bt_geprueft = jetzt
vorher = self._bt_verbunden
verbunden = self._bt_pruefen_jetzt()
self._bt_verbunden = verbunden
if vorher is not None and verbunden != vorher:
self._bt_gemeldet = 0.0 # Änderung gleich an den Server melden
if verbunden:
if vorher is not True:
self._bt_verbunden_seit = jetzt
if vorher is False:
log.info("Bluetooth-Lautsprecher wieder verbunden")
return
if self._bt_verbinden is None and jetzt - self._bt_versuch >= BT_NEU_S:
self._bt_versuch = jetzt
try:
self._bt_verbinden = subprocess.Popen(
["bluetoothctl", "--timeout", "15", "connect", self.bt_mac],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as e:
log.error("bluetoothctl nicht startbar: %s", e)
def main() -> None:
parser = argparse.ArgumentParser(description="Audiofy-Controller")
parser.add_argument("--env", type=Path, default=ORDNER / ".env")
args = parser.parse_args()
# Unter systemd setzt journald die Zeitstempel selbst
unter_systemd = "JOURNAL_STREAM" in os.environ
logging.basicConfig(level=logging.INFO, datefmt="%H:%M:%S",
format="%(levelname)-7s %(message)s" if unter_systemd else "%(asctime)s %(levelname)-7s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # keine Request-URLs im Log
env = env_lesen(args.env)
sp = SpotifyClient(env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"], env["SPOTIFY_REFRESH_TOKEN"],
neuer_refresh_token=lambda t: env_setzen(args.env, "SPOTIFY_REFRESH_TOKEN", t))
# alte Namen (VPS_URL, PI_API_TOKEN) weiter akzeptieren
server_url = env.get("SERVER_URL") or env.get("VPS_URL")
token = env.get("PLAYER_API_TOKEN") or env.get("PI_API_TOKEN")
server = ServerVerbindung(server_url, token) if server_url and token else None
if not server:
log.warning("SERVER_URL/PLAYER_API_TOKEN fehlen – nur Grund-Playlist, keine Wünsche")
controller = Controller(
sp,
Playlist(sp, env["SPOTIFY_PLAYLIST_ID"]),
server,
n=int(env.get("WISH_EVERY_N") or 3),
geraetename=env.get("DEVICE_NAME") or "Audiofy",
bt_mac=env.get("BT_SPEAKER_MAC") or None,
zustand_pfad=args.env.parent / "zustand.json",
env_pfad=args.env,
)
log.info("Controller gestartet (Gerät '%s', Server: %s)", controller.geraetename, server_url or "–")
backoff = POLL_S
while True:
try:
controller.bluetooth_pruefen()
befehle = controller.server_abgleich()
controller.schritt()
controller.befehle_ausfuehren(befehle)
controller.melden()
controller.bluetooth_melden()
controller.zustand_sichern()
backoff = POLL_S
time.sleep(POLL_S)
except KeyboardInterrupt:
log.info("Beendet")
return
except SpotifyFehler as e:
if e.retry_after:
log.warning("Spotify-Rate-Limit – warte %d s", e.retry_after)
time.sleep(e.retry_after)
continue
log.error("Spotify-Fehler %s – neuer Versuch in %d s", e, backoff)
time.sleep(backoff)
backoff = min(backoff * 2, MAX_BACKOFF_S)
except httpx.HTTPError as e:
log.error("Netzwerkfehler (%s) – neuer Versuch in %d s", type(e).__name__, backoff)
time.sleep(backoff)
backoff = min(backoff * 2, MAX_BACKOFF_S)
except RuntimeError as e:
log.error("%s – neuer Versuch in %d s", e, backoff)
controller.melden() # Fehler im Admin-Bereich sichtbar machen
time.sleep(backoff)
backoff = min(backoff * 2, MAX_BACKOFF_S)
except Exception:
log.exception("Unerwarteter Fehler – neuer Versuch in %d s", backoff)
time.sleep(backoff)
backoff = min(backoff * 2, MAX_BACKOFF_S)
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
"""Audiofy – Bluetooth-Lautsprecher suchen, koppeln und verbinden (bluetoothctl, ohne sudo).
Gekoppelt wird immer MIT Agent in derselben bluetoothctl-Sitzung – sonst speichert
BlueZ den Schlüssel nicht dauerhaft ("Bonded: no"), und der Lautsprecher lässt sich
nach dem Aus- und Einschalten nicht mehr verbinden ("Permission denied").
"""
import logging
import re
import shutil
import subprocess
import time
log = logging.getLogger("audiofy.lautsprecher")
MAC_RE = re.compile(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$")
SUCHDAUER_S = 15
MAX_GERAETE = 30
def verfuegbar() -> bool:
return shutil.which("bluetoothctl") is not None
def _ctl(*args: str, timeout: int = 20) -> str:
try:
return subprocess.run(["bluetoothctl", *args], capture_output=True, text=True, timeout=timeout).stdout
except (OSError, subprocess.SubprocessError) as e:
log.warning("bluetoothctl %s fehlgeschlagen: %s", args[0] if args else "", e)
return ""
def info(mac: str) -> dict:
"""Name und Zustand eines Geräts; "audio" = kann Musik abspielen (A2DP-Sink)."""
text = _ctl("info", mac, timeout=10)
name = re.search(r"^\s*Name:\s*(.+)$", text, re.M)
ja = lambda feld: re.search(rf"^\s*{feld}:\s*yes", text, re.M) is not None # noqa: E731
return {
"mac": mac,
"name": (name.group(1).strip() if name else "")[:100],
"paired": ja("Paired"),
"bonded": ja("Bonded"),
"connected": ja("Connected"),
"audio": "Audio Sink" in text or re.search(r"^\s*Icon:\s*audio", text, re.M) is not None,
}
def oeffentlich(geraet: dict) -> dict:
"""Nur die Felder, die an den Server gemeldet werden."""
return {k: geraet[k] for k in ("mac", "name", "paired", "connected")}
def suchen(dauer: int = SUCHDAUER_S) -> list[dict]:
"""Sucht Bluetooth-Geräte und liefert nur Audiogeräte (verbundene und gekoppelte zuerst)."""
_ctl("--timeout", str(dauer), "scan", "on", timeout=dauer + 10)
macs = re.findall(r"^Device\s+(([0-9A-F]{2}:){5}[0-9A-F]{2})", _ctl("devices"), re.M)
geraete = [g for g in (info(m[0]) for m in macs) if g["audio"]]
geraete.sort(key=lambda g: (not g["connected"], not g["paired"], g["name"].casefold()))
return geraete[:MAX_GERAETE]
def trennen(mac: str) -> None:
_ctl("disconnect", mac, timeout=15)
def verbinden(mac: str) -> bool:
_ctl("--timeout", "15", "connect", mac, timeout=25)
return info(mac)["connected"]
def koppeln(mac: str) -> tuple[bool, str]:
"""Koppelt (falls nötig, mit Agent), vertraut und verbindet. Liefert (ok, Meldung)."""
if not MAC_RE.match(mac):
return False, "Ungültige Adresse"
vorher = info(mac)
if not vorher["bonded"]:
# Eine interaktive Sitzung, damit der Agent während des Koppelns registriert bleibt
befehle = []
if vorher["paired"]:
befehle.append((f"remove {mac}", 2)) # halbe Kopplung ohne Schlüssel verwerfen
befehle += [("agent NoInputNoOutput", 1), ("default-agent", 1), ("pairable on", 1),
("scan on", 8), ("scan off", 1), (f"pair {mac}", 12), (f"trust {mac}", 2),
(f"connect {mac}", 10), ("quit", 1)]
p = None
try:
p = subprocess.Popen(["bluetoothctl"], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, text=True)
for befehl, warten in befehle:
p.stdin.write(befehl + "\n")
p.stdin.flush()
time.sleep(warten)
p.stdin.close()
p.wait(timeout=10)
except (OSError, subprocess.SubprocessError, BrokenPipeError) as e:
return False, f"Kopplung fehlgeschlagen ({type(e).__name__})"
finally:
if p is not None and p.poll() is None:
p.kill()
else:
_ctl("trust", mac)
jetzt = info(mac)
if not jetzt["connected"] and jetzt["bonded"]:
verbinden(mac)
jetzt = info(mac)
if jetzt["connected"] and jetzt["bonded"]:
return True, f"Verbunden mit {jetzt['name'] or mac}"
if not jetzt["bonded"]:
return False, "Kopplung fehlgeschlagen – ist der Lautsprecher im Kopplungsmodus?"
return False, "Gekoppelt, aber keine Verbindung – Lautsprecher eingeschaltet und in Reichweite?"
+1
View File
@@ -0,0 +1 @@
httpx==0.28.1
+94
View File
@@ -0,0 +1,94 @@
"""Audiofy – Verbindung Player → Server (Player-API mit Bearer-Token, nur ausgehend).
Alle Aufrufe fangen Fehler selbst ab: Ist der Server nicht erreichbar, liefern sie
None/False, und der Controller spielt einfach die Grund-Playlist weiter. Im
Offline-Fall wird nur alle OFFLINE_RETRY_S Sekunden ein neuer Versuch gemacht.
"""
import logging
import time
from dataclasses import dataclass
import httpx
log = logging.getLogger("audiofy.server")
OFFLINE_RETRY_S = 15
TIMEOUT_S = 5
@dataclass
class Wunsch:
id: int
uri: str
title: str = ""
artist: str = ""
priority: bool = False # "Als Nächstes" des Besitzers
class ServerVerbindung:
def __init__(self, basis_url: str, token: str):
self._http = httpx.Client(base_url=basis_url.rstrip("/") + "/api/player", timeout=TIMEOUT_S,
headers={"Authorization": f"Bearer {token}"})
self.online: bool | None = None
self._naechster_versuch = 0.0
self._unbestaetigt: set[int] = set() # gespielte Wünsche, deren Meldung noch fehlt
# --- intern ----------------------------------------------------------------
def _anfrage(self, methode: str, pfad: str, **kwargs):
"""JSON der Antwort oder None bei jedem Fehler (Netz, Timeout, HTTP-Fehler)."""
if self.online is False and time.monotonic() < self._naechster_versuch:
return None
try:
r = self._http.request(methode, pfad, **kwargs)
if r.status_code == 401:
self._offline("Token abgelehnt (401) – PLAYER_API_TOKEN prüfen")
return None
r.raise_for_status()
if self.online is not True:
log.info("Server erreichbar")
self.online = True
return r.json() if r.content else {}
except (httpx.HTTPError, ValueError) as e:
self._offline(f"{type(e).__name__}")
return None
def _offline(self, grund: str) -> None:
if self.online is not False:
log.warning("Server nicht erreichbar (%s) – spiele Grund-Playlist weiter, neuer Versuch alle %d s",
grund, OFFLINE_RETRY_S)
self.online = False
self._naechster_versuch = time.monotonic() + OFFLINE_RETRY_S
# --- Player-API ----------------------------------------------------------------
def konfiguration(self) -> dict | None:
return self._anfrage("GET", "/config")
def naechster_wunsch(self) -> Wunsch | None:
self._bestaetigungen_nachholen()
daten = self._anfrage("GET", "/next-wish")
w = (daten or {}).get("wish")
if not w or w["id"] in self._unbestaetigt:
return None # noch nicht als gespielt bestätigt → nicht doppelt spielen
return Wunsch(id=w["id"], uri=w["track_uri"], title=w.get("title", ""), artist=w.get("artist", ""),
priority=bool(w.get("priority")))
def wunsch_gespielt(self, wunsch: Wunsch) -> None:
self._unbestaetigt.add(wunsch.id)
self._bestaetigungen_nachholen()
def _bestaetigungen_nachholen(self) -> None:
for wid in list(self._unbestaetigt):
if self._anfrage("POST", f"/wish/{wid}/played") is not None:
self._unbestaetigt.discard(wid)
def melden(self, zustand: dict) -> bool:
return self._anfrage("POST", "/now-playing", json=zustand) is not None
def bluetooth_melden(self, zustand: dict) -> bool:
return self._anfrage("POST", "/bluetooth", json=zustand) is not None
def quittieren(self, befehl_id: int, ok: bool, meldung: str | None = None) -> None:
self._anfrage("POST", f"/commands/{befehl_id}/ack", json={"ok": ok, "message": meldung})
+128
View File
@@ -0,0 +1,128 @@
"""Audiofy – schlanker Spotify-Web-API-Client für den Controller (httpx).
Erneuert den Access-Token selbst per Refresh-Token. Fehler werden als
SpotifyFehler geworfen (bei 429 mit retry_after); Netzwerkfehler kommen als
httpx.HTTPError durch – die Wiederholung übernimmt der Controller.
"""
import base64
import logging
import time
from collections.abc import Callable
import httpx
log = logging.getLogger("audiofy.spotify")
API = "https://api.spotify.com/v1"
TOKEN_URL = "https://accounts.spotify.com/api/token"
class SpotifyFehler(Exception):
def __init__(self, status: int, nachricht: str, retry_after: int | None = None):
super().__init__(f"{status}: {nachricht}")
self.status = status
self.retry_after = retry_after
def _fehlertext(r: httpx.Response) -> str:
try:
fehler = r.json().get("error")
if isinstance(fehler, dict):
return fehler.get("message", "") + (f" ({fehler['reason']})" if fehler.get("reason") else "")
return str(r.json().get("error_description") or fehler)
except ValueError:
return r.text[:200]
class SpotifyClient:
def __init__(self, client_id: str, client_secret: str, refresh_token: str,
neuer_refresh_token: Callable[[str], None] | None = None):
self._basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
self._refresh_token = refresh_token
self._neuer_refresh_token = neuer_refresh_token
self._token: str | None = None
self._ablauf = 0.0
self._http = httpx.Client(timeout=10)
def _access_token(self) -> str:
if self._token and time.monotonic() < self._ablauf - 60:
return self._token
r = self._http.post(TOKEN_URL, headers={"Authorization": f"Basic {self._basic}"},
data={"grant_type": "refresh_token", "refresh_token": self._refresh_token})
if r.status_code != 200:
raise SpotifyFehler(r.status_code, "Token-Erneuerung fehlgeschlagen: " + _fehlertext(r))
daten = r.json()
self._token = daten["access_token"]
self._ablauf = time.monotonic() + daten.get("expires_in", 3600)
# Spotify kann den Refresh-Token rotieren – dann den neuen dauerhaft speichern
neu = daten.get("refresh_token")
if neu and neu != self._refresh_token:
self._refresh_token = neu
if self._neuer_refresh_token:
self._neuer_refresh_token(neu)
log.info("Neuer Refresh-Token von Spotify erhalten und gespeichert")
return self._token
def anfrage(self, methode: str, pfad: str, **kwargs):
"""Liefert das JSON der Antwort oder None (204, leerer oder Nicht-JSON-Body)."""
url = pfad if pfad.startswith("http") else API + pfad
for versuch in range(2):
r = self._http.request(methode, url, headers={"Authorization": f"Bearer {self._access_token()}"},
**kwargs)
if r.status_code == 401 and versuch == 0:
self._token = None # Token abgelaufen/ungültig → einmal erneuern
continue
if r.status_code == 429:
raise SpotifyFehler(429, "Rate-Limit", int(r.headers.get("Retry-After", "5")))
if r.status_code >= 400:
raise SpotifyFehler(r.status_code, _fehlertext(r))
if not r.content:
return None
try:
return r.json()
except ValueError:
# z. B. PUT /me/player/pause liefert teils 200 mit Nicht-JSON-Body
return None
raise SpotifyFehler(401, "Nicht autorisiert")
# --- Wiedergabe -------------------------------------------------------
def wiedergabe(self) -> dict | None:
"""GET /me/player – None, wenn gerade nichts aktiv ist (204)."""
return self.anfrage("GET", "/me/player", params={"market": "from_token"})
def geraete(self) -> list[dict]:
return (self.anfrage("GET", "/me/player/devices") or {}).get("devices", [])
def einreihen(self, uri: str, device_id: str) -> None:
self.anfrage("POST", "/me/player/queue", params={"uri": uri, "device_id": device_id})
def titel(self, uri: str) -> dict:
"""Metadaten eines Titels (GET /tracks/{id})."""
return self.anfrage("GET", f"/tracks/{uri.rsplit(':', 1)[-1]}", params={"market": "from_token"}) or {}
def fortsetzen(self, device_id: str) -> None:
"""Pausierte Wiedergabe auf dem Gerät fortsetzen (ohne Titelwechsel)."""
self.anfrage("PUT", "/me/player/play", params={"device_id": device_id})
def abspielen(self, uris: list[str], device_id: str) -> None:
self.anfrage("PUT", "/me/player/play", params={"device_id": device_id}, json={"uris": uris})
# --- Playlist ---------------------------------------------------------
def playlist_titel(self, playlist_id: str) -> list[str]:
"""Alle abspielbaren Track-URIs der Playlist (seit 2026 nur über /items, Feld "item")."""
uris: list[str] = []
pfad: str | None = f"/playlists/{playlist_id}/items"
params: dict | None = {"limit": 50, "market": "from_token"}
while pfad:
daten = self.anfrage("GET", pfad, params=params) or {}
for eintrag in daten.get("items", []):
titel = eintrag.get("item") or eintrag.get("track") or {}
uri = titel.get("uri", "")
if titel.get("type") == "track" and uri.startswith("spotify:track:") \
and titel.get("is_playable", True):
uris.append(uri)
pfad, params = daten.get("next"), None # "next" enthält die Query schon
return uris
+1
View File
@@ -0,0 +1 @@
"""Audiofy-Web – Website, Admin-Bereich und Player-API (läuft auf dem Server)."""
+408
View File
@@ -0,0 +1,408 @@
"""Audiofy-Web – Admin-Bereich (/admin): der Besitzer steuert alles, auch vom Handy.
Zugang:
- Passwort als argon2-Hash in der .env (ADMIN_PASSWORD_HASH, gesetzt per verwaltung.py)
- Login-Versuche: max. 5 pro 15 min pro IP
- Sitzungs-Cookie HttpOnly, Secure, SameSite=Strict; in der DB nur als SHA-256-Hash
- jede ändernde Aktion braucht den CSRF-Token der Sitzung (Header X-CSRF-Token)
Befehle an den Player (play, pause, skip, volume, bt_scan, bt_connect) landen in der
Tabelle commands und werden vom Controller mit /api/player/config abgeholt.
"""
import hashlib
import hmac
import io
import re
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Literal
import segno
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel, Field
from .config import konfig
from .db import (BEFEHL_GUELTIG_S, befehl_anlegen, einstellung_setzen, einstellungen, get_db, jetzt,
neuer_zugangs_token)
from .gaeste import (PLAYER_OFFLINE_S, STATIC, Drossel, gleiche_herkunft, ip_hash, zeitpunkt)
from .player_api import player_zustand
from . import turnstile
from .spotify import Spotify, SpotifyFehler, spotify
router = APIRouter()
ADMIN_COOKIE = "audiofy_admin"
SITZUNG_TAGE = 14
login_drossel = Drossel(5, 15 * 60)
_hasher = PasswordHasher()
PLAYLIST_RE = re.compile(r"(?:playlist[/:])([A-Za-z0-9]{22})|^([A-Za-z0-9]{22})$")
# Einstellungen, die direkt per /admin/api/settings geändert werden dürfen (Typ, Min, Max)
AENDERBAR = {
"wish_every_n": (int, 1, 20),
"wish_limit_count": (int, 1, 50),
"wish_limit_minutes": (int, 1, 1440),
"repeat_block_minutes": (int, 0, 1440),
"moderation": (bool, None, None),
"wishes_paused": (bool, None, None),
"block_explicit": (bool, None, None),
}
# --- Sitzung ---------------------------------------------------------------
def _hash(wert: str) -> str:
return hashlib.sha256(wert.encode()).hexdigest()
def sitzung(request: Request, db: sqlite3.Connection) -> sqlite3.Row | None:
wert = request.cookies.get(ADMIN_COOKIE)
if not wert or len(wert) > 100:
return None
return db.execute("SELECT * FROM admin_sessions WHERE id = ? AND expires_at > ?", (_hash(wert), jetzt())).fetchone()
def admin_pflicht(request: Request, db: sqlite3.Connection = Depends(get_db)) -> sqlite3.Row:
s = sitzung(request, db)
if s is None:
raise HTTPException(401, "Bitte anmelden.")
if request.method not in ("GET", "HEAD"):
gleiche_herkunft(request)
if not hmac.compare_digest(request.headers.get("x-csrf-token", ""), s["csrf"]):
raise HTTPException(403, "Sicherheitsprüfung fehlgeschlagen – Seite neu laden.")
return s
api = APIRouter(prefix="/admin/api", dependencies=[Depends(admin_pflicht)])
# --- Seiten & Login --------------------------------------------------------
def _seite(datei: str) -> FileResponse:
return FileResponse(STATIC / datei, headers={"Cache-Control": "no-store", "X-Robots-Tag": "noindex"})
@router.get("/admin", include_in_schema=False)
def admin_seite(request: Request, db: sqlite3.Connection = Depends(get_db)):
if sitzung(request, db) is None:
return RedirectResponse("/admin/login", status_code=303)
return _seite("admin.html")
@router.get("/admin/login", include_in_schema=False)
def login_seite(request: Request, db: sqlite3.Connection = Depends(get_db)):
if sitzung(request, db) is not None:
return RedirectResponse("/admin", status_code=303)
return _seite("login.html")
class Login(BaseModel):
password: str = Field(min_length=1, max_length=200)
turnstile: str | None = Field(default=None, max_length=2048)
@router.post("/admin/login", include_in_schema=False)
def login(daten: Login, request: Request, response: Response, db: sqlite3.Connection = Depends(get_db)) -> dict:
gleiche_herkunft(request)
if not login_drossel.erlaubt(ip_hash(request)):
raise HTTPException(429, "Zu viele Anmeldeversuche – bitte 15 Minuten warten.")
gespeichert = konfig().admin_passwort_hash
if not gespeichert:
raise HTTPException(503, "Es ist noch kein Admin-Passwort gesetzt.")
turnstile.pruefen(daten.turnstile, request, "login", bei_ausfall_erlauben=False)
try:
_hasher.verify(gespeichert, daten.password)
except (VerificationError, InvalidHashError):
raise HTTPException(401, "Falsches Passwort.")
wert = secrets.token_urlsafe(32)
ablauf = jetzt(timedelta(days=SITZUNG_TAGE))
with db:
db.execute("DELETE FROM admin_sessions WHERE expires_at <= ?", (jetzt(),))
db.execute("INSERT INTO admin_sessions (id, csrf, created_at, expires_at) VALUES (?, ?, ?, ?)",
(_hash(wert), secrets.token_urlsafe(24), jetzt(), ablauf))
response.set_cookie(ADMIN_COOKIE, wert, max_age=SITZUNG_TAGE * 86400, httponly=True,
secure=konfig().cookie_secure, samesite="strict", path="/")
return {"ok": True}
@api.post("/logout")
def logout(request: Request, response: Response, db: sqlite3.Connection = Depends(get_db)) -> dict:
with db:
db.execute("DELETE FROM admin_sessions WHERE id = ?", (_hash(request.cookies.get(ADMIN_COOKIE, "")),))
response.delete_cookie(ADMIN_COOKIE, path="/")
return {"ok": True}
# --- Gesamtzustand -----------------------------------------------------------
def _wunsch(r: sqlite3.Row) -> dict:
return {k: r[k] for k in ("id", "track_uri", "title", "artist", "cover_url", "guest_name", "status",
"priority", "explicit", "created_at")} | {"has_guest": bool(r["guest_id"] or r["guest_ip"])}
@api.get("/state")
def zustand(s: sqlite3.Row = Depends(admin_pflicht), db: sqlite3.Connection = Depends(get_db)) -> dict:
e = einstellungen(db)
pi = player_zustand(db)
zuletzt = zeitpunkt(pi["last_seen"])
alter_s = (datetime.now(timezone.utc) - zuletzt).total_seconds() if zuletzt else None
gemeldet = zeitpunkt(pi["updated_at"])
grenze = jetzt(-timedelta(seconds=BEFEHL_GUELTIG_S))
befehle = [{"id": r["id"], "type": r["type"], "created_at": r["created_at"],
"status": ("ok" if r["ok"] else "fehler") if r["acked_at"] else
("wartet" if r["created_at"] >= grenze else "abgelaufen"),
"message": r["message"]}
for r in db.execute("SELECT * FROM commands ORDER BY id DESC LIMIT 8")]
return {
"csrf": s["csrf"],
"player": {
"online": alter_s is not None and alter_s < PLAYER_OFFLINE_S,
"last_seen_s": None if alter_s is None else int(alter_s),
"state": pi["state"],
"bluetooth": pi["bluetooth"],
"age_ms": int((datetime.now(timezone.utc) - gemeldet).total_seconds() * 1000) if gemeldet else None,
},
"settings": {k: v for k, v in e.items() if k != "access_token"},
"queue": [_wunsch(r) for r in db.execute("SELECT * FROM wishes WHERE status = 'queued' "
"ORDER BY priority DESC, position, id")],
"pending": [_wunsch(r) for r in db.execute("SELECT * FROM wishes WHERE status = 'pending' "
"ORDER BY position, id")],
"commands": befehle,
}
# --- Wiedergabe --------------------------------------------------------------
class Befehl(BaseModel):
type: Literal["play", "pause", "skip", "volume", "bt_scan", "bt_connect"]
percent: int | None = Field(default=None, ge=0, le=100)
mac: str | None = Field(default=None, pattern=r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$")
@api.post("/command")
def befehl(b: Befehl, db: sqlite3.Connection = Depends(get_db)) -> dict:
if b.type == "volume" and b.percent is None:
raise HTTPException(422, "Lautstärke fehlt.")
if b.type == "bt_connect" and not b.mac:
raise HTTPException(422, "Lautsprecher fehlt.")
payload = {"percent": b.percent} if b.type == "volume" else {"mac": b.mac} if b.type == "bt_connect" else {}
nr = befehl_anlegen(db, b.type, payload)
return {"ok": True, "id": nr}
class NotAus(BaseModel):
stop: bool
@api.post("/emergency")
def not_aus(n: NotAus, db: sqlite3.Connection = Depends(get_db)) -> dict:
"""Not-Aus: Wiedergabe stoppen und Controller anhalten, bis wieder freigegeben."""
einstellung_setzen(db, "emergency_stop", n.stop)
befehl_anlegen(db, "pause" if n.stop else "play")
return {"ok": True}
@api.get("/search")
def suche(q: str = Query(min_length=2, max_length=100), sp: Spotify = Depends(spotify)) -> dict:
if not sp.verfuegbar():
raise HTTPException(503, "Spotify-Suche ist nicht eingerichtet.")
try:
return {"results": sp.suche(q)}
except SpotifyFehler:
raise HTTPException(503, "Spotify ist gerade nicht erreichbar.")
class TitelWahl(BaseModel):
track_uri: str = Field(pattern=r"^spotify:track:[A-Za-z0-9]{22}$")
@api.post("/play-next")
def als_naechstes(t: TitelWahl, db: sqlite3.Connection = Depends(get_db), sp: Spotify = Depends(spotify)) -> dict:
"""Besitzer-Priorität: vor alle Wünsche, ohne Limits und Sperren."""
try:
titel = sp.titel(t.track_uri)
except SpotifyFehler:
raise HTTPException(503, "Titel konnte nicht geladen werden.")
with db:
pos = db.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM wishes").fetchone()[0]
db.execute("INSERT INTO wishes (track_uri, title, artist, album, cover_url, duration_ms, explicit, "
"guest_name, status, priority, position, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'Besitzer', 'queued', 1, ?, ?)",
(titel["track_uri"], titel["title"], titel["artist"], titel["album"], titel["cover_url"],
titel["duration_ms"], int(titel["explicit"]), pos, jetzt()))
return {"ok": True}
# --- Wünsche -----------------------------------------------------------------
@api.post("/wish/{wish_id}/{aktion}")
def wunsch_aktion(wish_id: int, aktion: Literal["approve", "reject", "delete", "up", "down", "block-guest"],
db: sqlite3.Connection = Depends(get_db)) -> dict:
w = db.execute("SELECT * FROM wishes WHERE id = ?", (wish_id,)).fetchone()
if w is None:
raise HTTPException(404, "Wunsch nicht gefunden.")
with db:
if aktion == "approve":
db.execute("UPDATE wishes SET status = 'queued' WHERE id = ? AND status = 'pending'", (wish_id,))
elif aktion == "reject":
db.execute("UPDATE wishes SET status = 'rejected' WHERE id = ? AND status = 'pending'", (wish_id,))
elif aktion == "delete":
db.execute("UPDATE wishes SET status = 'deleted' WHERE id = ? AND status IN ('pending', 'queued')",
(wish_id,))
elif aktion in ("up", "down"):
# mit dem Nachbarn gleicher Liste (Status + Priorität) die Position tauschen
vergleich, ordnung = ("<", "DESC") if aktion == "up" else (">", "ASC")
n = db.execute(f"SELECT id, position FROM wishes WHERE status = ? AND priority = ? "
f"AND (position {vergleich} ? OR (position = ? AND id {vergleich} ?)) "
f"ORDER BY position {ordnung}, id {ordnung} LIMIT 1",
(w["status"], w["priority"], w["position"], w["position"], wish_id)).fetchone()
if n:
if n["position"] == w["position"]: # gleiche Position → eindeutig machen
db.execute("UPDATE wishes SET position = position + 0.5 WHERE id = ?",
(wish_id if aktion == "down" else n["id"],))
else:
db.execute("UPDATE wishes SET position = ? WHERE id = ?", (n["position"], wish_id))
db.execute("UPDATE wishes SET position = ? WHERE id = ?", (w["position"], n["id"]))
elif aktion == "block-guest":
label = w["guest_name"] or f"Gast von „{w['title']}“"
for art, wert in (("guest", w["guest_id"]), ("ip", w["guest_ip"])):
if wert:
db.execute("INSERT OR IGNORE INTO blocklist (kind, value, label, created_at) VALUES (?, ?, ?, ?)",
(art, wert, label, jetzt()))
# offene Wünsche dieses Gasts gleich mit entfernen
db.execute("UPDATE wishes SET status = 'deleted' WHERE status IN ('pending', 'queued') "
"AND ((guest_id IS NOT NULL AND guest_id = ?) OR (guest_ip IS NOT NULL AND guest_ip = ?))",
(w["guest_id"], w["guest_ip"]))
return {"ok": True}
# --- Sperrliste ----------------------------------------------------------------
class Sperre(BaseModel):
kind: Literal["track", "artist"]
value: str = Field(pattern=r"^spotify:(track|artist):[A-Za-z0-9]{22}$")
label: str = Field(max_length=300)
@api.get("/blocklist")
def sperrliste(db: sqlite3.Connection = Depends(get_db)) -> dict:
return {"items": [{"id": r["id"], "kind": r["kind"], "label": r["label"], "created_at": r["created_at"]}
for r in db.execute("SELECT * FROM blocklist ORDER BY kind, id DESC")]}
@api.post("/blocklist")
def sperren(sp: Sperre, db: sqlite3.Connection = Depends(get_db)) -> dict:
if not sp.value.startswith(f"spotify:{sp.kind}:"):
raise HTTPException(422, "Art und Wert passen nicht zusammen.")
with db:
db.execute("INSERT OR IGNORE INTO blocklist (kind, value, label, created_at) VALUES (?, ?, ?, ?)",
(sp.kind, sp.value, sp.label, jetzt()))
return {"ok": True}
@api.post("/blocklist/{eintrag_id}/delete")
def entsperren(eintrag_id: int, db: sqlite3.Connection = Depends(get_db)) -> dict:
with db:
db.execute("DELETE FROM blocklist WHERE id = ?", (eintrag_id,))
return {"ok": True}
# --- Einstellungen -------------------------------------------------------------
@api.post("/settings")
def einstellungen_aendern(werte: dict, db: sqlite3.Connection = Depends(get_db)) -> dict:
for key, wert in werte.items():
if key not in AENDERBAR:
raise HTTPException(422, f"Unbekannte Einstellung: {key}")
typ, minimum, maximum = AENDERBAR[key]
if typ is bool and not isinstance(wert, bool):
raise HTTPException(422, f"{key}: ja/nein erwartet")
if typ is int and (isinstance(wert, bool) or not isinstance(wert, int) or not minimum <= wert <= maximum):
raise HTTPException(422, f"{key}: Zahl von {minimum} bis {maximum} erwartet")
for key, wert in werte.items():
einstellung_setzen(db, key, wert)
return {"ok": True}
class PlaylistLink(BaseModel):
link: str = Field(max_length=300)
@api.post("/playlist")
def playlist_wechseln(p: PlaylistLink, db: sqlite3.Connection = Depends(get_db)) -> dict:
treffer = PLAYLIST_RE.search(p.link.strip())
if not treffer:
raise HTTPException(422, "Das sieht nicht nach einem Spotify-Playlist-Link aus.")
playlist_id = treffer.group(1) or treffer.group(2)
einstellung_setzen(db, "playlist_id", playlist_id)
einstellung_setzen(db, "reshuffle_token", einstellungen(db)["reshuffle_token"] + 1)
return {"ok": True, "playlist_id": playlist_id}
@api.post("/reshuffle")
def neu_mischen(db: sqlite3.Connection = Depends(get_db)) -> dict:
einstellung_setzen(db, "reshuffle_token", einstellungen(db)["reshuffle_token"] + 1)
return {"ok": True}
# --- Gastzugang (Einladungslink / QR-Code) ---------------------------------------
def _einladungslink(request: Request, token: str) -> str:
basis = konfig().public_url or str(request.base_url).rstrip("/")
return f"{basis}/z/{token}"
@api.get("/access")
def zugang(request: Request, db: sqlite3.Connection = Depends(get_db)) -> dict:
e = einstellungen(db)
return {"enabled": e["access_enabled"], "link": _einladungslink(request, e["access_token"])}
class ZugangSchalter(BaseModel):
enabled: bool
@api.post("/access")
def zugang_schalten(z: ZugangSchalter, db: sqlite3.Connection = Depends(get_db)) -> dict:
einstellung_setzen(db, "access_enabled", z.enabled)
return {"ok": True}
@api.post("/access/renew")
def zugang_erneuern(db: sqlite3.Connection = Depends(get_db)) -> dict:
"""Neuer Link: der alte Link und alle damit erteilten Zugänge werden ungültig."""
einstellung_setzen(db, "access_token", neuer_zugangs_token())
return {"ok": True}
@api.get("/access/qr")
def zugang_qr(request: Request, format: Literal["svg", "png"] = "svg",
db: sqlite3.Connection = Depends(get_db)) -> Response:
qr = segno.make(_einladungslink(request, einstellungen(db)["access_token"]), error="m")
puffer = io.BytesIO()
if format == "png":
qr.save(puffer, kind="png", scale=16, border=3)
return Response(puffer.getvalue(), media_type="image/png", headers={
"Content-Disposition": 'attachment; filename="audiofy-einladung.png"', "Cache-Control": "no-store"})
qr.save(puffer, kind="svg", scale=8, border=2, xmldecl=False, svgns=True)
return Response(puffer.getvalue(), media_type="image/svg+xml", headers={"Cache-Control": "no-store"})
# --- Verlauf -------------------------------------------------------------------
@api.get("/history")
def verlauf(limit: int = Query(default=100, ge=1, le=500), db: sqlite3.Connection = Depends(get_db)) -> dict:
zeilen = db.execute("SELECT h.*, w.guest_name FROM history h LEFT JOIN wishes w ON w.id = h.wish_id "
"ORDER BY h.id DESC LIMIT ?", (limit,))
return {"items": [{"title": r["title"], "artist": r["artist"], "cover_url": r["cover_url"],
"source": r["source"], "guest_name": r["guest_name"], "started_at": r["started_at"]}
for r in zeilen]}
router.include_router(api)
+70
View File
@@ -0,0 +1,70 @@
"""Audiofy-Web – Konfiguration aus der .env.
Pfad über die Umgebungsvariable AUDIOFY_ENV, Standard /opt/audiofy/.env.
Geheimnisse stehen nur dort (Rechte 600), nie im Code oder in Logs.
"""
import os
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
def env_lesen(pfad: Path) -> dict:
"""Einfacher KEY=VALUE-Parser (Kommentare und Anführungszeichen erlaubt)."""
werte = {}
for zeile in pfad.read_text().splitlines():
zeile = zeile.strip()
if not zeile or zeile.startswith("#") or "=" not in zeile:
continue
key, _, wert = zeile.partition("=")
werte[key.strip()] = wert.strip().strip('"').strip("'")
return werte
@dataclass(frozen=True)
class Konfig:
env_pfad: Path
db_pfad: Path
player_api_token: str
spotify_client_id: str
spotify_client_secret: str
spotify_refresh_token: str
admin_passwort_hash: str
session_secret: str
playlist_id: str # nur Startwert – danach gilt die Einstellung in der Datenbank
cookie_secure: bool = True # nur für lokale Tests über http auf 0 setzen
public_url: str = "" # z. B. https://musik.example.org (für Einladungslink/QR-Code)
turnstile_site_key: str = "" # Cloudflare Turnstile (öffentlich)
turnstile_secret: str = "" # Cloudflare Turnstile (geheim)
def env_wert_setzen(pfad: Path, key: str, wert: str) -> None:
"""Setzt KEY=wert in der .env – direkt in der Datei (der Ordner ist für den Dienst nicht beschreibbar)."""
zeilen = pfad.read_text().splitlines() if pfad.is_file() else []
neu = [z for z in zeilen if not z.strip().startswith(f"{key}=")]
neu.append(f"{key}={wert}")
fd = os.open(pfad, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write("\n".join(neu) + "\n")
@lru_cache
def konfig() -> Konfig:
pfad = Path(os.environ.get("AUDIOFY_ENV", "/opt/audiofy/.env"))
werte = env_lesen(pfad) if pfad.is_file() else {}
return Konfig(
env_pfad=pfad,
db_pfad=Path(werte.get("DB_PATH") or "/opt/audiofy/data/audiofy.db"),
player_api_token=werte.get("PLAYER_API_TOKEN") or werte.get("PI_API_TOKEN", ""), # alter Name
spotify_client_id=werte.get("SPOTIFY_CLIENT_ID", ""),
spotify_client_secret=werte.get("SPOTIFY_CLIENT_SECRET", ""),
spotify_refresh_token=werte.get("SPOTIFY_REFRESH_TOKEN", ""),
admin_passwort_hash=werte.get("ADMIN_PASSWORD_HASH", ""),
session_secret=werte.get("SESSION_SECRET", ""),
playlist_id=werte.get("SPOTIFY_PLAYLIST_ID", ""),
cookie_secure=werte.get("COOKIE_SECURE", "1") != "0",
public_url=werte.get("PUBLIC_URL", "").rstrip("/"),
turnstile_site_key=werte.get("TURNSTILE_SITE_KEY", ""),
turnstile_secret=werte.get("TURNSTILE_SECRET_KEY", ""),
)
+201
View File
@@ -0,0 +1,201 @@
"""Audiofy-Web – SQLite-Datenbank (audiofy.db): Schema, Einstellungen, Hilfsfunktionen.
Zeitstempel sind UTC im ISO-Format (lexikografisch vergleichbar).
"""
import json
import secrets
import sqlite3
from collections.abc import Iterator
from datetime import datetime, timedelta, timezone
from pathlib import Path
from .config import konfig
SCHEMA = """
-- Einstellungen des Besitzers (Wert als JSON)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Wünsche der Gäste und "Als Nächstes" des Besitzers (priority = 1)
-- status: pending = zur Freigabe (Moderation), queued = Warteschlange,
-- played = gespielt, rejected = abgelehnt, deleted = vom Besitzer gelöscht
CREATE TABLE IF NOT EXISTS wishes (
id INTEGER PRIMARY KEY,
track_uri TEXT NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
cover_url TEXT,
duration_ms INTEGER,
explicit INTEGER NOT NULL DEFAULT 0,
guest_id TEXT, -- zufällige Cookie-ID des Geräts
guest_ip TEXT, -- gehashte IP (nie im Klartext)
guest_name TEXT, -- optionaler Spitzname
status TEXT NOT NULL CHECK (status IN ('pending', 'queued', 'played', 'rejected', 'deleted')),
priority INTEGER NOT NULL DEFAULT 0,
position REAL NOT NULL, -- Reihenfolge innerhalb der Warteschlange
created_at TEXT NOT NULL,
played_at TEXT
);
CREATE INDEX IF NOT EXISTS wishes_status ON wishes (status, priority DESC, position);
CREATE INDEX IF NOT EXISTS wishes_guest ON wishes (guest_id, created_at);
-- Verlauf: was wann lief (source: playlist, wish, owner, fremd)
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY,
track_uri TEXT NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
cover_url TEXT,
source TEXT,
wish_id INTEGER REFERENCES wishes (id),
started_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS history_started ON history (started_at);
CREATE INDEX IF NOT EXISTS history_track ON history (track_uri, started_at);
-- Befehle des Besitzers an den Player (play, pause, skip, volume, …)
CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
acked_at TEXT,
ok INTEGER,
message TEXT
);
-- Sperrliste (kind: track, artist, guest, ip)
CREATE TABLE IF NOT EXISTS blocklist (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('track', 'artist', 'guest', 'ip')),
value TEXT NOT NULL,
label TEXT,
created_at TEXT NOT NULL,
UNIQUE (kind, value)
);
-- Admin-Sitzungen (id = SHA-256 des Cookie-Werts, nie der Klartext)
CREATE TABLE IF NOT EXISTS admin_sessions (
id TEXT PRIMARY KEY,
csrf TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
-- Letzter gemeldeter Zustand des Players (genau eine Zeile)
CREATE TABLE IF NOT EXISTS pi_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
track_uri TEXT,
payload TEXT NOT NULL DEFAULT '{}',
updated_at TEXT, -- letzte "läuft gerade"-Meldung
last_seen TEXT -- letzter Kontakt überhaupt
);
"""
STANDARD_EINSTELLUNGEN = {
"wish_every_n": 3, # jeder N-te Titel ist ein Wunsch
"playlist_id": "", # Grund-Playlist
"reshuffle_token": 0, # Erhöhen = Playlist neu mischen
"emergency_stop": False, # Not-Aus
"moderation": False, # Wünsche erst nach Freigabe
"wishes_paused": False, # Wunschannahme pausiert
"wish_limit_count": 2, # max. Wünsche pro Gast …
"wish_limit_minutes": 30, # … pro Zeitraum
"repeat_block_minutes": 60, # Sperrzeit für Wiederholungen
"block_explicit": False, # explizite Titel nicht erlauben
"access_enabled": False, # Gastzugang per Einladungslink/QR-Code (anfangs aus)
"access_token": "", # geheimer Teil des Einladungslinks /z/<token>
}
BEFEHL_GUELTIG_S = 60 # nicht abgeholte Befehle verfallen danach (kein verspätetes Überspringen)
def jetzt(delta: timedelta = timedelta()) -> str:
return (datetime.now(timezone.utc) + delta).isoformat(timespec="seconds")
def verbinden(pfad: Path | None = None) -> sqlite3.Connection:
conn = sqlite3.connect(pfad or konfig().db_pfad, timeout=10, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def initialisieren(pfad: Path, playlist_id: str = "") -> None:
pfad.parent.mkdir(parents=True, exist_ok=True)
conn = verbinden(pfad)
try:
conn.execute("PRAGMA journal_mode = WAL")
with conn:
conn.executescript(SCHEMA)
standard = dict(STANDARD_EINSTELLUNGEN, playlist_id=playlist_id)
conn.executemany("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
[(k, json.dumps(v)) for k, v in standard.items()])
conn.execute("INSERT OR IGNORE INTO pi_state (id) VALUES (1)")
# Nachrüsten für bestehende Datenbanken: Bluetooth-Zustand des Players
spalten = {r[1] for r in conn.execute("PRAGMA table_info(pi_state)")}
if "bluetooth" not in spalten:
conn.execute("ALTER TABLE pi_state ADD COLUMN bluetooth TEXT NOT NULL DEFAULT '{}'")
if not json.loads(conn.execute("SELECT value FROM settings WHERE key = 'access_token'").fetchone()[0]):
conn.execute("UPDATE settings SET value = ? WHERE key = 'access_token'",
(json.dumps(neuer_zugangs_token()),))
finally:
conn.close()
def neuer_zugangs_token() -> str:
"""Kurz genug für einen gut scanbaren QR-Code, lang genug gegen Raten (72 Bit)."""
return secrets.token_urlsafe(9)
def get_db() -> Iterator[sqlite3.Connection]:
"""FastAPI-Dependency: eine Verbindung pro Anfrage."""
conn = verbinden()
try:
yield conn
finally:
conn.close()
# --- Einstellungen ---------------------------------------------------------
def einstellungen(conn: sqlite3.Connection) -> dict:
werte = dict(STANDARD_EINSTELLUNGEN)
werte.update({r["key"]: json.loads(r["value"]) for r in conn.execute("SELECT key, value FROM settings")})
return werte
def einstellung_setzen(conn: sqlite3.Connection, key: str, wert) -> None:
if key not in STANDARD_EINSTELLUNGEN:
raise KeyError(key)
with conn:
conn.execute("INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value", (key, json.dumps(wert)))
# --- Befehle an den Player -----------------------------------------------------
def befehl_anlegen(conn: sqlite3.Connection, typ: str, payload: dict | None = None) -> int:
with conn:
cur = conn.execute("INSERT INTO commands (type, payload, created_at) VALUES (?, ?, ?)",
(typ, json.dumps(payload or {}), jetzt()))
return cur.lastrowid
def offene_befehle(conn: sqlite3.Connection) -> list[dict]:
grenze = jetzt(-timedelta(seconds=BEFEHL_GUELTIG_S))
zeilen = conn.execute("SELECT id, type, payload, created_at FROM commands "
"WHERE acked_at IS NULL AND created_at >= ? ORDER BY id", (grenze,))
return [{"id": r["id"], "type": r["type"], "payload": json.loads(r["payload"]), "created_at": r["created_at"]}
for r in zeilen]
# --- Player-Zustand ------------------------------------------------------------
def lebenszeichen(conn: sqlite3.Connection) -> None:
with conn:
conn.execute("UPDATE pi_state SET last_seen = ? WHERE id = 1", (jetzt(),))
+335
View File
@@ -0,0 +1,335 @@
"""Audiofy-Web – Gastseite: Suche, Wünschen, "Läuft gerade" und Warteschlange.
Zugang nur mit Einladung: Der Link /z/<token> (auch als QR-Code im Admin-Bereich)
setzt ein Zugangs-Cookie. Ohne gültigen Zugang gibt es nur eine Hinweisseite, die
API antwortet mit 401. Zugang aus oder neuer Link → alle bisherigen Zugänge ungültig.
Gäste melden sich nie bei Spotify an. Ein Gerät wird über ein zufälliges Cookie
erkannt, zusätzlich über die gehashte IP (nie im Klartext gespeichert).
Schutzregeln beim Wünschen:
- Wunschannahme pausiert → abgelehnt
- Gast/Gerät gesperrt (Cookie-ID oder IP) → abgelehnt
- Limit: max. wish_limit_count Wünsche pro wish_limit_minutes (pro Cookie ODER IP)
- Titel/Interpret auf der Sperrliste, explizit (falls ausgeschaltet) → abgelehnt
- schon in der Warteschlange / zur Freigabe → abgelehnt (keine Duplikate)
- lief in den letzten repeat_block_minutes oder läuft gerade → abgelehnt
"""
import hashlib
import hmac
import re
import secrets
import sqlite3
import threading
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel, Field
from .config import konfig
from .db import einstellungen, get_db, jetzt
from .player_api import player_zustand
from . import turnstile
from .spotify import Spotify, SpotifyFehler, spotify
router = APIRouter()
STATIC = Path(__file__).parent / "static"
GAST_COOKIE = "audiofy_gast"
ZUGANG_COOKIE = "audiofy_zugang"
ZUGANG_TAGE = 30
GAST_RE = re.compile(r"^[A-Za-z0-9_-]{22}$")
PLAYER_OFFLINE_S = 30 # ohne Lebenszeichen gilt der Player als offline
MEINE_WUENSCHE_H = 6 # so lange sieht ein Gast seine eigenen Wünsche
_wunsch_lock = threading.Lock() # Prüfen + Eintragen atomar (keine Duplikate bei Doppelklick)
# --- Hilfen ----------------------------------------------------------------
class Drossel:
"""Einfache Ratenbegrenzung im Speicher (gleitendes Fenster), pro Schlüssel."""
def __init__(self, anzahl: int, sekunden: int):
self.anzahl, self.sekunden = anzahl, sekunden
self._zeiten: dict[str, deque] = defaultdict(deque)
self._lock = threading.Lock()
def erlaubt(self, schluessel: str) -> bool:
jetzt_s = time.monotonic()
with self._lock:
zeiten = self._zeiten[schluessel]
while zeiten and jetzt_s - zeiten[0] > self.sekunden:
zeiten.popleft()
if len(zeiten) >= self.anzahl:
return False
zeiten.append(jetzt_s)
if len(self._zeiten) > 10_000: # Speicher begrenzen
self._zeiten = defaultdict(deque, {schluessel: zeiten})
return True
suche_drossel = Drossel(30, 60)
wunsch_drossel = Drossel(10, 60)
einladung_drossel = Drossel(10, 60) # gegen Raten von Einladungslinks
def gast_id(request: Request) -> str | None:
wert = request.cookies.get(GAST_COOKIE, "")
return wert if GAST_RE.match(wert) else None
def gast_cookie_setzen(response: Response, gid: str) -> None:
response.set_cookie(GAST_COOKIE, gid, max_age=365 * 86400, httponly=True,
secure=konfig().cookie_secure, samesite="lax")
def _geheimnis() -> bytes:
return (konfig().session_secret or "audiofy").encode()
def zugangs_wert(token: str) -> str:
"""Cookie-Wert für den Zugang – hängt am aktuellen Token, ein neuer Link macht alte Cookies ungültig."""
return hmac.new(_geheimnis(), b"zugang:" + token.encode(), hashlib.sha256).hexdigest()[:32]
def hat_zugang(request: Request, e: dict) -> bool:
if not (e["access_enabled"] and e["access_token"]):
return False
return hmac.compare_digest(request.cookies.get(ZUGANG_COOKIE, ""), zugangs_wert(e["access_token"]))
def zugang_pflicht(request: Request, db: sqlite3.Connection = Depends(get_db)) -> None:
"""Dependency für die Gast-API."""
if not hat_zugang(request, einstellungen(db)):
raise HTTPException(401, "Kein Zugang – bitte den QR-Code vor Ort scannen.")
def ip_hash(request: Request) -> str:
"""IP des Gasts (von Caddy über X-Forwarded-For, Uvicorn --proxy-headers) als HMAC-Hash."""
ip = request.client.host if request.client else "unbekannt"
return hmac.new(_geheimnis(), ip.encode(), hashlib.sha256).hexdigest()[:24]
def gleiche_herkunft(request: Request) -> None:
"""CSRF-Schutz für Gast-POSTs: Origin (falls gesendet) muss zur eigenen Seite passen."""
origin = request.headers.get("origin")
if origin and urlsplit(origin).netloc != request.headers.get("host"):
raise HTTPException(403, "Ungültige Herkunft")
def zeitpunkt(iso: str | None) -> datetime | None:
return datetime.fromisoformat(iso) if iso else None
@dataclass
class Kontext:
"""Vorberechnete Mengen für die Sperrprüfung (eine DB-Runde für alle Suchtreffer)."""
aktiv: set[str] # in Warteschlange oder zur Freigabe
kuerzlich: set[str] # lief in der Sperrzeit oder läuft gerade
titel_gesperrt: set[str]
interpreten_gesperrt: set[str]
explizit_aus: bool
def kontext(db: sqlite3.Connection, e: dict) -> Kontext:
grenze = jetzt(-timedelta(minutes=e["repeat_block_minutes"]))
kuerzlich = {r[0] for r in db.execute("SELECT track_uri FROM history WHERE started_at >= ?", (grenze,))}
aktuell = db.execute("SELECT track_uri FROM pi_state WHERE id = 1").fetchone()
if aktuell and aktuell[0]:
kuerzlich.add(aktuell[0])
sperre = db.execute("SELECT kind, value FROM blocklist WHERE kind IN ('track', 'artist')").fetchall()
return Kontext(
aktiv={r[0] for r in db.execute("SELECT track_uri FROM wishes WHERE status IN ('pending', 'queued')")},
kuerzlich=kuerzlich,
titel_gesperrt={r["value"] for r in sperre if r["kind"] == "track"},
interpreten_gesperrt={r["value"] for r in sperre if r["kind"] == "artist"},
explizit_aus=bool(e["block_explicit"]),
)
def sperrgrund(titel: dict, ktx: Kontext) -> str | None:
if titel["track_uri"] in ktx.titel_gesperrt or ktx.interpreten_gesperrt.intersection(titel.get("artist_uris", [])):
return "Dieser Titel kann leider nicht gewünscht werden."
if ktx.explizit_aus and titel.get("explicit"):
return "Titel mit expliziten Inhalten sind ausgeschaltet."
if titel["track_uri"] in ktx.aktiv:
return "Steht schon auf der Wunschliste."
if titel["track_uri"] in ktx.kuerzlich:
return "Lief gerade erst – bitte später nochmal."
return None
def gast_gesperrt(db: sqlite3.Connection, gid: str | None, iph: str) -> bool:
return db.execute("SELECT 1 FROM blocklist WHERE (kind = 'guest' AND value = ?) OR (kind = 'ip' AND value = ?)",
(gid or "", iph)).fetchone() is not None
def limit_status(db: sqlite3.Connection, e: dict, gid: str | None, iph: str) -> tuple[int, int | None]:
"""(verbleibende Wünsche, Minuten bis zum nächsten möglichen Wunsch oder None)."""
grenze = jetzt(-timedelta(minutes=e["wish_limit_minutes"]))
zeilen = db.execute("SELECT created_at FROM wishes WHERE (guest_id = ? OR guest_ip = ?) AND created_at >= ? "
"ORDER BY created_at", (gid or "-", iph, grenze)).fetchall()
rest = max(0, e["wish_limit_count"] - len(zeilen))
if rest:
return rest, None
frei_ab = zeitpunkt(zeilen[0]["created_at"]) + timedelta(minutes=e["wish_limit_minutes"])
return 0, max(1, round((frei_ab - datetime.now(timezone.utc)).total_seconds() / 60))
# --- Seiten ----------------------------------------------------------------
def kein_zugang_seite(status_code: int = 403) -> FileResponse:
return FileResponse(STATIC / "zugang.html", status_code=status_code, headers={"Cache-Control": "no-store"})
@router.get("/", include_in_schema=False)
def startseite(request: Request, db: sqlite3.Connection = Depends(get_db)) -> FileResponse:
if not hat_zugang(request, einstellungen(db)):
return kein_zugang_seite()
antwort = FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-cache"})
if not gast_id(request):
gast_cookie_setzen(antwort, secrets.token_urlsafe(16))
return antwort
@router.get("/z/{token}", include_in_schema=False)
def einladung(token: str, request: Request, db: sqlite3.Connection = Depends(get_db)):
"""Einladungslink bzw. QR-Code: gültig → Zugangs-Cookie setzen und zur Wunschseite."""
if not einladung_drossel.erlaubt(ip_hash(request)):
return kein_zugang_seite(429)
e = einstellungen(db)
if not (e["access_enabled"] and e["access_token"] and len(token) <= 64
and hmac.compare_digest(token.encode(), e["access_token"].encode())):
return kein_zugang_seite()
antwort = RedirectResponse("/", status_code=303)
antwort.set_cookie(ZUGANG_COOKIE, zugangs_wert(e["access_token"]), max_age=ZUGANG_TAGE * 86400,
httponly=True, secure=konfig().cookie_secure, samesite="lax")
if not gast_id(request):
gast_cookie_setzen(antwort, secrets.token_urlsafe(16))
return antwort
# --- API -------------------------------------------------------------------
@router.get("/api/search", dependencies=[Depends(zugang_pflicht)])
def suche(request: Request, q: str = Query(min_length=2, max_length=100),
db: sqlite3.Connection = Depends(get_db), sp: Spotify = Depends(spotify)) -> dict:
if not suche_drossel.erlaubt(ip_hash(request)):
raise HTTPException(429, "Zu viele Suchanfragen – bitte kurz warten.")
if not sp.verfuegbar():
raise HTTPException(503, "Die Suche ist gerade nicht verfügbar.")
try:
treffer = sp.suche(q)
except SpotifyFehler:
raise HTTPException(503, "Die Suche ist gerade überlastet – bitte gleich nochmal versuchen.")
ktx = kontext(db, einstellungen(db))
return {"results": [{k: v for k, v in t.items() if k not in ("artist_uris", "artists")}
| {"blocked": sperrgrund(t, ktx)}
for t in treffer]}
@router.get("/api/turnstile")
def turnstile_konfig() -> dict:
"""Öffentlicher Site-Key für das Turnstile-Widget (null = Schutz aus)."""
return {"site_key": konfig().turnstile_site_key if turnstile.aktiv() else None}
class WunschAnfrage(BaseModel):
track_uri: str = Field(pattern=r"^spotify:track:[A-Za-z0-9]{22}$")
name: str | None = Field(default=None, max_length=30)
turnstile: str | None = Field(default=None, max_length=2048)
@router.post("/api/wish", dependencies=[Depends(zugang_pflicht)])
def wuenschen(anfrage: WunschAnfrage, request: Request, response: Response,
db: sqlite3.Connection = Depends(get_db), sp: Spotify = Depends(spotify)) -> dict:
gleiche_herkunft(request)
gid = gast_id(request)
if not gid:
gid = secrets.token_urlsafe(16)
gast_cookie_setzen(response, gid)
iph = ip_hash(request)
if not wunsch_drossel.erlaubt(iph):
raise HTTPException(429, "Zu viele Versuche – bitte kurz warten.")
e = einstellungen(db)
if e["wishes_paused"]:
raise HTTPException(403, "Die Wunschannahme ist gerade pausiert.")
if gast_gesperrt(db, gid, iph):
raise HTTPException(403, "Von diesem Gerät sind gerade keine Wünsche möglich.")
rest, minuten = limit_status(db, e, gid, iph)
if not rest:
raise HTTPException(429, f"Du hast dein Limit von {e['wish_limit_count']} Wünschen erreicht – "
f"nächster Wunsch in ca. {minuten} Min.")
turnstile.pruefen(anfrage.turnstile, request, "wish", bei_ausfall_erlauben=True)
try:
titel = sp.titel(anfrage.track_uri) # Titelinfos immer selbst holen, nie dem Browser glauben
except SpotifyFehler as fehler:
if fehler.status in (400, 404):
raise HTTPException(404, "Titel nicht gefunden.")
raise HTTPException(503, "Spotify ist gerade nicht erreichbar – bitte gleich nochmal versuchen.")
name = " ".join((anfrage.name or "").split()) or None
status = "pending" if e["moderation"] else "queued"
with _wunsch_lock:
grund = sperrgrund(titel, kontext(db, e))
if grund:
raise HTTPException(409, grund)
with db:
pos = db.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM wishes").fetchone()[0]
db.execute("INSERT INTO wishes (track_uri, title, artist, album, cover_url, duration_ms, explicit, "
"guest_id, guest_ip, guest_name, status, position, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(titel["track_uri"], titel["title"], titel["artist"], titel["album"], titel["cover_url"],
titel["duration_ms"], int(titel["explicit"]), gid, iph, name, status, pos, jetzt()))
return {"ok": True, "status": status,
"message": "Dein Wunsch wartet auf Freigabe." if status == "pending" else "Dein Wunsch ist auf der Liste!"}
@router.get("/api/status", dependencies=[Depends(zugang_pflicht)])
def status(request: Request, db: sqlite3.Connection = Depends(get_db)) -> dict:
gid = gast_id(request)
e = einstellungen(db)
pi = player_zustand(db)
zustand = pi["state"]
zuletzt = zeitpunkt(pi["last_seen"])
online = bool(zuletzt and (datetime.now(timezone.utc) - zuletzt).total_seconds() < PLAYER_OFFLINE_S)
jetzt_laeuft = None
if online and zustand.get("track"):
alter_ms = int((datetime.now(timezone.utc) - zeitpunkt(pi["updated_at"])).total_seconds() * 1000)
jetzt_laeuft = {**{k: zustand["track"].get(k) for k in ("title", "artist", "cover_url", "duration_ms")},
"progress_ms": zustand.get("progress_ms", 0), "is_playing": zustand.get("is_playing", False),
"age_ms": max(0, alter_ms), "is_wish": zustand.get("source") == "wish"}
warteschlange = [{"title": r["title"], "artist": r["artist"], "cover_url": r["cover_url"],
"name": r["guest_name"], "mine": bool(gid) and r["guest_id"] == gid}
for r in db.execute("SELECT * FROM wishes WHERE status = 'queued' AND priority = 0 "
"ORDER BY position, id LIMIT 30")]
meine = []
if gid:
grenze = jetzt(-timedelta(hours=MEINE_WUENSCHE_H))
meine = [{"title": r["title"], "artist": r["artist"], "status": r["status"]}
for r in db.execute("SELECT title, artist, status FROM wishes WHERE guest_id = ? AND created_at >= ? "
"ORDER BY created_at DESC LIMIT 10", (gid, grenze))]
rest, minuten = limit_status(db, e, gid, ip_hash(request))
return {
"now_playing": jetzt_laeuft,
"online": online,
"queue": warteschlange,
"pending_count": db.execute("SELECT COUNT(*) FROM wishes WHERE status = 'pending'").fetchone()[0],
"mine": meine,
"wishes_paused": e["wishes_paused"],
"moderation": e["moderation"],
"wish_every_n": e["wish_every_n"],
"limit": {"count": e["wish_limit_count"], "minutes": e["wish_limit_minutes"],
"remaining": rest, "next_in_minutes": minuten},
}
+50
View File
@@ -0,0 +1,50 @@
"""Audiofy-Web – FastAPI-Anwendung.
Start (Server): uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 1 --no-access-log
Caddy terminiert HTTPS und leitet an 127.0.0.1:8000 weiter.
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from . import admin, db, gaeste, player_api
from .config import konfig
# Streng: nur eigene Skripte/Styles, Cover-Bilder vom Spotify-CDN, Turnstile von Cloudflare
CSP = ("default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self'; "
"img-src 'self' https://i.scdn.co data:; frame-src https://challenges.cloudflare.com; "
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
@asynccontextmanager
async def lifespan(app: FastAPI):
k = konfig()
db.initialisieren(k.db_pfad, k.playlist_id)
yield
# Keine automatische API-Doku öffentlich ausliefern
app = FastAPI(title="Audiofy – Musikwünsche", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
@app.middleware("http")
async def sicherheits_header(request: Request, call_next):
antwort = await call_next(request)
antwort.headers.setdefault("Content-Security-Policy", CSP)
if request.url.path.startswith("/api/"):
antwort.headers.setdefault("Cache-Control", "no-store")
return antwort
app.include_router(player_api.router, prefix="/api/player")
app.include_router(player_api.router, prefix="/api/pi", include_in_schema=False) # alter Pfad, für ältere Player
app.include_router(gaeste.router)
app.include_router(admin.router)
app.mount("/static", StaticFiles(directory=gaeste.STATIC), name="static")
@app.get("/healthz")
def healthz() -> dict:
return {"ok": True}
+167
View File
@@ -0,0 +1,167 @@
"""Audiofy-Web – Player-API (/api/player/…), geschützt per Bearer-Token (PLAYER_API_TOKEN).
Der Player baut nur ausgehende Verbindungen auf und fragt hier ca. alle 3 s nach:
GET /api/player/config Einstellungen + offene Befehle (Ziel: Reaktion < 3 s)
GET /api/player/next-wish nächster Wunsch (Besitzer-Priorität zuerst) oder null
POST /api/player/wish/{id}/played Wunsch wurde gestartet → erledigt
POST /api/player/now-playing aktueller Titel/Zustand (schreibt den Verlauf)
POST /api/player/bluetooth Lautsprecher und Suchergebnisse
POST /api/player/commands/{id}/ack Befehl ausgeführt (oder fehlgeschlagen)
Der alte Pfad /api/pi/… bleibt vorerst als Alias erhalten (ältere Player).
"""
import hmac
import json
import sqlite3
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from .config import Konfig, konfig
from .db import einstellungen, get_db, jetzt, lebenszeichen, offene_befehle
_bearer = HTTPBearer(auto_error=False)
def player_auth(cred: HTTPAuthorizationCredentials | None = Depends(_bearer), k: Konfig = Depends(konfig)) -> None:
if not k.player_api_token:
raise HTTPException(503, "PLAYER_API_TOKEN ist auf dem Server nicht gesetzt")
if cred is None or not hmac.compare_digest(cred.credentials.encode(), k.player_api_token.encode()):
raise HTTPException(401, "Ungültiger Token", headers={"WWW-Authenticate": "Bearer"})
router = APIRouter(tags=["player"], dependencies=[Depends(player_auth)]) # Präfix setzt main.py
class Titel(BaseModel):
track_uri: str = Field(pattern=r"^spotify:track:[A-Za-z0-9]{22}$")
title: str = Field(max_length=300)
artist: str = Field(max_length=300)
album: str | None = Field(default=None, max_length=300)
cover_url: str | None = Field(default=None, max_length=500)
duration_ms: int | None = Field(default=None, ge=0)
class JetztLaeuft(BaseModel):
track: Titel | None = None # None = es läuft nichts
progress_ms: int = Field(default=0, ge=0)
is_playing: bool = False
new_track: bool = False # Controller hat einen Titelwechsel erkannt
source: Literal["playlist", "wish", "owner", "fremd"] | None = None
wish_id: int | None = None
next: Titel | None = None # bereits eingereihter nächster Titel
device_ok: bool = True # Gerät "Audiofy" erreichbar
speaker_ok: bool = True # Bluetooth-Lautsprecher verbunden
stopped: bool = False # Controller im Not-Aus
volume_percent: int | None = Field(default=None, ge=0, le=100)
error: str | None = Field(default=None, max_length=300) # z. B. "Playlist nicht lesbar"
MAC_RE = r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$"
class Lautsprecher(BaseModel):
mac: str = Field(pattern=MAC_RE)
name: str = Field(default="", max_length=100)
paired: bool = False
connected: bool = False
class BluetoothZustand(BaseModel):
current: Lautsprecher | None = None # eingestellter Lautsprecher
devices: list[Lautsprecher] = Field(default_factory=list, max_length=50) # letzte Suche
scanning: bool = False # Suche oder Kopplung läuft gerade
scanned_at: str | None = Field(default=None, max_length=40)
class Quittung(BaseModel):
ok: bool = True
message: str | None = Field(default=None, max_length=500)
@router.get("/config")
def pi_config(db: sqlite3.Connection = Depends(get_db)) -> dict:
lebenszeichen(db)
e = einstellungen(db)
return {
"wish_every_n": e["wish_every_n"],
"playlist_id": e["playlist_id"],
"reshuffle_token": e["reshuffle_token"],
"stopped": e["emergency_stop"],
"commands": offene_befehle(db),
}
@router.get("/next-wish")
def naechster_wunsch(db: sqlite3.Connection = Depends(get_db)) -> dict:
lebenszeichen(db)
if einstellungen(db)["emergency_stop"]:
return {"wish": None}
r = db.execute("SELECT id, track_uri, title, artist, cover_url, duration_ms, priority FROM wishes "
"WHERE status = 'queued' ORDER BY priority DESC, position, id LIMIT 1").fetchone()
if r is None:
return {"wish": None}
return {"wish": {"id": r["id"], "track_uri": r["track_uri"], "title": r["title"], "artist": r["artist"],
"cover_url": r["cover_url"], "duration_ms": r["duration_ms"], "priority": bool(r["priority"])}}
@router.post("/wish/{wish_id}/played")
def wunsch_gespielt(wish_id: int, db: sqlite3.Connection = Depends(get_db)) -> dict:
with db:
cur = db.execute("UPDATE wishes SET status = 'played', played_at = ? WHERE id = ? AND status = 'queued'",
(jetzt(), wish_id))
if cur.rowcount:
return {"ok": True, "status": "played"}
r = db.execute("SELECT status FROM wishes WHERE id = ?", (wish_id,)).fetchone()
if r is None:
raise HTTPException(404, "Wunsch nicht gefunden")
return {"ok": True, "status": r["status"]} # schon erledigt/gelöscht – idempotent
@router.post("/now-playing")
def jetzt_laeuft(daten: JetztLaeuft, db: sqlite3.Connection = Depends(get_db)) -> dict:
zeit = jetzt()
uri = daten.track.track_uri if daten.track else None
with db:
db.execute("UPDATE pi_state SET track_uri = ?, payload = ?, updated_at = ?, last_seen = ? WHERE id = 1",
(uri, daten.model_dump_json(), zeit, zeit))
# Verlauf: nur Titel, die wirklich laufen – neuer Eintrag bei jedem Titelwechsel
# (auch derselbe Titel erneut); ein beim Start nur pausierter Titel zählt nicht
letzter = db.execute("SELECT track_uri FROM history ORDER BY id DESC LIMIT 1").fetchone()
if daten.track and daten.is_playing and (daten.new_track or uri != (letzter[0] if letzter else None)):
wish_id = daten.wish_id
if wish_id is not None and not db.execute("SELECT 1 FROM wishes WHERE id = ?", (wish_id,)).fetchone():
wish_id = None
db.execute("INSERT INTO history (track_uri, title, artist, cover_url, source, wish_id, started_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(uri, daten.track.title, daten.track.artist, daten.track.cover_url, daten.source,
wish_id, zeit))
return {"ok": True}
@router.post("/bluetooth")
def bluetooth_melden(daten: BluetoothZustand, db: sqlite3.Connection = Depends(get_db)) -> dict:
"""Player meldet Lautsprecher und Suchergebnisse (für den Admin-Bereich)."""
with db:
db.execute("UPDATE pi_state SET bluetooth = ?, last_seen = ? WHERE id = 1", (daten.model_dump_json(), jetzt()))
return {"ok": True}
@router.post("/commands/{befehl_id}/ack")
def befehl_quittieren(befehl_id: int, q: Quittung, db: sqlite3.Connection = Depends(get_db)) -> dict:
with db:
cur = db.execute("UPDATE commands SET acked_at = ?, ok = ?, message = ? WHERE id = ? AND acked_at IS NULL",
(jetzt(), int(q.ok), q.message, befehl_id))
if not cur.rowcount and not db.execute("SELECT 1 FROM commands WHERE id = ?", (befehl_id,)).fetchone():
raise HTTPException(404, "Befehl nicht gefunden")
return {"ok": True}
def player_zustand(db: sqlite3.Connection) -> dict:
"""Letzter gemeldeter Zustand (für Gastseite und Admin)."""
r = db.execute("SELECT payload, bluetooth, updated_at, last_seen FROM pi_state WHERE id = 1").fetchone()
return {"state": json.loads(r["payload"] or "{}"), "bluetooth": json.loads(r["bluetooth"] or "{}"),
"updated_at": r["updated_at"], "last_seen": r["last_seen"]}
+116
View File
@@ -0,0 +1,116 @@
"""Audiofy-Web – Spotify-Zugriff für Suche und Titelinfos.
Eigener Refresh-Token des Servers (auth.py --profil server, keine Scopes nötig).
Im Development Mode liefert die Suche höchstens 10 Treffer.
"""
import base64
import logging
import threading
import time
from functools import lru_cache
import httpx
from .config import Konfig, env_wert_setzen, konfig
log = logging.getLogger("audiofy.spotify")
API = "https://api.spotify.com/v1"
TOKEN_URL = "https://accounts.spotify.com/api/token"
CACHE_S = 300 # Suchergebnisse so lange zwischenspeichern
CACHE_MAX = 500
class SpotifyFehler(Exception):
def __init__(self, status: int, retry_after: int | None = None):
super().__init__(f"Spotify-Fehler {status}")
self.status = status
self.retry_after = retry_after
def titel_aus(t: dict) -> dict:
"""Spotify-Track-Objekt → schlankes Dict für Website und Datenbank."""
bilder = sorted((t.get("album") or {}).get("images") or [], key=lambda b: b.get("width") or 0)
# kleinstes Bild ab 200 px (gut für Listen), sonst das größte
cover = next((b["url"] for b in bilder if (b.get("width") or 0) >= 200), bilder[-1]["url"] if bilder else None)
return {
"track_uri": t["uri"],
"title": t.get("name") or "",
"artist": ", ".join(a.get("name", "") for a in t.get("artists") or []),
"artist_uris": [a["uri"] for a in t.get("artists") or [] if a.get("uri")],
"artists": [{"name": a.get("name", ""), "uri": a["uri"]} for a in t.get("artists") or [] if a.get("uri")],
"album": (t.get("album") or {}).get("name"),
"cover_url": cover,
"duration_ms": t.get("duration_ms"),
"explicit": bool(t.get("explicit")),
}
class Spotify:
def __init__(self, k: Konfig):
self._k = k
self._refresh_token = k.spotify_refresh_token
self._token: str | None = None
self._ablauf = 0.0
self._lock = threading.Lock()
self._http = httpx.Client(timeout=8)
self._cache: dict[str, tuple[float, list[dict]]] = {}
def verfuegbar(self) -> bool:
return bool(self._k.spotify_client_id and self._k.spotify_client_secret and self._refresh_token)
def _access_token(self) -> str:
with self._lock:
if self._token and time.monotonic() < self._ablauf - 60:
return self._token
basic = base64.b64encode(f"{self._k.spotify_client_id}:{self._k.spotify_client_secret}".encode()).decode()
r = self._http.post(TOKEN_URL, headers={"Authorization": f"Basic {basic}"},
data={"grant_type": "refresh_token", "refresh_token": self._refresh_token})
if r.status_code != 200:
log.error("Token-Erneuerung fehlgeschlagen (%s)", r.status_code)
raise SpotifyFehler(r.status_code)
daten = r.json()
self._token = daten["access_token"]
self._ablauf = time.monotonic() + daten.get("expires_in", 3600)
neu = daten.get("refresh_token")
if neu and neu != self._refresh_token:
self._refresh_token = neu
env_wert_setzen(self._k.env_pfad, "SPOTIFY_REFRESH_TOKEN", neu)
log.info("Neuer Refresh-Token von Spotify gespeichert")
return self._token
def _get(self, pfad: str, params: dict) -> dict:
for versuch in range(2):
r = self._http.get(API + pfad, params=params, headers={"Authorization": f"Bearer {self._access_token()}"})
if r.status_code == 401 and versuch == 0:
self._token = None
continue
if r.status_code == 429:
raise SpotifyFehler(429, int(r.headers.get("Retry-After", "5")))
if r.status_code >= 400:
raise SpotifyFehler(r.status_code)
return r.json()
raise SpotifyFehler(401)
def suche(self, q: str) -> list[dict]:
schluessel = " ".join(q.casefold().split())
treffer = self._cache.get(schluessel)
if treffer and time.monotonic() - treffer[0] < CACHE_S:
return treffer[1]
daten = self._get("/search", {"q": q, "type": "track", "limit": 10, "market": "from_token"})
ergebnis = [titel_aus(t) for t in (daten.get("tracks") or {}).get("items") or []
if t and t.get("uri", "").startswith("spotify:track:") and t.get("is_playable", True)]
if len(self._cache) >= CACHE_MAX:
self._cache.clear()
self._cache[schluessel] = (time.monotonic(), ergebnis)
return ergebnis
def titel(self, uri: str) -> dict:
return titel_aus(self._get(f"/tracks/{uri.rsplit(':', 1)[-1]}", {"market": "from_token"}))
@lru_cache
def spotify() -> Spotify:
"""FastAPI-Dependency (in Tests per dependency_overrides ersetzbar)."""
return Spotify(konfig())
+127
View File
@@ -0,0 +1,127 @@
/* Audiofy – Admin-Bereich (ergänzt app.css) */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 4px 0 12px; }
.kopf h1 { font-size: 1.4rem; }
.pillen { display: flex; flex-wrap: wrap; gap: 6px; }
.pille { font-size: .78rem; font-weight: 700; padding: 3px 10px; border-radius: 999px; background: #333; color: var(--leise); }
.pille.gut { background: #12391f; color: var(--akzent); }
.pille.schlecht { background: #3d1717; color: var(--fehler); }
.reiter {
position: sticky;
top: 0;
z-index: 5;
display: flex;
gap: 4px;
overflow-x: auto;
background: var(--bg);
padding: 8px 0;
margin-bottom: 8px;
scrollbar-width: none;
}
.reiter button {
flex: 1 0 auto;
font: inherit;
font-weight: 600;
font-size: .95rem;
min-height: 44px;
padding: 0 14px;
border: 0;
border-radius: 999px;
background: var(--karte);
color: var(--leise);
cursor: pointer;
}
.reiter button[aria-selected="true"] { background: var(--text); color: #111; }
.zahl { display: inline-block; min-width: 20px; margin-left: 4px; padding: 0 6px; border-radius: 999px; background: var(--warnung); color: #111; font-size: .78rem; }
button.gross, a.gross {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
font-weight: 700;
font-size: 1.05rem;
min-height: 52px;
padding: 0 18px;
border: 0;
border-radius: 14px;
background: #2f2f2f;
color: var(--text);
cursor: pointer;
text-decoration: none;
}
button.akzent { background: var(--akzent); color: var(--akzent-text); }
/* Symbol über dem Text */
.gross.mit-symbol { flex-direction: column; gap: 4px; min-height: 72px; padding: 8px 12px; }
.gross.mit-symbol .symbol { font-size: 1.6rem; line-height: 1; }
button.gefahr { background: #b3261e; color: #fff; }
button:disabled { opacity: .5; cursor: default; }
form#login button { width: 100%; margin-top: 14px; }
.knopfreihe { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; }
.knopfreihe.zwei { grid-template-columns: repeat(2, 1fr); }
.voll { width: 100%; margin-top: 12px; }
button.klein {
font: inherit;
font-size: .85rem;
font-weight: 600;
min-height: 40px;
min-width: 40px;
padding: 0 10px;
border: 0;
border-radius: 10px;
background: #2f2f2f;
color: var(--text);
cursor: pointer;
flex: 0 0 auto;
}
button.klein.akzent { background: var(--akzent); color: var(--akzent-text); }
button.klein.gefahr { background: #4a1c1c; color: var(--fehler); }
.aktionen { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.schalter-zeile { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-top: 1px solid var(--linie); }
.schalter-zeile:first-of-type { border-top: 0; }
.schalter-zeile .text .m { white-space: normal; }
.schalter { position: relative; width: 58px; height: 34px; flex: 0 0 auto; }
.schalter input { opacity: 0; width: 100%; height: 100%; margin: 0; position: absolute; cursor: pointer; z-index: 1; }
.schalter span { position: absolute; inset: 0; border-radius: 999px; background: #444; transition: background .15s; }
.schalter span::after { content: ""; position: absolute; top: 4px; left: 4px; width: 26px; height: 26px; border-radius: 50%; background: #fff; transition: transform .15s; }
.schalter input:checked + span { background: var(--akzent); }
.schalter input:checked + span::after { transform: translateX(24px); }
.schalter input:focus-visible + span { outline: 2px solid var(--text); outline-offset: 2px; }
.feld-zeile { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; }
.feld-zeile input[type="number"] {
width: 96px;
font: inherit;
font-size: 1.05rem;
color: var(--text);
background: #2a2a2a;
border: 2px solid transparent;
border-radius: 10px;
padding: 8px 10px;
text-align: right;
}
input[type="range"] { width: 100%; height: 36px; accent-color: var(--akzent); }
.lautstaerke { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
.lautstaerke output { min-width: 3ch; text-align: right; font-weight: 700; }
.notaus-aktiv { background: #3d1717; color: var(--fehler); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 16px; font-weight: 700; }
.qr { display: block; width: 100%; max-width: 280px; margin: 12px auto; background: #fff; border-radius: 12px; padding: 8px; }
.link-feld { width: 100%; font: inherit; font-size: .9rem; color: var(--text); background: #2a2a2a; border: 0; border-radius: 10px; padding: 10px 12px; }
.fehlertext { color: var(--fehler); }
/* Legende zu den Symbol-Knöpfen – nur auf Touch-Geräten (dort gibt es keine Hover-Beschriftung) */
.legende { display: none; flex-wrap: wrap; justify-content: center; text-align: center; gap: 4px 14px; margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--linie); color: var(--leise); font-size: .82rem; }
@media (hover: none) { .legende { display: flex; } }
.zeit { color: var(--leise); font-size: .8rem; white-space: nowrap; }
.marke.besitzer { background: #6a4cff; color: #fff; }
.marke.quelle { background: #333; color: var(--leise); }
.abstand { margin-top: 18px; }
+176
View File
@@ -0,0 +1,176 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<meta name="robots" content="noindex">
<title>Audiofy – Admin</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/admin.css">
<script src="/static/admin.js" defer></script>
</head>
<body>
<main>
<div class="kopf">
<h1>Audiofy Admin</h1>
<button id="abmelden" class="klein" type="button">Abmelden</button>
</div>
<div class="pillen" id="pillen"></div>
<nav class="reiter" role="tablist">
<button type="button" role="tab" data-reiter="wiedergabe" aria-selected="true">Wiedergabe</button>
<button type="button" role="tab" data-reiter="wuensche">Wünsche<span id="zahl-freigabe" class="zahl" hidden></span></button>
<button type="button" role="tab" data-reiter="gaeste">Gäste</button>
<button type="button" role="tab" data-reiter="einstellungen">Einstellungen</button>
<button type="button" role="tab" data-reiter="verlauf">Verlauf</button>
</nav>
<div id="notaus-hinweis" class="notaus-aktiv" hidden>Not-Aus aktiv – der Controller ist angehalten.</div>
<!-- ===== Wiedergabe ===== -->
<section data-seite="wiedergabe">
<div class="karte">
<h2>Läuft gerade</h2>
<div id="jetzt-inhalt" class="titel-zeile"></div>
<div class="fortschritt"><div id="fortschritt-balken"></div></div>
<p id="naechster" class="hinweis klein"></p>
<p id="pi-fehler" class="hinweis klein fehlertext" hidden></p>
<div class="knopfreihe">
<button class="gross mit-symbol" type="button" data-befehl="play"><span class="symbol" aria-hidden="true">▶</span><span>Play</span></button>
<button class="gross mit-symbol" type="button" data-befehl="pause"><span class="symbol" aria-hidden="true">⏸</span><span>Pause</span></button>
<button class="gross mit-symbol" type="button" data-befehl="skip"><span class="symbol" aria-hidden="true">⏭</span><span>Weiter</span></button>
</div>
<div class="lautstaerke">
<span aria-hidden="true">🔊</span>
<input id="lautstaerke" type="range" min="0" max="100" step="5" aria-label="Lautstärke">
<output id="lautstaerke-wert">–</output>
</div>
</div>
<div class="karte">
<h2>Als Nächstes spielen</h2>
<input id="naechstes-suche" type="search" placeholder="Titel suchen …" autocomplete="off" maxlength="100">
<ul id="naechstes-treffer" class="liste"></ul>
</div>
<div class="karte">
<h2>Not-Aus</h2>
<p class="hinweis klein">Stoppt die Wiedergabe und hält den Controller an, bis du wieder freigibst.</p>
<button id="notaus" class="gross mit-symbol gefahr voll" type="button"><span class="symbol" aria-hidden="true">⛔</span><span>Not-Aus</span></button>
</div>
<div class="karte">
<h2>Letzte Befehle</h2>
<ul id="befehle" class="liste kompakt"></ul>
</div>
</section>
<!-- ===== Wünsche ===== -->
<section data-seite="wuensche" hidden>
<div class="karte">
<div class="schalter-zeile">
<div class="text"><span class="t">Wünsche annehmen</span><span class="m">Aus = Gäste sehen „pausiert“</span></div>
<label class="schalter"><input type="checkbox" id="s-annahme"><span></span></label>
</div>
<div class="schalter-zeile">
<div class="text"><span class="t">Moderation</span><span class="m">Wünsche erst nach deiner Freigabe spielen</span></div>
<label class="schalter"><input type="checkbox" id="s-moderation"><span></span></label>
</div>
</div>
<div class="karte" id="freigabe-karte">
<h2>Zur Freigabe</h2>
<ul id="freigabe" class="liste"></ul>
<p id="freigabe-leer" class="hinweis">Nichts zu prüfen.</p>
<p class="legende"><span>✓ freigeben</span><span>✕ ablehnen</span><span>🚫 Gast sperren</span></p>
</div>
<div class="karte">
<h2>Warteschlange</h2>
<ul id="warteschlange" class="liste"></ul>
<p id="warteschlange-leer" class="hinweis">Keine Wünsche in der Warteschlange.</p>
<p class="legende"><span>↑ früher</span><span>↓ später</span><span>✕ löschen</span><span>🚫 Gast sperren</span></p>
</div>
</section>
<!-- ===== Gäste ===== -->
<section data-seite="gaeste" hidden>
<div class="karte">
<h2>Gastzugang</h2>
<div class="schalter-zeile">
<div class="text"><span class="t">Zugang offen</span><span class="m">Aus = niemand kommt auf die Wunschseite</span></div>
<label class="schalter"><input type="checkbox" id="s-zugang"><span></span></label>
</div>
<img id="qr" class="qr" alt="QR-Code zum Einladungslink">
<input id="einladungslink" class="link-feld" type="text" readonly aria-label="Einladungslink">
<div class="knopfreihe zwei">
<button id="link-kopieren" class="gross" type="button">Link kopieren</button>
<a id="qr-png" class="gross" href="/admin/api/access/qr?format=png" download>QR als Bild</a>
</div>
<button id="link-erneuern" class="gross voll" type="button">Neuen Link erzeugen</button>
<p class="hinweis klein">Ein neuer Link macht den alten und alle damit erteilten Zugänge ungültig.</p>
</div>
<div class="karte">
<h2>Limit pro Gast</h2>
<div class="feld-zeile"><span>Wünsche</span><input id="e-limit-anzahl" type="number" min="1" max="50"></div>
<div class="feld-zeile"><span>pro Minuten</span><input id="e-limit-minuten" type="number" min="1" max="1440"></div>
<button class="gross akzent voll" type="button" data-speichern="e-limit-anzahl:wish_limit_count,e-limit-minuten:wish_limit_minutes">Speichern</button>
</div>
<div class="karte">
<h2>Sperrliste</h2>
<div class="schalter-zeile">
<div class="text"><span class="t">Explizite Titel sperren</span></div>
<label class="schalter"><input type="checkbox" id="s-explizit"><span></span></label>
</div>
<input id="sperr-suche" type="search" placeholder="Titel oder Interpret zum Sperren suchen …" autocomplete="off" maxlength="100">
<ul id="sperr-treffer" class="liste"></ul>
<h2 class="abstand">Gesperrt</h2>
<ul id="sperrliste" class="liste"></ul>
<p id="sperrliste-leer" class="hinweis">Nichts gesperrt.</p>
</div>
</section>
<!-- ===== Einstellungen ===== -->
<section data-seite="einstellungen" hidden>
<div class="karte">
<h2>Ablauf</h2>
<div class="feld-zeile"><span>Jeder wievielte Titel ist ein Wunsch (N)</span><input id="e-n" type="number" min="1" max="20"></div>
<div class="feld-zeile"><span>Sperrzeit für Wiederholungen (Min.)</span><input id="e-sperrzeit" type="number" min="0" max="1440"></div>
<button class="gross akzent voll" type="button" data-speichern="e-n:wish_every_n,e-sperrzeit:repeat_block_minutes">Speichern</button>
</div>
<div class="karte">
<h2>Lautsprecher</h2>
<div id="bt-aktuell" class="titel-zeile"></div>
<p class="hinweis klein">Neuen Bluetooth-Lautsprecher vorher in den Kopplungsmodus versetzen (meist Bluetooth-Taste gedrückt halten, bis die LED schnell blinkt). Während der Suche kann die Musik kurz stocken – WLAN und Bluetooth teilen sich oft einen Funkchip.</p>
<button id="bt-suchen" class="gross voll" type="button">Nach Lautsprechern suchen</button>
<p id="bt-status" class="hinweis klein"></p>
<ul id="bt-geraete" class="liste"></ul>
</div>
<div class="karte">
<h2>Grund-Playlist</h2>
<p class="hinweis klein">Aktuell: <a id="playlist-link" target="_blank" rel="noopener noreferrer"></a></p>
<input id="playlist-neu" type="text" placeholder="Spotify-Playlist-Link einfügen" autocomplete="off" maxlength="300">
<div class="knopfreihe zwei">
<button id="playlist-wechseln" class="gross akzent" type="button">Wechseln</button>
<button id="neu-mischen" class="gross" type="button">Neu mischen</button>
</div>
</div>
</section>
<!-- ===== Verlauf ===== -->
<section data-seite="verlauf" hidden>
<div class="karte">
<h2>Verlauf</h2>
<ul id="verlauf" class="liste"></ul>
<p id="verlauf-leer" class="hinweis">Noch nichts gespielt.</p>
</div>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="assertive" hidden></div>
</body>
</html>
+450
View File
@@ -0,0 +1,450 @@
// Audiofy – Admin-Bereich. Daten nur per textContent einsetzen (kein innerHTML mit fremden Inhalten).
"use strict";
const ABFRAGE_MS = 3000;
const QUELLE = { playlist: "Playlist", wish: "Wunsch", owner: "Besitzer", fremd: "Spotify-App" };
const BEFEHL = { play: "Play", pause: "Pause", skip: "Weiter", volume: "Lautstärke",
bt_scan: "Lautsprecher suchen", bt_connect: "Lautsprecher verbinden" };
const $ = (id) => document.getElementById(id);
let csrf = "";
let zustand = null;
let fortschritt = null;
let aktiverReiter = "wiedergabe";
let lautstaerkeZiel = null; // {wert, bis}: gesendeter Wert, bis der Player ihn bestätigt
// --- Hilfen ------------------------------------------------------------------
function el(tag, klasse, text) {
const e = document.createElement(tag);
if (klasse) e.className = klasse;
if (text !== undefined && text !== null) e.textContent = text;
return e;
}
function cover(url, gross) {
const img = el("img", "cover");
img.alt = "";
img.loading = "lazy";
if (url && url.startsWith("https://i.scdn.co/")) img.src = url;
if (gross) img.width = img.height = 72;
return img;
}
function textBlock(titel, interpret, ...extra) {
const t = el("div", "text");
t.append(el("span", "t", titel), el("span", "a", interpret));
extra.filter(Boolean).forEach((x) => t.append(x));
return t;
}
function knopf(text, klasse, aktion, titel) {
const b = el("button", "klein " + (klasse || ""), text);
b.type = "button";
if (titel) b.title = b.ariaLabel = titel;
b.addEventListener("click", async () => {
b.disabled = true;
try { await aktion(); } finally { b.disabled = false; }
});
return b;
}
function uhrzeit(iso) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
let toastTimer = null;
function toast(text, fehler) {
const t = $("toast");
t.textContent = text;
t.className = "toast" + (fehler ? " fehler" : "");
t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.hidden = true; }, 3500);
}
async function api(pfad, daten, methode) {
const optionen = { method: methode || (daten === undefined ? "GET" : "POST"), credentials: "same-origin", headers: {} };
if (optionen.method !== "GET") {
optionen.headers["X-CSRF-Token"] = csrf;
optionen.headers["Content-Type"] = "application/json";
optionen.body = JSON.stringify(daten || {});
}
const antwort = await fetch("/admin/api" + pfad, optionen);
if (antwort.status === 401) { location.href = "/admin/login"; throw new Error("Abgemeldet"); }
let inhalt = null;
try { inhalt = await antwort.json(); } catch (_) { /* leer */ }
if (!antwort.ok) {
const meldung = inhalt && typeof inhalt.detail === "string" ? inhalt.detail : "Fehler " + antwort.status;
toast(meldung, true);
throw new Error(meldung);
}
return inhalt;
}
async function aktion(pfad, daten, meldung) {
await api(pfad, daten);
if (meldung) toast(meldung);
await laden();
}
function setzeWert(id, wert) {
const e = $(id);
if (document.activeElement !== e) e.value = wert; // Eingaben des Besitzers nicht überschreiben
}
function setzeSchalter(id, wert) {
const e = $(id);
if (document.activeElement !== e) e.checked = !!wert;
}
// --- Reiter --------------------------------------------------------------------
function zeigeReiter(name) {
aktiverReiter = name;
document.querySelectorAll("[data-reiter]").forEach((b) => b.setAttribute("aria-selected", String(b.dataset.reiter === name)));
document.querySelectorAll("[data-seite]").forEach((s) => { s.hidden = s.dataset.seite !== name; });
if (name === "verlauf") ladeVerlauf();
if (name === "gaeste") { ladeZugang(); ladeSperrliste(); }
}
// --- Darstellung des Zustands ------------------------------------------------------
function zeigePillen(z) {
const p = $("pillen");
p.replaceChildren();
const st = z.player.state || {};
const pille = (text, gut) => p.append(el("span", "pille " + (gut ? "gut" : "schlecht"), text));
if (z.player.online) pille("Player online", true);
else pille(z.player.last_seen_s === null ? "Player noch nie gesehen" : `Player offline (${Math.round(z.player.last_seen_s / 60)} min)`, false);
if (z.player.online) {
pille(st.device_ok === false ? "Gerät fehlt" : "Gerät ok", st.device_ok !== false);
pille(st.speaker_ok === false ? "Lautsprecher getrennt" : "Lautsprecher ok", st.speaker_ok !== false);
}
if (z.settings.wishes_paused) pille("Wünsche pausiert", false);
if (!z.settings.access_enabled) pille("Gastzugang zu", false);
}
function zeigeWiedergabe(z) {
const st = z.player.state || {};
const box = $("jetzt-inhalt");
box.replaceChildren();
if (z.player.online && st.track) {
const marke = st.source ? el("span", "marke quelle", QUELLE[st.source] || st.source) : null;
box.append(cover(st.track.cover_url, true),
textBlock(st.track.title, st.track.artist, marke, st.is_playing ? null : el("span", "m", "pausiert")));
fortschritt = st.track.duration_ms ? {
basis: (st.progress_ms || 0) + (st.is_playing ? z.player.age_ms || 0 : 0),
dauer: st.track.duration_ms, zeit: performance.now(), spielt: st.is_playing,
} : null;
} else {
box.append(el("span", "hinweis", z.player.online ? "Gerade läuft nichts." : "Der Player meldet sich nicht."));
fortschritt = null;
}
zeichneFortschritt();
$("naechster").textContent = st.next ? `Als Nächstes: ${st.next.title} – ${st.next.artist}` : "";
$("pi-fehler").hidden = !st.error;
$("pi-fehler").textContent = st.error || "";
if (typeof st.volume_percent === "number") {
// Nach einer Änderung den eigenen Wert halten, bis der Player ihn zurückmeldet (max. 15 s)
const ziel = lautstaerkeZiel;
const wartet = ziel && performance.now() < ziel.bis && st.volume_percent !== ziel.wert;
if (!wartet) {
lautstaerkeZiel = null;
setzeWert("lautstaerke", st.volume_percent);
if (document.activeElement !== $("lautstaerke")) $("lautstaerke-wert").textContent = st.volume_percent;
}
}
const stopp = z.settings.emergency_stop;
$("notaus-hinweis").hidden = !stopp;
const symbol = el("span", "symbol", stopp ? "✅" : "⛔");
symbol.setAttribute("aria-hidden", "true");
$("notaus").replaceChildren(symbol, el("span", null, stopp ? "Freigeben" : "Not-Aus"));
$("notaus").className = "gross mit-symbol voll " + (stopp ? "akzent" : "gefahr");
const liste = $("befehle");
liste.replaceChildren();
z.commands.forEach((b) => {
const li = el("li");
const status = { ok: "ausgeführt", fehler: "fehlgeschlagen", wartet: "wartet …", abgelaufen: "nicht abgeholt" }[b.status];
li.append(el("span", "zeit", uhrzeit(b.created_at)), textBlock(BEFEHL[b.type] || b.type, b.message || ""),
el("span", "status " + (b.status === "ok" ? "queued" : b.status === "wartet" ? "pending" : "rejected"), status));
liste.append(li);
});
if (!z.commands.length) liste.append(el("li", "hinweis", "Noch keine Befehle."));
}
function zeichneFortschritt() {
const balken = $("fortschritt-balken");
if (!fortschritt) { balken.style.width = "0"; return; }
const pos = fortschritt.basis + (fortschritt.spielt ? performance.now() - fortschritt.zeit : 0);
balken.style.width = Math.min(100, (pos / fortschritt.dauer) * 100) + "%";
}
function wunschZeile(w, istFreigabe) {
const li = el("li");
const extra = [];
if (w.priority) extra.push(el("span", "marke besitzer", "Besitzer"));
if (w.explicit) extra.push(el("span", "marke e", "E"));
const wer = w.priority ? null : el("span", "m", (w.guest_name ? w.guest_name + " · " : "") + uhrzeit(w.created_at));
const text = textBlock(w.title, w.artist, wer);
extra.forEach((x) => text.querySelector(".t").append(x));
const knoepfe = el("div", "aktionen");
if (istFreigabe) {
knoepfe.append(knopf("✓", "akzent", () => aktion(`/wish/${w.id}/approve`, {}, "Freigegeben"), "Freigeben"),
knopf("✕", "gefahr", () => aktion(`/wish/${w.id}/reject`, {}, "Abgelehnt"), "Ablehnen"));
} else {
knoepfe.append(knopf("↑", "", () => aktion(`/wish/${w.id}/up`, {}), "Nach oben"),
knopf("↓", "", () => aktion(`/wish/${w.id}/down`, {}), "Nach unten"),
knopf("✕", "gefahr", () => aktion(`/wish/${w.id}/delete`, {}, "Entfernt"), "Löschen"));
}
if (w.has_guest) {
knoepfe.append(knopf("🚫", "gefahr", async () => {
if (confirm(`Gast „${w.guest_name || "ohne Namen"}“ sperren und seine offenen Wünsche entfernen?`)) {
await aktion(`/wish/${w.id}/block-guest`, {}, "Gast gesperrt");
}
}, "Gast sperren"));
}
li.append(cover(w.cover_url), text, knoepfe);
return li;
}
function zeigeWuensche(z) {
setzeSchalter("s-annahme", !z.settings.wishes_paused);
setzeSchalter("s-moderation", z.settings.moderation);
const fl = $("freigabe");
fl.replaceChildren(...z.pending.map((w) => wunschZeile(w, true)));
$("freigabe-leer").hidden = z.pending.length > 0;
$("freigabe-karte").hidden = !z.settings.moderation && z.pending.length === 0;
const zahl = $("zahl-freigabe");
zahl.hidden = z.pending.length === 0;
zahl.textContent = z.pending.length;
const ws = $("warteschlange");
ws.replaceChildren(...z.queue.map((w) => wunschZeile(w, false)));
$("warteschlange-leer").hidden = z.queue.length > 0;
}
function zeigeLautsprecher(z) {
const bt = z.player.bluetooth || {};
const box = $("bt-aktuell");
box.replaceChildren();
if (bt.current) {
const status = el("span", "pille " + (bt.current.connected ? "gut" : "schlecht"), bt.current.connected ? "verbunden" : "getrennt");
box.append(textBlock(bt.current.name || "Unbekannter Lautsprecher", bt.current.mac), status);
} else {
box.append(el("span", "hinweis", z.player.online ? "Kein Bluetooth-Lautsprecher eingestellt." : "Player meldet sich nicht."));
}
const suchen = $("bt-suchen");
suchen.disabled = !!bt.scanning || !z.player.online;
suchen.textContent = bt.scanning ? "Läuft …" : "Nach Lautsprechern suchen";
$("bt-status").textContent = bt.scanning ? "Suche bzw. Kopplung läuft – das dauert bis zu 45 Sekunden." :
bt.scanned_at ? `Letzte Suche: ${uhrzeit(bt.scanned_at)} – ${(bt.devices || []).length} Audiogerät(e) gefunden.` : "";
const liste = $("bt-geraete");
liste.replaceChildren(...(bt.devices || []).map((g) => {
const li = el("li");
const istAktuell = bt.current && bt.current.mac === g.mac;
const info = [g.mac, g.paired ? "gekoppelt" : "neu"].join(" · ");
const knoepfe = el("div", "aktionen");
if (!istAktuell) {
const b = knopf("Verbinden", "akzent", async () => {
if (!confirm(`Auf „${g.name || g.mac}“ umstellen? Die Musik setzt dabei kurz aus.`)) return;
await aktion("/command", { type: "bt_connect", mac: g.mac }, "Verbinde – bitte warten …");
});
b.disabled = !!bt.scanning;
knoepfe.append(b);
} else {
knoepfe.append(el("span", "pille gut", "aktuell"));
}
li.append(textBlock(g.name || "Unbekannt", info), knoepfe);
return li;
}));
}
function zeigeEinstellungen(z) {
const e = z.settings;
setzeWert("e-n", e.wish_every_n);
setzeWert("e-sperrzeit", e.repeat_block_minutes);
setzeWert("e-limit-anzahl", e.wish_limit_count);
setzeWert("e-limit-minuten", e.wish_limit_minutes);
setzeSchalter("s-explizit", e.block_explicit);
setzeSchalter("s-zugang", e.access_enabled);
const link = $("playlist-link");
link.textContent = e.playlist_id || "keine";
if (e.playlist_id) link.href = "https://open.spotify.com/playlist/" + encodeURIComponent(e.playlist_id);
}
async function laden() {
try {
zustand = await api("/state");
} catch (_) { return; }
csrf = zustand.csrf;
zeigePillen(zustand);
zeigeWiedergabe(zustand);
zeigeWuensche(zustand);
zeigeEinstellungen(zustand);
zeigeLautsprecher(zustand);
}
// --- Gäste: Zugang & Sperrliste ------------------------------------------------------
async function ladeZugang() {
const z = await api("/access");
setzeSchalter("s-zugang", z.enabled);
$("einladungslink").value = z.link;
$("qr").src = "/admin/api/access/qr?t=" + Date.now();
}
async function ladeSperrliste() {
const daten = await api("/blocklist");
const art = { track: "Titel", artist: "Interpret", guest: "Gast", ip: "Gast (IP)" };
const liste = $("sperrliste");
liste.replaceChildren(...daten.items.map((s) => {
const li = el("li");
li.append(textBlock(s.label || "–", art[s.kind] || s.kind),
knopf("Entsperren", "", async () => { await api(`/blocklist/${s.id}/delete`, {}); toast("Entsperrt"); ladeSperrliste(); }));
return li;
}));
$("sperrliste-leer").hidden = daten.items.length > 0;
}
// --- Suche (Als Nächstes / Sperren) ---------------------------------------------------
function suchfeld(feldId, listeId, zeile) {
let timer = null;
let nr = 0;
$(feldId).addEventListener("input", (ereignis) => {
clearTimeout(timer);
const q = ereignis.target.value.trim();
if (q.length < 2) { nr++; $(listeId).replaceChildren(); return; }
timer = setTimeout(async () => {
const meine = ++nr;
try {
const daten = await api("/search?q=" + encodeURIComponent(q));
if (meine !== nr) return;
$(listeId).replaceChildren(...(daten.results.length ? daten.results.map(zeile) : [el("li", "hinweis", "Nichts gefunden.")]));
} catch (_) { /* Meldung kam schon */ }
}, 400);
});
}
function naechstesZeile(t) {
const li = el("li");
li.append(cover(t.cover_url), textBlock(t.title, t.artist),
knopf("Als Nächstes", "akzent", async () => {
await aktion("/play-next", { track_uri: t.track_uri }, `„${t.title}“ kommt als Nächstes`);
$("naechstes-suche").value = "";
$("naechstes-treffer").replaceChildren();
}));
return li;
}
function sperrZeile(t) {
const li = el("li");
const knoepfe = el("div", "aktionen");
knoepfe.append(knopf("Titel", "gefahr", async () => {
await api("/blocklist", { kind: "track", value: t.track_uri, label: `${t.title} – ${t.artist}` });
toast("Titel gesperrt"); ladeSperrliste();
}, "Titel sperren"));
(t.artists || []).forEach((a) => knoepfe.append(knopf(a.name, "gefahr", async () => {
await api("/blocklist", { kind: "artist", value: a.uri, label: a.name });
toast(`${a.name} gesperrt`); ladeSperrliste();
}, `Interpret ${a.name} sperren`)));
li.append(cover(t.cover_url), textBlock(t.title, t.artist), knoepfe);
return li;
}
// --- Verlauf -----------------------------------------------------------------------
async function ladeVerlauf() {
const daten = await api("/history?limit=200");
$("verlauf").replaceChildren(...daten.items.map((h) => {
const li = el("li");
const quelle = el("span", "marke quelle", QUELLE[h.source] || "?");
const wer = h.guest_name ? el("span", "m", "gewünscht von " + h.guest_name) : null;
const text = textBlock(h.title, h.artist, wer);
text.querySelector(".t").append(quelle);
li.append(el("span", "zeit", uhrzeit(h.started_at)), cover(h.cover_url), text);
return li;
}));
$("verlauf-leer").hidden = daten.items.length > 0;
}
// --- Start ---------------------------------------------------------------------------
function start() {
document.querySelectorAll("[data-reiter]").forEach((b) => b.addEventListener("click", () => zeigeReiter(b.dataset.reiter)));
document.querySelectorAll("[data-befehl]").forEach((b) => b.addEventListener("click", async () => {
b.disabled = true;
try { await aktion("/command", { type: b.dataset.befehl }, "Befehl gesendet"); } catch (_) { /* gemeldet */ }
b.disabled = false;
}));
const regler = $("lautstaerke");
regler.addEventListener("input", () => { $("lautstaerke-wert").textContent = regler.value; });
regler.addEventListener("change", () => {
lautstaerkeZiel = { wert: Number(regler.value), bis: performance.now() + 15000 };
aktion("/command", { type: "volume", percent: Number(regler.value) }, "Lautstärke gesendet")
.catch(() => { lautstaerkeZiel = null; });
});
$("notaus").addEventListener("click", async () => {
const stopp = !(zustand && zustand.settings.emergency_stop);
await aktion("/emergency", { stop: stopp }, stopp ? "Not-Aus ausgelöst" : "Freigegeben").catch(() => {});
});
const schalter = (id, key, umkehren) => $(id).addEventListener("change", (ereignis) => {
const wert = umkehren ? !ereignis.target.checked : ereignis.target.checked;
aktion("/settings", { [key]: wert }, "Gespeichert").catch(() => { ereignis.target.checked = !ereignis.target.checked; });
});
schalter("s-annahme", "wishes_paused", true);
schalter("s-moderation", "moderation");
schalter("s-explizit", "block_explicit");
document.querySelectorAll("[data-speichern]").forEach((b) => b.addEventListener("click", () => {
const werte = {};
b.dataset.speichern.split(",").forEach((paar) => {
const [feld, key] = paar.split(":");
werte[key] = Number($(feld).value);
});
aktion("/settings", werte, "Gespeichert").catch(() => {});
}));
$("s-zugang").addEventListener("change", (ereignis) => {
aktion("/access", { enabled: ereignis.target.checked }, ereignis.target.checked ? "Gastzugang offen" : "Gastzugang geschlossen")
.catch(() => { ereignis.target.checked = !ereignis.target.checked; });
});
$("link-kopieren").addEventListener("click", async () => {
try { await navigator.clipboard.writeText($("einladungslink").value); toast("Link kopiert"); }
catch (_) { $("einladungslink").select(); toast("Bitte manuell kopieren", true); }
});
$("link-erneuern").addEventListener("click", async () => {
if (!confirm("Neuen Link erzeugen? Der alte Link und alle bisherigen Gastzugänge werden ungültig.")) return;
await api("/access/renew", {});
toast("Neuer Link erzeugt");
ladeZugang();
});
$("playlist-wechseln").addEventListener("click", async () => {
await aktion("/playlist", { link: $("playlist-neu").value }, "Playlist gewechselt").catch(() => {});
$("playlist-neu").value = "";
});
$("bt-suchen").addEventListener("click", () => aktion("/command", { type: "bt_scan" }, "Suche gestartet").catch(() => {}));
$("neu-mischen").addEventListener("click", () => aktion("/reshuffle", {}, "Wird neu gemischt").catch(() => {}));
$("abmelden").addEventListener("click", async () => {
await api("/logout", {}).catch(() => {});
location.href = "/admin/login";
});
suchfeld("naechstes-suche", "naechstes-treffer", naechstesZeile);
suchfeld("sperr-suche", "sperr-treffer", sperrZeile);
laden();
setInterval(() => { if (!document.hidden) laden(); }, ABFRAGE_MS);
setInterval(zeichneFortschritt, 1000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) laden(); });
}
start();
+166
View File
@@ -0,0 +1,166 @@
/* Audiofy – Gastseite (mobil zuerst) */
:root {
--bg: #121212;
--karte: #1e1e1e;
--linie: #2c2c2c;
--text: #f2f2f2;
--leise: #a7a7a7;
--akzent: #1ed760;
--akzent-text: #0b0b0b;
--warnung: #f5a524;
--fehler: #ff6b6b;
--radius: 14px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main {
max-width: 640px;
margin: 0 auto;
padding: 16px 16px calc(32px + env(safe-area-inset-bottom));
}
header { text-align: center; margin: 8px 0 16px; }
h1 { margin: 0; font-size: 1.9rem; letter-spacing: .02em; }
.untertitel { margin: 0; color: var(--leise); }
h2 { margin: 0 0 12px; font-size: 1.05rem; color: var(--leise); font-weight: 600; text-transform: uppercase; letter-spacing: .06em; }
.karte {
background: var(--karte);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 16px;
}
.banner {
background: #3a2d10;
color: var(--warnung);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 16px;
font-weight: 600;
}
.banner.leise { background: var(--karte); color: var(--leise); font-weight: 400; }
.hinweis { color: var(--leise); margin: 8px 0 0; }
.klein { font-size: .875rem; }
input[type="search"], input[type="text"] {
width: 100%;
font: inherit;
font-size: 1.05rem; /* ≥ 16px verhindert Zoom auf iOS */
color: var(--text);
background: #2a2a2a;
border: 2px solid transparent;
border-radius: 999px;
padding: 12px 18px;
outline: none;
}
input:focus { border-color: var(--akzent); }
.name-zeile { display: block; margin-top: 10px; }
.turnstile { display: flex; justify-content: center; }
.turnstile:not(:empty) { margin-top: 10px; }
.name-zeile span { display: block; font-size: .875rem; color: var(--leise); margin: 0 0 4px 6px; }
.name-zeile input { font-size: 1rem; padding: 10px 16px; }
.liste { list-style: none; margin: 8px 0 0; padding: 0; }
.liste li {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--linie);
}
.liste li:first-child { border-top: 0; }
.liste.kompakt li { padding: 6px 0; }
.cover {
width: 52px;
height: 52px;
border-radius: 6px;
background: #333;
flex: 0 0 auto;
object-fit: cover;
}
.titel-zeile { display: flex; align-items: center; gap: 14px; }
.titel-zeile .cover { width: 72px; height: 72px; }
.text { min-width: 0; flex: 1; }
.text .t, .text .a {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.text .t { font-weight: 600; }
.text .a, .text .m { color: var(--leise); font-size: .9rem; }
.text .m { display: block; }
.marke {
display: inline-block;
font-size: .72rem;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
margin-left: 6px;
vertical-align: 2px;
background: var(--akzent);
color: var(--akzent-text);
}
.marke.e { background: #555; color: var(--text); }
button.wuenschen {
flex: 0 0 auto;
font: inherit;
font-weight: 700;
min-height: 44px;
min-width: 96px;
padding: 0 16px;
border: 0;
border-radius: 999px;
background: var(--akzent);
color: var(--akzent-text);
cursor: pointer;
}
button.wuenschen:disabled { background: #333; color: var(--leise); cursor: default; }
.gesperrt { color: var(--leise); font-size: .8rem; }
.fortschritt { height: 4px; background: #333; border-radius: 2px; margin-top: 12px; overflow: hidden; }
#fortschritt-balken { height: 100%; width: 0; background: var(--akzent); transition: width 1s linear; }
.status { font-size: .8rem; font-weight: 700; white-space: nowrap; }
.status.queued { color: var(--akzent); }
.status.pending { color: var(--warnung); }
.status.played { color: var(--leise); }
.status.rejected, .status.deleted { color: var(--fehler); }
.toast {
position: fixed;
left: 50%;
bottom: calc(24px + env(safe-area-inset-bottom));
transform: translateX(-50%);
max-width: calc(100% - 32px);
background: var(--text);
color: #111;
padding: 12px 18px;
border-radius: 12px;
font-weight: 600;
box-shadow: 0 6px 24px rgba(0, 0, 0, .5);
}
.toast.fehler { background: var(--fehler); color: #111; }
footer { text-align: center; color: #666; font-size: .8rem; margin-top: 24px; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); border: 0;
}
+237
View File
@@ -0,0 +1,237 @@
// Audiofy – Gastseite. Daten nur per textContent einsetzen (kein innerHTML mit fremden Inhalten).
"use strict";
const STATUS_ALLE_MS = 5000;
const SUCHE_VERZOEGERUNG_MS = 400;
const STATUS_TEXT = {
queued: "auf der Liste",
pending: "wartet auf Freigabe",
played: "gespielt",
rejected: "abgelehnt",
deleted: "entfernt",
};
const $ = (id) => document.getElementById(id);
let letzterStatus = null;
let schutz = { token: async () => null, reset() {}, bereit: () => true }; // Turnstile (falls aktiv)
let fortschrittBasis = null; // {progress, dauer, zeit, spielt}
function el(tag, klasse, text) {
const e = document.createElement(tag);
if (klasse) e.className = klasse;
if (text !== undefined && text !== null) e.textContent = text;
return e;
}
function cover(url, gross) {
const img = el("img", "cover");
img.alt = "";
img.loading = "lazy";
img.decoding = "async";
if (url && url.startsWith("https://i.scdn.co/")) img.src = url;
if (gross) img.width = img.height = 72;
return img;
}
function textBlock(titel, interpret, extra) {
const t = el("div", "text");
t.append(el("span", "t", titel), el("span", "a", interpret));
if (extra) t.append(extra);
return t;
}
let toastTimer = null;
function toast(text, fehler) {
const t = $("toast");
t.textContent = text;
t.className = "toast" + (fehler ? " fehler" : "");
t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.hidden = true; }, 4000);
}
async function api(pfad, optionen) {
const antwort = await fetch(pfad, Object.assign({ credentials: "same-origin" }, optionen));
let daten = null;
try { daten = await antwort.json(); } catch (_) { /* leer */ }
if (antwort.status === 401) { // Zugang abgelaufen/deaktiviert → Hinweisseite zeigen
location.reload();
throw new Error("Kein Zugang.");
}
if (!antwort.ok) {
const meldung = daten && typeof daten.detail === "string" ? daten.detail : "Da ist etwas schiefgelaufen.";
throw new Error(meldung);
}
return daten;
}
// --- Läuft gerade ---------------------------------------------------------
function zeigeJetzt(jetzt) {
const box = $("jetzt-inhalt");
box.replaceChildren();
if (!jetzt) {
box.className = "titel-zeile leer";
box.append(el("span", "hinweis", "Gerade läuft nichts."));
$("fortschritt").hidden = true;
fortschrittBasis = null;
return;
}
box.className = "titel-zeile";
const extra = jetzt.is_wish ? el("span", "marke", "Wunsch") : null;
const text = textBlock(jetzt.title, jetzt.artist);
if (extra) text.querySelector(".t").append(extra);
if (!jetzt.is_playing) text.append(el("span", "m", "pausiert"));
box.append(cover(jetzt.cover_url, true), text);
if (jetzt.duration_ms) {
fortschrittBasis = {
progress: jetzt.progress_ms + (jetzt.is_playing ? jetzt.age_ms : 0),
dauer: jetzt.duration_ms,
zeit: performance.now(),
spielt: jetzt.is_playing,
};
$("fortschritt").hidden = false;
zeichneFortschritt();
}
}
function zeichneFortschritt() {
if (!fortschrittBasis) return;
const b = fortschrittBasis;
const pos = b.progress + (b.spielt ? performance.now() - b.zeit : 0);
$("fortschritt-balken").style.width = Math.min(100, (pos / b.dauer) * 100) + "%";
}
// --- Status (Warteschlange, eigene Wünsche, Hinweise) -----------------------
function zeigeStatus(s) {
letzterStatus = s;
zeigeJetzt(s.now_playing);
$("hinweis-pausiert").hidden = !s.wishes_paused;
$("hinweis-moderation").hidden = !s.moderation || s.wishes_paused;
const l = s.limit;
$("limit-info").textContent = s.wishes_paused ? "" :
l.remaining > 0
? `Du kannst noch ${l.remaining} von ${l.count} Wünschen in ${l.minutes} Minuten abgeben.`
: `Limit erreicht – nächster Wunsch in ca. ${l.next_in_minutes} Min.`;
const ws = $("warteschlange");
ws.replaceChildren();
s.queue.forEach((w, i) => {
const li = el("li");
const wer = w.mine ? "dein Wunsch" : (w.name ? `gewünscht von ${w.name}` : null);
li.append(el("span", "status", String(i + 1) + "."), cover(w.cover_url),
textBlock(w.title, w.artist, wer ? el("span", "m", wer) : null));
ws.append(li);
});
$("warteschlange-leer").hidden = s.queue.length > 0;
const fi = $("freigabe-info");
fi.hidden = !s.pending_count;
fi.textContent = s.pending_count === 1 ? "1 Wunsch wartet auf Freigabe." : `${s.pending_count} Wünsche warten auf Freigabe.`;
const meine = $("meine");
meine.replaceChildren();
s.mine.forEach((w) => {
const li = el("li");
li.append(textBlock(w.title, w.artist), el("span", "status " + w.status, STATUS_TEXT[w.status] || w.status));
meine.append(li);
});
$("meine-karte").hidden = s.mine.length === 0;
aktualisiereKnoepfe();
}
async function ladeStatus() {
try {
zeigeStatus(await api("/api/status"));
} catch (_) { /* nächster Versuch beim nächsten Intervall */ }
}
// --- Suche & Wünschen --------------------------------------------------------
let sucheTimer = null;
let sucheNr = 0;
function aktualisiereKnoepfe() {
const aus = letzterStatus && (letzterStatus.wishes_paused || letzterStatus.limit.remaining === 0);
document.querySelectorAll("button.wuenschen").forEach((b) => {
if (!b.dataset.gesperrt && !b.dataset.erledigt) b.disabled = !!aus;
});
}
function zeigeTreffer(treffer) {
const liste = $("treffer");
liste.replaceChildren();
if (!treffer.length) {
liste.append(el("li", "hinweis", "Nichts gefunden."));
return;
}
treffer.forEach((t) => {
const li = el("li");
const text = textBlock(t.title, t.artist, t.blocked ? el("span", "gesperrt", t.blocked) : null);
if (t.explicit) text.querySelector(".t").append(el("span", "marke e", "E"));
const knopf = el("button", "wuenschen", "Wünschen");
knopf.type = "button";
if (t.blocked) { knopf.disabled = true; knopf.dataset.gesperrt = "1"; }
knopf.addEventListener("click", () => wuenschen(t, knopf));
li.append(cover(t.cover_url), text, knopf);
liste.append(li);
});
aktualisiereKnoepfe();
}
async function suchen(q) {
const nr = ++sucheNr;
try {
const daten = await api("/api/search?q=" + encodeURIComponent(q));
if (nr === sucheNr) zeigeTreffer(daten.results);
} catch (fehler) {
if (nr === sucheNr) toast(fehler.message, true);
}
}
async function wuenschen(titel, knopf) {
knopf.disabled = true;
const name = $("name").value.trim();
try { localStorage.setItem("audiofy-name", name); } catch (_) { /* privat */ }
try {
if (!schutz.bereit()) knopf.textContent = "Prüfung …";
const turnstile = await schutz.token();
const daten = await api("/api/wish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ track_uri: titel.track_uri, name: name || null, turnstile }),
}).finally(() => schutz.reset());
knopf.textContent = "✓ Gewünscht";
knopf.dataset.erledigt = "1";
toast(daten.message);
ladeStatus();
} catch (fehler) {
toast(fehler.message, true);
knopf.textContent = "Wünschen";
knopf.disabled = false;
}
}
function start() {
try { $("name").value = localStorage.getItem("audiofy-name") || ""; } catch (_) { /* privat */ }
$("suchfeld").addEventListener("input", (ereignis) => {
clearTimeout(sucheTimer);
const q = ereignis.target.value.trim();
if (q.length < 2) { sucheNr++; $("treffer").replaceChildren(); return; }
sucheTimer = setTimeout(() => suchen(q), SUCHE_VERZOEGERUNG_MS);
});
$("suchfeld").addEventListener("keydown", (ereignis) => {
if (ereignis.key === "Enter") { ereignis.target.blur(); } // Tastatur auf dem Handy schließen
});
window.AudiofyTurnstile.einrichten($("turnstile"), "wish").then((s) => { schutz = s; });
ladeStatus();
setInterval(() => { if (!document.hidden) ladeStatus(); }, STATUS_ALLE_MS);
setInterval(zeichneFortschritt, 1000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) ladeStatus(); });
}
start();
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<title>Audiofy – Musikwünsche</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="preconnect" href="https://challenges.cloudflare.com">
<script src="/static/turnstile.js" defer></script>
<script src="/static/app.js" defer></script>
</head>
<body>
<main>
<header>
<h1>Audiofy</h1>
<p class="untertitel">Musikwünsche</p>
</header>
<section id="jetzt" class="karte" aria-live="polite">
<h2>Läuft gerade</h2>
<div id="jetzt-inhalt" class="titel-zeile leer">
<span class="hinweis">Gerade läuft nichts.</span>
</div>
<div id="fortschritt" class="fortschritt" hidden><div id="fortschritt-balken"></div></div>
</section>
<div id="hinweis-pausiert" class="banner" hidden>Die Wunschannahme ist gerade pausiert.</div>
<div id="hinweis-moderation" class="banner leise" hidden>Wünsche werden vor dem Abspielen kurz geprüft.</div>
<section id="suche" class="karte">
<h2>Song wünschen</h2>
<label class="sr-only" for="suchfeld">Titel oder Interpret</label>
<input id="suchfeld" type="search" placeholder="Titel oder Interpret suchen …"
autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="100" enterkeyhint="search">
<label class="name-zeile">
<span>Dein Name (optional)</span>
<input id="name" type="text" maxlength="30" autocomplete="nickname" placeholder="z. B. Tante Erna">
</label>
<p id="limit-info" class="hinweis klein"></p>
<div id="turnstile" class="turnstile"></div>
<ul id="treffer" class="liste"></ul>
</section>
<section id="meine-karte" class="karte" hidden>
<h2>Deine Wünsche</h2>
<ul id="meine" class="liste kompakt"></ul>
</section>
<section class="karte">
<h2>Als Nächstes gewünscht</h2>
<p id="freigabe-info" class="hinweis klein" hidden></p>
<ul id="warteschlange" class="liste"></ul>
<p id="warteschlange-leer" class="hinweis">Noch keine Wünsche – sei die oder der Erste!</p>
</section>
<footer>Suche und Titelinfos: Spotify</footer>
</main>
<div id="toast" class="toast" role="status" aria-live="assertive" hidden></div>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<meta name="robots" content="noindex">
<title>Audiofy – Admin</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/admin.css">
<link rel="preconnect" href="https://challenges.cloudflare.com">
<script src="/static/turnstile.js" defer></script>
<script src="/static/login.js" defer></script>
</head>
<body>
<main>
<header>
<h1>Audiofy</h1>
<p class="untertitel">Admin-Bereich</p>
</header>
<form id="login" class="karte">
<h2>Anmelden</h2>
<label class="name-zeile">
<span>Passwort</span>
<input id="passwort" type="password" autocomplete="current-password" required maxlength="200">
</label>
<div id="turnstile" class="turnstile"></div>
<button class="gross akzent" type="submit">Anmelden</button>
<p id="fehler" class="hinweis fehlertext" role="alert"></p>
</form>
</main>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
// Audiofy – Admin-Login
"use strict";
let schutz = { token: async () => null, reset() {}, bereit: () => true }; // Turnstile (falls aktiv)
window.AudiofyTurnstile.einrichten(document.getElementById("turnstile"), "login").then((s) => { schutz = s; });
document.getElementById("login").addEventListener("submit", async (ereignis) => {
ereignis.preventDefault();
const fehler = document.getElementById("fehler");
const knopf = ereignis.target.querySelector("button");
fehler.textContent = "";
knopf.disabled = true;
try {
if (!schutz.bereit()) knopf.textContent = "Sicherheitsprüfung …";
const turnstile = await schutz.token();
knopf.textContent = "Anmelden …";
const antwort = await fetch("/admin/login", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: document.getElementById("passwort").value, turnstile }),
}).finally(() => schutz.reset());
if (antwort.ok) {
location.href = "/admin";
return;
}
let meldung = "Anmeldung fehlgeschlagen.";
try { meldung = (await antwort.json()).detail || meldung; } catch (_) { /* leer */ }
fehler.textContent = meldung;
} catch (_) {
fehler.textContent = "Keine Verbindung zum Server.";
}
knopf.textContent = "Anmelden";
knopf.disabled = false;
});
+73
View File
@@ -0,0 +1,73 @@
// Audiofy – Cloudflare Turnstile (Bot-Schutz). Lädt das Widget nur, wenn der Server einen Site-Key liefert.
"use strict";
window.AudiofyTurnstile = (() => {
let ladeVersprechen = null;
function skriptLaden() {
if (!ladeVersprechen) {
ladeVersprechen = new Promise((fertig, fehler) => {
const s = document.createElement("script");
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
s.async = true;
s.onload = () => fertig(window.turnstile);
s.onerror = () => fehler(new Error("Turnstile konnte nicht geladen werden"));
document.head.append(s);
});
}
return ladeVersprechen;
}
const LEER = { token: async () => null, reset() {}, bereit: () => true };
// Liefert {token(): Promise<string|null>, reset(), bereit()} – ohne Site-Key ein "leerer" Schutz.
async function einrichten(container, aktion) {
// Site-Key und Cloudflare-Skript gleichzeitig holen (spart einen Umlauf)
const skript = skriptLaden().catch(() => null);
let siteKey = null;
try {
siteKey = (await (await fetch("/api/turnstile", { credentials: "same-origin" })).json()).site_key;
} catch (_) { /* ohne Site-Key weiter */ }
if (!siteKey) return LEER;
let aktuell = null;
let warten = [];
const melden = (wert) => { aktuell = wert; warten.forEach((f) => f(wert)); warten = []; };
let ts = null;
let id = null;
try {
ts = await skript;
if (!ts) return LEER; // Skript nicht ladbar → Server entscheidet
id = ts.render(container, {
sitekey: siteKey,
action: aktion,
appearance: "interaction-only", // nur sichtbar, wenn Cloudflare eine Bestätigung braucht
language: "de",
callback: (t) => melden(t),
"expired-callback": () => { aktuell = null; },
"error-callback": () => { melden(null); },
});
} catch (_) {
return LEER; // Server entscheidet dann
}
return {
bereit: () => !!aktuell,
// wartet bis zu 15 s auf ein Token
token() {
if (aktuell) return Promise.resolve(aktuell);
return new Promise((fertig) => {
warten.push(fertig);
setTimeout(() => fertig(null), 15000);
});
},
// jedes Token gilt nur einmal → nach jeder Anfrage ein neues holen
reset() {
aktuell = null;
try { ts.reset(id); } catch (_) { /* egal */ }
},
};
}
return { einrichten };
})();
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<meta name="robots" content="noindex">
<title>Audiofy – Musikwünsche</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<main>
<header>
<h1>Audiofy</h1>
<p class="untertitel">Musikwünsche</p>
</header>
<section class="karte">
<h2>Nur für Gäste</h2>
<p>Musikwünsche sind nur mit Einladung möglich.</p>
<p class="hinweis">Bitte scanne den QR-Code vor Ort oder öffne den Einladungslink, den du bekommen hast.
Falls das nicht klappt, ist die Wunschannahme vielleicht gerade geschlossen.</p>
</section>
</main>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
"""Audiofy-Web – Cloudflare Turnstile: Bot-Schutz für Admin-Login und Wünsche.
Kein Domain-Umzug nötig: Der Browser holt sich bei Cloudflare ein Token, der
Server prüft es per siteverify. Ohne TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY in
der .env ist der Schutz aus (z. B. in Tests).
Ist Cloudflare nicht erreichbar, bleibt der Admin-Login gesperrt (sicher),
Wünsche gehen dagegen durch (die Party soll nicht stillstehen) – mit Logeintrag.
"""
import logging
from urllib.parse import urlsplit
import httpx
from fastapi import HTTPException, Request
from .config import konfig
log = logging.getLogger("audiofy.turnstile")
VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
def aktiv() -> bool:
k = konfig()
return bool(k.turnstile_site_key and k.turnstile_secret)
def _siteverify(daten: dict) -> dict:
r = httpx.post(VERIFY_URL, data=daten, timeout=8)
r.raise_for_status()
return r.json()
def pruefen(token: str | None, request: Request, aktion: str, bei_ausfall_erlauben: bool) -> None:
"""Wirft HTTPException, wenn das Turnstile-Token fehlt oder ungültig ist."""
if not aktiv():
return
if not token:
raise HTTPException(400, "Bitte warte kurz, bis die Sicherheitsprüfung abgeschlossen ist.")
daten = {"secret": konfig().turnstile_secret, "response": token}
if request.client:
daten["remoteip"] = request.client.host
try:
ergebnis = _siteverify(daten)
except (httpx.HTTPError, ValueError) as e:
if bei_ausfall_erlauben:
log.warning("Turnstile nicht erreichbar (%s) – %s ausnahmsweise ohne Prüfung erlaubt",
type(e).__name__, aktion)
return
log.error("Turnstile nicht erreichbar (%s) – %s abgelehnt", type(e).__name__, aktion)
raise HTTPException(503, "Die Sicherheitsprüfung ist gerade nicht erreichbar – bitte gleich nochmal versuchen.")
erwarteter_host = urlsplit(konfig().public_url).hostname
if (not ergebnis.get("success")
or (erwarteter_host and ergebnis.get("hostname") not in (None, erwarteter_host))
or (ergebnis.get("action") and ergebnis["action"] != aktion)):
log.info("Turnstile abgelehnt (%s): %s", aktion, ergebnis.get("error-codes"))
raise HTTPException(403, "Die Sicherheitsprüfung ist fehlgeschlagen – bitte nochmal versuchen.")
+5
View File
@@ -0,0 +1,5 @@
argon2-cffi==25.1.0
fastapi==0.143.3
httpx==0.28.1
segno==1.6.6
uvicorn==0.54.0
View File
+232
View File
@@ -0,0 +1,232 @@
"""Tests für den Admin-Bereich: Login, Sitzung, CSRF und alle Steuerfunktionen."""
import pytest
from argon2 import PasswordHasher
from fastapi.testclient import TestClient
from app import admin, config, db, gaeste
from app.spotify import spotify
from tests.test_gaeste import URI, FakeSpotify, sql
PASSWORT = "richtig-langes-passwort"
@pytest.fixture
def client(tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_text(f"DB_PATH={tmp_path / 'audiofy.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n"
f"PUBLIC_URL=https://musik.example.de\nADMIN_PASSWORD_HASH={PasswordHasher().hash(PASSWORT)}\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
config.konfig.cache_clear()
for name in ("suche_drossel", "wunsch_drossel", "einladung_drossel"):
monkeypatch.setattr(gaeste, name, gaeste.Drossel(1000, 60))
monkeypatch.setattr(admin, "login_drossel", gaeste.Drossel(5, 900))
from app.main import app
app.dependency_overrides[spotify] = FakeSpotify
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
config.konfig.cache_clear()
def anmelden(client) -> dict:
assert client.post("/admin/login", json={"password": PASSWORT}).status_code == 200
return {"X-CSRF-Token": client.get("/admin/api/state").json()["csrf"]}
def einstellung(key):
conn = db.verbinden()
wert = db.einstellungen(conn)[key]
conn.close()
return wert
def wunsch(status="queued", position=1.0, priority=0, guest_id=None, guest_ip=None, k="A"):
return sql("INSERT INTO wishes (track_uri, title, artist, status, priority, position, guest_id, guest_ip, created_at) "
"VALUES (?, ?, 'X', ?, ?, ?, ?, ?, ?) RETURNING id",
URI[k], f"Lied {k}", status, priority, position, guest_id, guest_ip, db.jetzt())[0]["id"]
# --- Zugang ----------------------------------------------------------------------
def test_ohne_login_gesperrt(client):
assert client.get("/admin", follow_redirects=False).headers["location"] == "/admin/login"
assert client.get("/admin/api/state").status_code == 401
assert client.post("/admin/api/command", json={"type": "skip"}).status_code == 401
def test_login_falsch_und_drossel(client):
for _ in range(5):
assert client.post("/admin/login", json={"password": "falsch"}).status_code == 401
assert client.post("/admin/login", json={"password": PASSWORT}).status_code == 429 # 6. Versuch
def test_login_cookie_und_seite(client):
r = client.post("/admin/login", json={"password": PASSWORT})
cookie = r.headers["set-cookie"].lower()
assert "httponly" in cookie and "samesite=strict" in cookie
assert client.get("/admin").status_code == 200
assert client.get("/admin/login", follow_redirects=False).headers["location"] == "/admin"
# in der Datenbank steht nur der Hash
wert = client.cookies.get(admin.ADMIN_COOKIE)
assert not sql("SELECT 1 FROM admin_sessions WHERE id = ?", wert)
def test_csrf_pflicht(client):
anmelden(client)
assert client.post("/admin/api/command", json={"type": "skip"}).status_code == 403
assert client.post("/admin/api/command", json={"type": "skip"},
headers={"X-CSRF-Token": "falsch"}).status_code == 403
def test_logout(client):
h = anmelden(client)
assert client.post("/admin/api/logout", headers=h).status_code == 200
assert client.get("/admin/api/state").status_code == 401
def test_kein_passwort_gesetzt(client, tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_text(env.read_text().replace("ADMIN_PASSWORD_HASH", "#"))
config.konfig.cache_clear()
assert client.post("/admin/login", json={"password": "egal"}).status_code == 503
# --- Wiedergabe ---------------------------------------------------------------------
def test_befehle_gehen_an_den_pi(client):
h = anmelden(client)
assert client.post("/admin/api/command", json={"type": "skip"}, headers=h).status_code == 200
assert client.post("/admin/api/command", json={"type": "volume", "percent": 40}, headers=h).status_code == 200
assert client.post("/admin/api/command", json={"type": "volume"}, headers=h).status_code == 422
assert client.post("/admin/api/command", json={"type": "rm -rf"}, headers=h).status_code == 422
befehle = sql("SELECT type, payload FROM commands ORDER BY id")
assert [(b["type"], b["payload"]) for b in befehle] == [("skip", "{}"), ("volume", '{"percent": 40}')]
assert [b["status"] for b in client.get("/admin/api/state").json()["commands"]] == ["wartet", "wartet"]
def test_not_aus(client):
h = anmelden(client)
client.post("/admin/api/emergency", json={"stop": True}, headers=h)
assert einstellung("emergency_stop") is True
client.post("/admin/api/emergency", json={"stop": False}, headers=h)
assert einstellung("emergency_stop") is False
assert [r["type"] for r in sql("SELECT type FROM commands ORDER BY id")] == ["pause", "play"]
def test_als_naechstes_vor_allen_wuenschen(client):
h = anmelden(client)
wunsch(position=1, k="A")
assert client.post("/admin/api/play-next", json={"track_uri": URI["B"]}, headers=h).status_code == 200
queue = client.get("/admin/api/state").json()["queue"]
assert [(w["title"], w["priority"]) for w in queue] == [("Lied B", 1), ("Lied A", 0)]
# --- Wünsche ------------------------------------------------------------------------
def test_freigabe_ablehnen_loeschen(client):
h = anmelden(client)
a, b, c = wunsch("pending", k="A"), wunsch("pending", k="B"), wunsch("queued", k="C")
client.post(f"/admin/api/wish/{a}/approve", headers=h)
client.post(f"/admin/api/wish/{b}/reject", headers=h)
client.post(f"/admin/api/wish/{c}/delete", headers=h)
status = {r["id"]: r["status"] for r in sql("SELECT id, status FROM wishes")}
assert status == {a: "queued", b: "rejected", c: "deleted"}
assert client.post("/admin/api/wish/999/approve", headers=h).status_code == 404
def test_umsortieren(client):
h = anmelden(client)
a, b, c = wunsch(position=1, k="A"), wunsch(position=2, k="B"), wunsch(position=3, k="C")
client.post(f"/admin/api/wish/{c}/up", headers=h)
client.post(f"/admin/api/wish/{a}/down", headers=h)
reihenfolge = [w["id"] for w in client.get("/admin/api/state").json()["queue"]]
assert reihenfolge == [c, a, b]
client.post(f"/admin/api/wish/{c}/up", headers=h) # ganz oben – bleibt
assert [w["id"] for w in client.get("/admin/api/state").json()["queue"]] == [c, a, b]
def test_gast_sperren(client):
h = anmelden(client)
a = wunsch(guest_id="gast1", guest_ip="hash1", k="A")
b = wunsch(guest_id="gast1", guest_ip="hash1", k="B", position=2)
andere = wunsch(guest_id="gast2", guest_ip="hash2", k="C", position=3)
client.post(f"/admin/api/wish/{a}/block-guest", headers=h)
sperren = {(r["kind"], r["value"]) for r in sql("SELECT kind, value FROM blocklist")}
assert sperren == {("guest", "gast1"), ("ip", "hash1")}
status = {r["id"]: r["status"] for r in sql("SELECT id, status FROM wishes")}
assert status == {a: "deleted", b: "deleted", andere: "queued"}
# --- Sperrliste & Einstellungen -------------------------------------------------------
def test_sperrliste(client):
h = anmelden(client)
assert client.post("/admin/api/blocklist", headers=h,
json={"kind": "artist", "value": "spotify:artist:" + "Z" * 22, "label": "Band Z"}).status_code == 200
assert client.post("/admin/api/blocklist", headers=h,
json={"kind": "track", "value": "spotify:artist:" + "Z" * 22, "label": "x"}).status_code == 422
eintraege = client.get("/admin/api/blocklist").json()["items"]
assert [(e["kind"], e["label"]) for e in eintraege] == [("artist", "Band Z")]
client.post(f"/admin/api/blocklist/{eintraege[0]['id']}/delete", headers=h)
assert client.get("/admin/api/blocklist").json()["items"] == []
def test_einstellungen_validiert(client):
h = anmelden(client)
assert client.post("/admin/api/settings", json={"wish_every_n": 4, "moderation": True}, headers=h).status_code == 200
assert (einstellung("wish_every_n"), einstellung("moderation")) == (4, True)
for falsch in ({"wish_every_n": 0}, {"wish_every_n": "4"}, {"moderation": 1}, {"access_token": "x"},
{"emergency_stop": True}):
assert client.post("/admin/api/settings", json=falsch, headers=h).status_code == 422, falsch
def test_playlist_wechseln(client):
h = anmelden(client)
link = "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc"
r = client.post("/admin/api/playlist", json={"link": link}, headers=h)
assert r.json()["playlist_id"] == "37i9dQZF1DXcBWIGoYBM5M"
assert einstellung("reshuffle_token") == 1
assert client.post("/admin/api/playlist", json={"link": "quatsch"}, headers=h).status_code == 422
client.post("/admin/api/reshuffle", headers=h)
assert einstellung("reshuffle_token") == 2
# --- Gastzugang -----------------------------------------------------------------------
def test_gastzugang_und_qr(client):
h = anmelden(client)
z = client.get("/admin/api/access").json()
assert z["enabled"] is False and z["link"].startswith("https://musik.example.de/z/")
client.post("/admin/api/access", json={"enabled": True}, headers=h)
assert einstellung("access_enabled") is True
alt = z["link"]
client.post("/admin/api/access/renew", headers=h)
assert client.get("/admin/api/access").json()["link"] != alt
svg = client.get("/admin/api/access/qr")
assert svg.headers["content-type"].startswith("image/svg+xml") and b"<svg" in svg.content
png = client.get("/admin/api/access/qr?format=png")
assert png.content[:8] == b"\x89PNG\r\n\x1a\n" and "attachment" in png.headers["content-disposition"]
def test_verlauf(client):
anmelden(client)
w = wunsch(k="A")
sql("UPDATE wishes SET guest_name = 'Erna' WHERE id = ?", w)
sql("INSERT INTO history (track_uri, title, artist, source, wish_id, started_at) VALUES (?, 'Lied A', 'X', 'wish', ?, ?)",
URI["A"], w, db.jetzt())
eintrag = client.get("/admin/api/history").json()["items"][0]
assert (eintrag["title"], eintrag["source"], eintrag["guest_name"]) == ("Lied A", "wish", "Erna")
def test_lautsprecher_befehle(client):
h = anmelden(client)
assert client.post("/admin/api/command", json={"type": "bt_scan"}, headers=h).status_code == 200
assert client.post("/admin/api/command", json={"type": "bt_connect"}, headers=h).status_code == 422
assert client.post("/admin/api/command", json={"type": "bt_connect", "mac": "falsch"}, headers=h).status_code == 422
assert client.post("/admin/api/command", json={"type": "bt_connect", "mac": "AA:BB:CC:DD:EE:FF"},
headers=h).status_code == 200
befehle = sql("SELECT type, payload FROM commands ORDER BY id")
assert [(b["type"], b["payload"]) for b in befehle] == [("bt_scan", "{}"),
("bt_connect", '{"mac": "AA:BB:CC:DD:EE:FF"}')]
assert "bluetooth" in client.get("/admin/api/state").json()["player"]
+257
View File
@@ -0,0 +1,257 @@
"""Tests für die Gastseite: Einladungszugang, Suche, Wünschen mit Schutzregeln, Status."""
import pytest
from fastapi.testclient import TestClient
from app import config, db, gaeste
from app.spotify import SpotifyFehler, spotify
URI = {k: "spotify:track:" + k * 22 for k in "ABCDE"}
KUENSTLER_GESPERRT = "spotify:artist:" + "Z" * 22
def titel(k, explicit=False, kuenstler="spotify:artist:" + "X" * 22):
return {"track_uri": URI[k], "title": f"Lied {k}", "artist": f"Band {k}", "artist_uris": [kuenstler],
"album": "Album", "cover_url": "https://i.scdn.co/image/x", "duration_ms": 200_000, "explicit": explicit}
class FakeSpotify:
def __init__(self):
self.titel_db = {URI["A"]: titel("A"), URI["B"]: titel("B"), URI["C"]: titel("C", explicit=True),
URI["D"]: titel("D", kuenstler=KUENSTLER_GESPERRT), URI["E"]: titel("E")}
def verfuegbar(self):
return True
def suche(self, q):
return list(self.titel_db.values())
def titel(self, uri):
if uri not in self.titel_db:
raise SpotifyFehler(404)
return dict(self.titel_db[uri])
@pytest.fixture
def client(tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_text(f"DB_PATH={tmp_path / 'audiofy.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
config.konfig.cache_clear()
for name in ("suche_drossel", "wunsch_drossel", "einladung_drossel"):
monkeypatch.setattr(gaeste, name, gaeste.Drossel(1000, 60))
from app.main import app
app.dependency_overrides[spotify] = FakeSpotify
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
config.konfig.cache_clear()
def setzen(**werte):
conn = db.verbinden()
for k, v in werte.items():
db.einstellung_setzen(conn, k, v)
conn.close()
def token():
conn = db.verbinden()
t = db.einstellungen(conn)["access_token"]
conn.close()
return t
def eintreten(client):
"""Zugang einschalten und Einladungslink öffnen."""
setzen(access_enabled=True)
r = client.get(f"/z/{token()}", follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/"
return client
def sql(abfrage, *params):
conn = db.verbinden()
with conn:
zeilen = conn.execute(abfrage, params).fetchall()
conn.close()
return zeilen
# --- Einladungszugang ------------------------------------------------------
def test_ohne_einladung_kein_zugang(client):
r = client.get("/")
assert r.status_code == 403 and "Nur für Gäste" in r.text
assert client.get("/api/status").status_code == 401
assert client.get("/api/search?q=ab").status_code == 401
assert client.post("/api/wish", json={"track_uri": URI["A"]}).status_code == 401
def test_token_vorhanden_und_zugang_anfangs_aus(client):
assert len(token()) >= 12
assert client.get(f"/z/{token()}", follow_redirects=False).status_code == 403 # access_enabled = False
def test_einladung_gibt_zugang(client):
eintreten(client)
r = client.get("/")
assert r.status_code == 200 and "Song wünschen" in r.text
assert client.get("/api/status").status_code == 200
def test_falscher_link(client):
setzen(access_enabled=True)
assert client.get("/z/falsch", follow_redirects=False).status_code == 403
assert client.get("/api/status").status_code == 401
def test_zugang_aus_sperrt_alle(client):
eintreten(client)
setzen(access_enabled=False)
assert client.get("/api/status").status_code == 401
assert client.get("/").status_code == 403
def test_neuer_link_macht_alte_zugaenge_ungueltig(client):
eintreten(client)
alt = token()
setzen(access_token=db.neuer_zugangs_token())
assert client.get("/api/status").status_code == 401
assert client.get(f"/z/{alt}", follow_redirects=False).status_code == 403
# --- Suche -------------------------------------------------------------------
def test_suche_markiert_gesperrte(client):
eintreten(client)
setzen(block_explicit=True)
sql("INSERT INTO blocklist (kind, value, created_at) VALUES ('artist', ?, 'x')", KUENSTLER_GESPERRT)
client.post("/api/wish", json={"track_uri": URI["B"]})
treffer = {t["track_uri"][-1]: t["blocked"] for t in client.get("/api/search?q=lied").json()["results"]}
assert treffer["A"] is None
assert "Wunschliste" in treffer["B"]
assert "explizit" in treffer["C"]
assert "nicht gewünscht" in treffer["D"]
assert "artist_uris" not in client.get("/api/search?q=lied").json()["results"][0]
def test_suche_mindestlaenge(client):
eintreten(client)
assert client.get("/api/search?q=a").status_code == 422
# --- Wünschen ----------------------------------------------------------------
def test_wunsch_landet_in_warteschlange(client):
eintreten(client)
r = client.post("/api/wish", json={"track_uri": URI["A"], "name": " Tante Erna "})
assert r.status_code == 200 and r.json()["status"] == "queued"
w = sql("SELECT title, guest_name, guest_ip, guest_id FROM wishes")[0]
assert (w["title"], w["guest_name"]) == ("Lied A", "Tante Erna")
assert w["guest_ip"] != "testclient" and len(w["guest_ip"]) == 24 # nur Hash
s = client.get("/api/status").json()
assert [q["title"] for q in s["queue"]] == ["Lied A"] and s["queue"][0]["mine"] is True
assert s["mine"][0]["status"] == "queued" and s["limit"]["remaining"] == 1
def test_titelinfos_kommen_vom_server(client):
eintreten(client)
client.post("/api/wish", json={"track_uri": URI["A"], "title": "Gefälscht"})
assert sql("SELECT title FROM wishes")[0]["title"] == "Lied A"
def test_moderation(client):
eintreten(client)
setzen(moderation=True)
r = client.post("/api/wish", json={"track_uri": URI["A"]})
assert r.json()["status"] == "pending"
s = client.get("/api/status").json()
assert s["queue"] == [] and s["pending_count"] == 1
def test_pausiert(client):
eintreten(client)
setzen(wishes_paused=True)
r = client.post("/api/wish", json={"track_uri": URI["A"]})
assert r.status_code == 403 and "pausiert" in r.json()["detail"]
def test_limit_pro_gast(client):
eintreten(client)
assert client.post("/api/wish", json={"track_uri": URI["A"]}).status_code == 200
assert client.post("/api/wish", json={"track_uri": URI["B"]}).status_code == 200
r = client.post("/api/wish", json={"track_uri": URI["E"]})
assert r.status_code == 429 and "Limit" in r.json()["detail"]
def test_keine_duplikate(client):
eintreten(client)
setzen(wish_limit_count=10)
client.post("/api/wish", json={"track_uri": URI["A"]})
r = client.post("/api/wish", json={"track_uri": URI["A"]})
assert r.status_code == 409 and "Wunschliste" in r.json()["detail"]
def test_kuerzlich_gespielt_und_laeuft_gerade(client):
eintreten(client)
setzen(wish_limit_count=10)
sql("INSERT INTO history (track_uri, title, artist, started_at) VALUES (?, 'x', 'y', ?)", URI["A"], db.jetzt())
sql("UPDATE pi_state SET track_uri = ? WHERE id = 1", URI["B"])
assert client.post("/api/wish", json={"track_uri": URI["A"]}).status_code == 409
assert client.post("/api/wish", json={"track_uri": URI["B"]}).status_code == 409
alt = "2020-01-01T00:00:00+00:00" # außerhalb der Sperrzeit → wieder erlaubt
sql("UPDATE history SET started_at = ?", alt)
assert client.post("/api/wish", json={"track_uri": URI["A"]}).status_code == 200
def test_explizit_und_sperrliste(client):
eintreten(client)
setzen(block_explicit=True, wish_limit_count=10)
assert client.post("/api/wish", json={"track_uri": URI["C"]}).status_code == 409
sql("INSERT INTO blocklist (kind, value, created_at) VALUES ('artist', ?, 'x')", KUENSTLER_GESPERRT)
assert client.post("/api/wish", json={"track_uri": URI["D"]}).status_code == 409
sql("INSERT INTO blocklist (kind, value, created_at) VALUES ('track', ?, 'x')", URI["E"])
assert client.post("/api/wish", json={"track_uri": URI["E"]}).status_code == 409
def test_gesperrter_gast(client):
eintreten(client)
gid = client.cookies.get(gaeste.GAST_COOKIE)
sql("INSERT INTO blocklist (kind, value, created_at) VALUES ('guest', ?, 'x')", gid)
r = client.post("/api/wish", json={"track_uri": URI["A"]})
assert r.status_code == 403 and "Gerät" in r.json()["detail"]
def test_ungueltige_eingaben(client):
eintreten(client)
assert client.post("/api/wish", json={"track_uri": "spotify:album:x"}).status_code == 422
assert client.post("/api/wish", json={"track_uri": URI["A"], "name": "x" * 31}).status_code == 422
unbekannt = "spotify:track:" + "Q" * 22
assert client.post("/api/wish", json={"track_uri": unbekannt}).status_code == 404
def test_fremde_herkunft(client):
eintreten(client)
r = client.post("/api/wish", json={"track_uri": URI["A"]}, headers={"Origin": "https://boese.example"})
assert r.status_code == 403
# --- Status ------------------------------------------------------------------
def test_laeuft_gerade_und_offline(client):
eintreten(client)
assert client.get("/api/status").json()["now_playing"] is None
sql("UPDATE pi_state SET payload = ?, updated_at = ?, last_seen = ? WHERE id = 1",
'{"track": {"track_uri": "%s", "title": "Lied A", "artist": "Band"}, "is_playing": true, '
'"progress_ms": 5000, "source": "wish"}' % URI["A"], db.jetzt(), db.jetzt())
jetzt = client.get("/api/status").json()["now_playing"]
assert jetzt["title"] == "Lied A" and jetzt["is_wish"] is True
sql("UPDATE pi_state SET last_seen = '2020-01-01T00:00:00+00:00'") # Player seit Langem still → offline
s = client.get("/api/status").json()
assert s["online"] is False and s["now_playing"] is None
def test_sicherheits_header(client):
r = client.get("/")
assert "default-src 'self'" in r.headers["content-security-policy"]
+164
View File
@@ -0,0 +1,164 @@
"""Tests für die Player-API (Wegwerf-Datenbank pro Test)."""
import pytest
from fastapi.testclient import TestClient
from app import config, db
TOKEN = "test-token-0123456789"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
URI_A = "spotify:track:" + "A" * 22
URI_B = "spotify:track:" + "B" * 22
URI_C = "spotify:track:" + "C" * 22
@pytest.fixture
def client(tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_text(f"PLAYER_API_TOKEN={TOKEN}\nDB_PATH={tmp_path / 'audiofy.db'}\nSPOTIFY_PLAYLIST_ID=startliste\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
config.konfig.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
config.konfig.cache_clear()
def wunsch(uri, status="queued", position=1.0, priority=0, title="Titel"):
conn = db.verbinden()
with conn:
cur = conn.execute("INSERT INTO wishes (track_uri, title, artist, status, priority, position, created_at) "
"VALUES (?, ?, 'Interpret', ?, ?, ?, ?)", (uri, title, status, priority, position, db.jetzt()))
conn.close()
return cur.lastrowid
def test_token_pflicht(client):
assert client.get("/api/player/config").status_code == 401
assert client.get("/api/player/config", headers={"Authorization": "Bearer falsch"}).status_code == 401
assert client.get("/api/player/config", headers=AUTH).status_code == 200
def test_config_standardwerte(client):
daten = client.get("/api/player/config", headers=AUTH).json()
assert daten == {"wish_every_n": 3, "playlist_id": "startliste", "reshuffle_token": 0,
"stopped": False, "commands": []}
def test_next_wish_leer_und_reihenfolge(client):
assert client.get("/api/player/next-wish", headers=AUTH).json() == {"wish": None}
wunsch(URI_A, position=2)
b = wunsch(URI_B, position=1)
wunsch(URI_C, status="pending", position=0) # zur Freigabe – zählt nicht
assert client.get("/api/player/next-wish", headers=AUTH).json()["wish"]["id"] == b
besitzer = wunsch(URI_C, position=9, priority=1) # "Als Nächstes" schlägt alles
w = client.get("/api/player/next-wish", headers=AUTH).json()["wish"]
assert w["id"] == besitzer and w["priority"] is True
def test_wunsch_gespielt_idempotent(client):
a = wunsch(URI_A)
assert client.post(f"/api/player/wish/{a}/played", headers=AUTH).json() == {"ok": True, "status": "played"}
assert client.post(f"/api/player/wish/{a}/played", headers=AUTH).json() == {"ok": True, "status": "played"}
assert client.get("/api/player/next-wish", headers=AUTH).json() == {"wish": None}
assert client.post("/api/player/wish/9999/played", headers=AUTH).status_code == 404
def test_now_playing_schreibt_verlauf_nur_bei_wechsel(client):
a = wunsch(URI_A)
titel = {"track_uri": URI_A, "title": "Lied A", "artist": "Band"}
for _ in range(3): # wiederholte Meldungen desselben Titels → ein Verlaufseintrag
r = client.post("/api/player/now-playing", headers=AUTH,
json={"track": titel, "is_playing": True, "source": "wish", "wish_id": a})
assert r.status_code == 200
client.post("/api/player/now-playing", headers=AUTH, json={"track": titel, "is_playing": True, "new_track": True})
client.post("/api/player/now-playing", headers=AUTH,
json={"track": {"track_uri": URI_B, "title": "Lied B", "artist": "X"}, "is_playing": True})
conn = db.verbinden()
verlauf = conn.execute("SELECT track_uri, source, wish_id FROM history ORDER BY id").fetchall()
conn.close()
assert [(v["track_uri"], v["source"], v["wish_id"]) for v in verlauf] == [
(URI_A, "wish", a), (URI_A, None, None), (URI_B, None, None)]
def test_pausierter_titel_nicht_im_verlauf(client):
titel = {"track_uri": URI_A, "title": "Lied A", "artist": "Band"}
client.post("/api/player/now-playing", headers=AUTH, json={"track": titel, "is_playing": False})
conn = db.verbinden()
assert conn.execute("SELECT COUNT(*) FROM history").fetchone()[0] == 0
conn.close()
client.post("/api/player/now-playing", headers=AUTH, json={"track": titel, "is_playing": True})
conn = db.verbinden()
assert conn.execute("SELECT COUNT(*) FROM history").fetchone()[0] == 1
conn.close()
def test_now_playing_validiert(client):
r = client.post("/api/player/now-playing", headers=AUTH,
json={"track": {"track_uri": "kaputt", "title": "x", "artist": "y"}})
assert r.status_code == 422
def test_befehle_abholen_quittieren_verfallen(client):
conn = db.verbinden()
skip = db.befehl_anlegen(conn, "skip")
vol = db.befehl_anlegen(conn, "volume", {"percent": 40})
with conn: # ein uralter Befehl darf nicht mehr ausgeliefert werden
conn.execute("INSERT INTO commands (type, created_at) VALUES ('skip', '2020-01-01T00:00:00+00:00')")
conn.close()
befehle = client.get("/api/player/config", headers=AUTH).json()["commands"]
assert [(b["id"], b["type"], b["payload"]) for b in befehle] == [(skip, "skip", {}), (vol, "volume", {"percent": 40})]
assert client.post(f"/api/player/commands/{skip}/ack", headers=AUTH, json={"ok": True}).status_code == 200
assert [b["id"] for b in client.get("/api/player/config", headers=AUTH).json()["commands"]] == [vol]
assert client.post("/api/player/commands/9999/ack", headers=AUTH, json={}).status_code == 404
def test_not_aus(client):
wunsch(URI_A)
conn = db.verbinden()
db.einstellung_setzen(conn, "emergency_stop", True)
conn.close()
assert client.get("/api/player/config", headers=AUTH).json()["stopped"] is True
assert client.get("/api/player/next-wish", headers=AUTH).json() == {"wish": None}
def test_lebenszeichen(client):
client.get("/api/player/config", headers=AUTH)
conn = db.verbinden()
assert conn.execute("SELECT last_seen FROM pi_state").fetchone()["last_seen"] is not None
conn.close()
def test_bluetooth_melden(client):
daten = {"current": {"mac": "AA:11:22:33:44:55", "name": "Box", "paired": True, "connected": True},
"devices": [{"mac": "AA:BB:CC:DD:EE:FF", "name": "Neu"}], "scanning": False,
"scanned_at": "2026-10-17T18:00:00+00:00"}
assert client.post("/api/player/bluetooth", headers=AUTH, json=daten).status_code == 200
from app.player_api import player_zustand
conn = db.verbinden()
bt = player_zustand(conn)["bluetooth"]
conn.close()
assert bt["current"]["name"] == "Box" and bt["devices"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
falsch = {"devices": [{"mac": "kein-mac"}]}
assert client.post("/api/player/bluetooth", headers=AUTH, json=falsch).status_code == 422
assert client.post("/api/player/bluetooth", json=daten).status_code == 401
def test_alte_datenbank_wird_nachgeruestet(tmp_path):
import sqlite3
pfad = tmp_path / "alt.db"
alt = sqlite3.connect(pfad)
alt.execute("CREATE TABLE pi_state (id INTEGER PRIMARY KEY CHECK (id = 1), track_uri TEXT, "
"payload TEXT NOT NULL DEFAULT '{}', updated_at TEXT, last_seen TEXT)")
alt.commit()
alt.close()
db.initialisieren(pfad)
conn = sqlite3.connect(pfad)
assert "bluetooth" in {r[1] for r in conn.execute("PRAGMA table_info(pi_state)")}
conn.close()
def test_alter_pfad_api_pi_bleibt_nutzbar(client):
"""Ältere Player sprechen noch /api/pi/… an."""
assert client.get("/api/pi/config", headers=AUTH).status_code == 200
assert client.get("/api/pi/config").status_code == 401
+122
View File
@@ -0,0 +1,122 @@
"""Tests für Cloudflare Turnstile (siteverify wird durch einen Platzhalter ersetzt)."""
import httpx
import pytest
from argon2 import PasswordHasher
from fastapi.testclient import TestClient
from app import admin, config, gaeste, turnstile
from app.spotify import spotify
from tests.test_gaeste import URI, FakeSpotify, eintreten
PASSWORT = "richtig-langes-passwort"
@pytest.fixture
def antworten(monkeypatch):
"""Steuert, was siteverify antwortet; merkt sich die Anfragen."""
zustand = {"ergebnis": {"success": True, "hostname": "musik.example.de"}, "anfragen": []}
def siteverify(daten):
zustand["anfragen"].append(daten)
if isinstance(zustand["ergebnis"], Exception):
raise zustand["ergebnis"]
return zustand["ergebnis"]
monkeypatch.setattr(turnstile, "_siteverify", siteverify)
return zustand
@pytest.fixture
def client(tmp_path, monkeypatch, antworten):
env = tmp_path / ".env"
env.write_text(f"DB_PATH={tmp_path / 'audiofy.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n"
f"PUBLIC_URL=https://musik.example.de\nADMIN_PASSWORD_HASH={PasswordHasher().hash(PASSWORT)}\n"
"TURNSTILE_SITE_KEY=site-oeffentlich\nTURNSTILE_SECRET_KEY=secret-geheim\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
config.konfig.cache_clear()
for name in ("suche_drossel", "wunsch_drossel", "einladung_drossel"):
monkeypatch.setattr(gaeste, name, gaeste.Drossel(1000, 60))
monkeypatch.setattr(admin, "login_drossel", gaeste.Drossel(1000, 900))
from app.main import app
app.dependency_overrides[spotify] = FakeSpotify
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
config.konfig.cache_clear()
def test_site_key_oeffentlich(client):
assert client.get("/api/turnstile").json() == {"site_key": "site-oeffentlich"}
def test_wunsch_ohne_token_abgelehnt(client):
eintreten(client)
r = client.post("/api/wish", json={"track_uri": URI["A"]})
assert r.status_code == 400 and "Sicherheitsprüfung" in r.json()["detail"]
def test_wunsch_mit_gueltigem_token(client, antworten):
eintreten(client)
assert client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "tok"}).status_code == 200
anfrage = antworten["anfragen"][0]
assert anfrage["secret"] == "secret-geheim" and anfrage["response"] == "tok"
def test_wunsch_ungueltiges_token(client, antworten):
eintreten(client)
antworten["ergebnis"] = {"success": False, "error-codes": ["invalid-input-response"]}
assert client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "tok"}).status_code == 403
def test_falscher_hostname(client, antworten):
eintreten(client)
antworten["ergebnis"] = {"success": True, "hostname": "boese.example"}
assert client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "tok"}).status_code == 403
def test_falsche_aktion(client, antworten):
"""Ein Token vom Login-Formular darf keinen Wunsch abschicken (und umgekehrt)."""
eintreten(client)
antworten["ergebnis"] = {"success": True, "hostname": "musik.example.de", "action": "login"}
assert client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "tok"}).status_code == 403
def test_wunsch_bei_cloudflare_ausfall_erlaubt(client, antworten):
eintreten(client)
antworten["ergebnis"] = httpx.ConnectError("weg")
assert client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "tok"}).status_code == 200
def test_limit_greift_vor_turnstile(client, antworten):
"""Abgewiesene Anfragen (z. B. Limit) kosten keine Turnstile-Prüfung."""
eintreten(client)
client.post("/api/wish", json={"track_uri": URI["A"], "turnstile": "t1"})
client.post("/api/wish", json={"track_uri": URI["B"], "turnstile": "t2"})
antworten["anfragen"].clear()
assert client.post("/api/wish", json={"track_uri": URI["E"], "turnstile": "t3"}).status_code == 429
assert antworten["anfragen"] == []
def test_login_ohne_token(client):
assert client.post("/admin/login", json={"password": PASSWORT}).status_code == 400
def test_login_mit_token(client):
assert client.post("/admin/login", json={"password": PASSWORT, "turnstile": "tok"}).status_code == 200
def test_login_ungueltiges_token(client, antworten):
antworten["ergebnis"] = {"success": False}
assert client.post("/admin/login", json={"password": PASSWORT, "turnstile": "tok"}).status_code == 403
def test_login_bei_cloudflare_ausfall_gesperrt(client, antworten):
antworten["ergebnis"] = httpx.ConnectError("weg")
assert client.post("/admin/login", json={"password": PASSWORT, "turnstile": "tok"}).status_code == 503
def test_csp_erlaubt_turnstile(client):
csp = client.get("/admin/login").headers["content-security-policy"]
assert "script-src 'self' https://challenges.cloudflare.com" in csp
assert "frame-src https://challenges.cloudflare.com" in csp
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Audiofy-Web – Verwaltung auf der Kommandozeile.
geheimnisse SESSION_SECRET und PLAYER_API_TOKEN erzeugen (nur falls noch nicht vorhanden)
passwort Admin-Passwort setzen (verdeckte Eingabe, gespeichert wird nur der argon2-Hash)
turnstile Cloudflare-Turnstile-Schlüssel setzen (Secret-Key verdeckt)
Auf dem Server als Dienst-Benutzer ausführen, damit die .env ihm gehört:
sudo -u audiofy /opt/audiofy/.venv/bin/python /opt/audiofy/app/verwaltung.py passwort
"""
import argparse
import getpass
import os
import secrets
import sys
from pathlib import Path
from argon2 import PasswordHasher
sys.path.insert(0, str(Path(__file__).resolve().parent))
from app.config import env_lesen, env_wert_setzen # noqa: E402
MIN_LAENGE = 12
def geheimnisse(env: Path) -> None:
werte = env_lesen(env) if env.is_file() else {}
if werte.get("PI_API_TOKEN") and not werte.get("PLAYER_API_TOKEN"):
# alten Namen übernehmen, damit ein bereits eingerichteter Player weiterläuft
env_wert_setzen(env, "PLAYER_API_TOKEN", werte["PI_API_TOKEN"])
werte["PLAYER_API_TOKEN"] = werte["PI_API_TOKEN"]
print("PLAYER_API_TOKEN: aus PI_API_TOKEN übernommen")
for key in ("SESSION_SECRET", "PLAYER_API_TOKEN"):
if werte.get(key):
print(f"{key}: schon vorhanden – unverändert")
else:
env_wert_setzen(env, key, secrets.token_urlsafe(48))
print(f"{key}: neu erzeugt")
def passwort(env: Path) -> None:
erstes = getpass.getpass("Neues Admin-Passwort: ")
if len(erstes) < MIN_LAENGE:
sys.exit(f"Zu kurz – mindestens {MIN_LAENGE} Zeichen.")
if getpass.getpass("Wiederholen: ") != erstes:
sys.exit("Die Eingaben stimmen nicht überein.")
env_wert_setzen(env, "ADMIN_PASSWORD_HASH", PasswordHasher().hash(erstes))
print(f"Passwort-Hash in {env} gespeichert. Bitte den Dienst neu starten (audiofy-web.service).")
def turnstile(env: Path) -> None:
site_key = input("Turnstile Site-Key (öffentlich): ").strip()
secret = getpass.getpass("Turnstile Secret-Key (verdeckt): ").strip()
if not site_key or not secret:
sys.exit("Beide Schlüssel sind nötig.")
env_wert_setzen(env, "TURNSTILE_SITE_KEY", site_key)
env_wert_setzen(env, "TURNSTILE_SECRET_KEY", secret)
print(f"Turnstile-Schlüssel in {env} gespeichert. Bitte den Dienst neu starten (audiofy-web.service).")
def main() -> None:
parser = argparse.ArgumentParser(description="Audiofy-Web – Verwaltung")
parser.add_argument("aktion", choices=("geheimnisse", "passwort", "turnstile"))
parser.add_argument("--env", type=Path, default=Path(os.environ.get("AUDIOFY_ENV", "/opt/audiofy/.env")))
args = parser.parse_args()
{"geheimnisse": geheimnisse, "passwort": passwort, "turnstile": turnstile}[args.aktion](args.env)
if __name__ == "__main__":
main()