From 3610de7baecd4420663e3c5eabdb1625007ea9f9 Mon Sep 17 00:00:00 2001 From: Dominic Alexander Windolph Date: Sat, 17 Oct 2026 19:16:39 +0100 Subject: [PATCH] =?UTF-8?q?Audiofy=20=E2=80=93=20Musikw=C3=BCnsche=20f?= =?UTF-8?q?=C3=BCr=20Partys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server (Website, Admin-Bereich, Player-API) und Player (raspotify + Controller) mit Install-Skripten für Debian-Systeme. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 17 + README.md | 163 +++++ install/install-player.sh | 195 ++++++ install/install-server.sh | 168 +++++ install/lib.sh | 146 ++++ install/vorlagen/20auto-upgrades | 2 + install/vorlagen/Caddyfile | 19 + install/vorlagen/audiofy-controller.service | 31 + install/vorlagen/audiofy-web.service | 30 + install/vorlagen/fail2ban-audiofy.conf | 8 + install/vorlagen/journald-10-audiofy.conf | 2 + install/vorlagen/raspotify-10-audiofy.conf | 8 + install/vorlagen/sshd-10-audiofy.conf | 8 + player/auth.py | 179 +++++ player/controller.py | 716 ++++++++++++++++++++ player/lautsprecher.py | 110 +++ player/requirements.txt | 1 + player/server_client.py | 94 +++ player/spotify_client.py | 128 ++++ server/app/__init__.py | 1 + server/app/admin.py | 408 +++++++++++ server/app/config.py | 70 ++ server/app/db.py | 201 ++++++ server/app/gaeste.py | 335 +++++++++ server/app/main.py | 50 ++ server/app/player_api.py | 167 +++++ server/app/spotify.py | 116 ++++ server/app/static/admin.css | 127 ++++ server/app/static/admin.html | 176 +++++ server/app/static/admin.js | 450 ++++++++++++ server/app/static/app.css | 166 +++++ server/app/static/app.js | 237 +++++++ server/app/static/index.html | 62 ++ server/app/static/login.html | 33 + server/app/static/login.js | 35 + server/app/static/turnstile.js | 73 ++ server/app/static/zugang.html | 25 + server/app/turnstile.py | 58 ++ server/requirements.txt | 5 + server/tests/__init__.py | 0 server/tests/test_admin.py | 232 +++++++ server/tests/test_gaeste.py | 257 +++++++ server/tests/test_player_api.py | 164 +++++ server/tests/test_turnstile.py | 122 ++++ server/verwaltung.py | 71 ++ 45 files changed, 5666 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 install/install-player.sh create mode 100755 install/install-server.sh create mode 100644 install/lib.sh create mode 100644 install/vorlagen/20auto-upgrades create mode 100644 install/vorlagen/Caddyfile create mode 100644 install/vorlagen/audiofy-controller.service create mode 100644 install/vorlagen/audiofy-web.service create mode 100644 install/vorlagen/fail2ban-audiofy.conf create mode 100644 install/vorlagen/journald-10-audiofy.conf create mode 100644 install/vorlagen/raspotify-10-audiofy.conf create mode 100644 install/vorlagen/sshd-10-audiofy.conf create mode 100755 player/auth.py create mode 100644 player/controller.py create mode 100644 player/lautsprecher.py create mode 100644 player/requirements.txt create mode 100644 player/server_client.py create mode 100644 player/spotify_client.py create mode 100644 server/app/__init__.py create mode 100644 server/app/admin.py create mode 100644 server/app/config.py create mode 100644 server/app/db.py create mode 100644 server/app/gaeste.py create mode 100644 server/app/main.py create mode 100644 server/app/player_api.py create mode 100644 server/app/spotify.py create mode 100644 server/app/static/admin.css create mode 100644 server/app/static/admin.html create mode 100644 server/app/static/admin.js create mode 100644 server/app/static/app.css create mode 100644 server/app/static/app.js create mode 100644 server/app/static/index.html create mode 100644 server/app/static/login.html create mode 100644 server/app/static/login.js create mode 100644 server/app/static/turnstile.js create mode 100644 server/app/static/zugang.html create mode 100644 server/app/turnstile.py create mode 100644 server/requirements.txt create mode 100644 server/tests/__init__.py create mode 100644 server/tests/test_admin.py create mode 100644 server/tests/test_gaeste.py create mode 100644 server/tests/test_player_api.py create mode 100644 server/tests/test_turnstile.py create mode 100644 server/verwaltung.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5242c43 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6960e8 --- /dev/null +++ b/README.md @@ -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 : + - 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:///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. diff --git a/install/install-player.sh b/install/install-player.sh new file mode 100755 index 0000000..56a25dd --- /dev/null +++ b/install/install-player.sh @@ -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 diff --git a/install/install-server.sh b/install/install-server.sh new file mode 100755 index 0000000..b68262e --- /dev/null +++ b/install/install-server.sh @@ -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 diff --git a/install/lib.sh b/install/lib.sh new file mode 100644 index 0000000..cb9b4ac --- /dev/null +++ b/install/lib.sh @@ -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 +} diff --git a/install/vorlagen/20auto-upgrades b/install/vorlagen/20auto-upgrades new file mode 100644 index 0000000..8d6d7c8 --- /dev/null +++ b/install/vorlagen/20auto-upgrades @@ -0,0 +1,2 @@ +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Unattended-Upgrade "1"; diff --git a/install/vorlagen/Caddyfile b/install/vorlagen/Caddyfile new file mode 100644 index 0000000..308bc96 --- /dev/null +++ b/install/vorlagen/Caddyfile @@ -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}} +} diff --git a/install/vorlagen/audiofy-controller.service b/install/vorlagen/audiofy-controller.service new file mode 100644 index 0000000..e6235e0 --- /dev/null +++ b/install/vorlagen/audiofy-controller.service @@ -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 diff --git a/install/vorlagen/audiofy-web.service b/install/vorlagen/audiofy-web.service new file mode 100644 index 0000000..6d82f4f --- /dev/null +++ b/install/vorlagen/audiofy-web.service @@ -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 diff --git a/install/vorlagen/fail2ban-audiofy.conf b/install/vorlagen/fail2ban-audiofy.conf new file mode 100644 index 0000000..a8a9f95 --- /dev/null +++ b/install/vorlagen/fail2ban-audiofy.conf @@ -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 diff --git a/install/vorlagen/journald-10-audiofy.conf b/install/vorlagen/journald-10-audiofy.conf new file mode 100644 index 0000000..076e765 --- /dev/null +++ b/install/vorlagen/journald-10-audiofy.conf @@ -0,0 +1,2 @@ +[Journal] +SystemMaxUse=200M diff --git a/install/vorlagen/raspotify-10-audiofy.conf b/install/vorlagen/raspotify-10-audiofy.conf new file mode 100644 index 0000000..c3ebbfb --- /dev/null +++ b/install/vorlagen/raspotify-10-audiofy.conf @@ -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 diff --git a/install/vorlagen/sshd-10-audiofy.conf b/install/vorlagen/sshd-10-audiofy.conf new file mode 100644 index 0000000..de3e196 --- /dev/null +++ b/install/vorlagen/sshd-10-audiofy.conf @@ -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}} diff --git a/player/auth.py b/player/auth.py new file mode 100755 index 0000000..6d68204 --- /dev/null +++ b/player/auth.py @@ -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() diff --git a/player/controller.py b/player/controller.py new file mode 100644 index 0000000..3b7381c --- /dev/null +++ b/player/controller.py @@ -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() diff --git a/player/lautsprecher.py b/player/lautsprecher.py new file mode 100644 index 0000000..2660d53 --- /dev/null +++ b/player/lautsprecher.py @@ -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?" diff --git a/player/requirements.txt b/player/requirements.txt new file mode 100644 index 0000000..cee3237 --- /dev/null +++ b/player/requirements.txt @@ -0,0 +1 @@ +httpx==0.28.1 diff --git a/player/server_client.py b/player/server_client.py new file mode 100644 index 0000000..b66f050 --- /dev/null +++ b/player/server_client.py @@ -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}) diff --git a/player/spotify_client.py b/player/spotify_client.py new file mode 100644 index 0000000..51fbb84 --- /dev/null +++ b/player/spotify_client.py @@ -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 diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..78c5ed9 --- /dev/null +++ b/server/app/__init__.py @@ -0,0 +1 @@ +"""Audiofy-Web – Website, Admin-Bereich und Player-API (läuft auf dem Server).""" diff --git a/server/app/admin.py b/server/app/admin.py new file mode 100644 index 0000000..5b98c54 --- /dev/null +++ b/server/app/admin.py @@ -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) diff --git a/server/app/config.py b/server/app/config.py new file mode 100644 index 0000000..1951b02 --- /dev/null +++ b/server/app/config.py @@ -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", ""), + ) diff --git a/server/app/db.py b/server/app/db.py new file mode 100644 index 0000000..1bce307 --- /dev/null +++ b/server/app/db.py @@ -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/ +} + +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(),)) diff --git a/server/app/gaeste.py b/server/app/gaeste.py new file mode 100644 index 0000000..954bed4 --- /dev/null +++ b/server/app/gaeste.py @@ -0,0 +1,335 @@ +"""Audiofy-Web – Gastseite: Suche, Wünschen, "Läuft gerade" und Warteschlange. + +Zugang nur mit Einladung: Der Link /z/ (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}, + } diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000..ae1868d --- /dev/null +++ b/server/app/main.py @@ -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} diff --git a/server/app/player_api.py b/server/app/player_api.py new file mode 100644 index 0000000..23eadab --- /dev/null +++ b/server/app/player_api.py @@ -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"]} diff --git a/server/app/spotify.py b/server/app/spotify.py new file mode 100644 index 0000000..0555eb6 --- /dev/null +++ b/server/app/spotify.py @@ -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()) diff --git a/server/app/static/admin.css b/server/app/static/admin.css new file mode 100644 index 0000000..b2bfd2c --- /dev/null +++ b/server/app/static/admin.css @@ -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; } diff --git a/server/app/static/admin.html b/server/app/static/admin.html new file mode 100644 index 0000000..dada080 --- /dev/null +++ b/server/app/static/admin.html @@ -0,0 +1,176 @@ + + + + + + + + Audiofy – Admin + + + + + +
+
+

Audiofy Admin

+ +
+
+ + + + + + +
+
+

Läuft gerade

+
+
+

+ +
+ + + +
+
+ + + – +
+
+ +
+

Als Nächstes spielen

+ +
    +
    + +
    +

    Not-Aus

    +

    Stoppt die Wiedergabe und hält den Controller an, bis du wieder freigibst.

    + +
    + +
    +

    Letzte Befehle

    +
      +
      +
      + + + + + + + + + + + + +
      + + + + diff --git a/server/app/static/admin.js b/server/app/static/admin.js new file mode 100644 index 0000000..4ebfc8f --- /dev/null +++ b/server/app/static/admin.js @@ -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(); diff --git a/server/app/static/app.css b/server/app/static/app.css new file mode 100644 index 0000000..00d23c2 --- /dev/null +++ b/server/app/static/app.css @@ -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; +} diff --git a/server/app/static/app.js b/server/app/static/app.js new file mode 100644 index 0000000..58fcccf --- /dev/null +++ b/server/app/static/app.js @@ -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(); diff --git a/server/app/static/index.html b/server/app/static/index.html new file mode 100644 index 0000000..e89f204 --- /dev/null +++ b/server/app/static/index.html @@ -0,0 +1,62 @@ + + + + + + + Audiofy – Musikwünsche + + + + + + +
      +
      +

      Audiofy

      +

      Musikwünsche

      +
      + +
      +

      Läuft gerade

      +
      + Gerade läuft nichts. +
      + +
      + + + + +
      +

      Song wünschen

      + + + +

      +
      +
        +
        + + + +
        +

        Als Nächstes gewünscht

        + +
          +

          Noch keine Wünsche – sei die oder der Erste!

          +
          + +
          Suche und Titelinfos: Spotify
          +
          + + + + diff --git a/server/app/static/login.html b/server/app/static/login.html new file mode 100644 index 0000000..832de76 --- /dev/null +++ b/server/app/static/login.html @@ -0,0 +1,33 @@ + + + + + + + + Audiofy – Admin + + + + + + + +
          +
          +

          Audiofy

          +

          Admin-Bereich

          +
          +
          +

          Anmelden

          + +
          + + +
          +
          + + diff --git a/server/app/static/login.js b/server/app/static/login.js new file mode 100644 index 0000000..c1c6d0c --- /dev/null +++ b/server/app/static/login.js @@ -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; +}); diff --git a/server/app/static/turnstile.js b/server/app/static/turnstile.js new file mode 100644 index 0000000..d6f6f67 --- /dev/null +++ b/server/app/static/turnstile.js @@ -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, 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 }; +})(); diff --git a/server/app/static/zugang.html b/server/app/static/zugang.html new file mode 100644 index 0000000..5dcfce3 --- /dev/null +++ b/server/app/static/zugang.html @@ -0,0 +1,25 @@ + + + + + + + + Audiofy – Musikwünsche + + + +
          +
          +

          Audiofy

          +

          Musikwünsche

          +
          +
          +

          Nur für Gäste

          +

          Musikwünsche sind nur mit Einladung möglich.

          +

          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.

          +
          +
          + + diff --git a/server/app/turnstile.py b/server/app/turnstile.py new file mode 100644 index 0000000..f72c9f8 --- /dev/null +++ b/server/app/turnstile.py @@ -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.") diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..65bd870 --- /dev/null +++ b/server/requirements.txt @@ -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 diff --git a/server/tests/__init__.py b/server/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/test_admin.py b/server/tests/test_admin.py new file mode 100644 index 0000000..e109b4e --- /dev/null +++ b/server/tests/test_admin.py @@ -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"= 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"] diff --git a/server/tests/test_player_api.py b/server/tests/test_player_api.py new file mode 100644 index 0000000..afc4969 --- /dev/null +++ b/server/tests/test_player_api.py @@ -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 diff --git a/server/tests/test_turnstile.py b/server/tests/test_turnstile.py new file mode 100644 index 0000000..c88638f --- /dev/null +++ b/server/tests/test_turnstile.py @@ -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 diff --git a/server/verwaltung.py b/server/verwaltung.py new file mode 100644 index 0000000..6d2d342 --- /dev/null +++ b/server/verwaltung.py @@ -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()