Audiofy – Musikwünsche für Partys

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 19:16:39 +01:00
co-authored by Claude Opus 5.5
commit 3610de7bae
45 changed files with 5666 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
"""Audiofy-Web – SQLite-Datenbank (audiofy.db): Schema, Einstellungen, Hilfsfunktionen.
Zeitstempel sind UTC im ISO-Format (lexikografisch vergleichbar).
"""
import json
import secrets
import sqlite3
from collections.abc import Iterator
from datetime import datetime, timedelta, timezone
from pathlib import Path
from .config import konfig
SCHEMA = """
-- Einstellungen des Besitzers (Wert als JSON)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Wünsche der Gäste und "Als Nächstes" des Besitzers (priority = 1)
-- status: pending = zur Freigabe (Moderation), queued = Warteschlange,
-- played = gespielt, rejected = abgelehnt, deleted = vom Besitzer gelöscht
CREATE TABLE IF NOT EXISTS wishes (
id INTEGER PRIMARY KEY,
track_uri TEXT NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
cover_url TEXT,
duration_ms INTEGER,
explicit INTEGER NOT NULL DEFAULT 0,
guest_id TEXT, -- zufällige Cookie-ID des Geräts
guest_ip TEXT, -- gehashte IP (nie im Klartext)
guest_name TEXT, -- optionaler Spitzname
status TEXT NOT NULL CHECK (status IN ('pending', 'queued', 'played', 'rejected', 'deleted')),
priority INTEGER NOT NULL DEFAULT 0,
position REAL NOT NULL, -- Reihenfolge innerhalb der Warteschlange
created_at TEXT NOT NULL,
played_at TEXT
);
CREATE INDEX IF NOT EXISTS wishes_status ON wishes (status, priority DESC, position);
CREATE INDEX IF NOT EXISTS wishes_guest ON wishes (guest_id, created_at);
-- Verlauf: was wann lief (source: playlist, wish, owner, fremd)
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY,
track_uri TEXT NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
cover_url TEXT,
source TEXT,
wish_id INTEGER REFERENCES wishes (id),
started_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS history_started ON history (started_at);
CREATE INDEX IF NOT EXISTS history_track ON history (track_uri, started_at);
-- Befehle des Besitzers an den Player (play, pause, skip, volume, …)
CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
acked_at TEXT,
ok INTEGER,
message TEXT
);
-- Sperrliste (kind: track, artist, guest, ip)
CREATE TABLE IF NOT EXISTS blocklist (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('track', 'artist', 'guest', 'ip')),
value TEXT NOT NULL,
label TEXT,
created_at TEXT NOT NULL,
UNIQUE (kind, value)
);
-- Admin-Sitzungen (id = SHA-256 des Cookie-Werts, nie der Klartext)
CREATE TABLE IF NOT EXISTS admin_sessions (
id TEXT PRIMARY KEY,
csrf TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
-- Letzter gemeldeter Zustand des Players (genau eine Zeile)
CREATE TABLE IF NOT EXISTS pi_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
track_uri TEXT,
payload TEXT NOT NULL DEFAULT '{}',
updated_at TEXT, -- letzte "läuft gerade"-Meldung
last_seen TEXT -- letzter Kontakt überhaupt
);
"""
STANDARD_EINSTELLUNGEN = {
"wish_every_n": 3, # jeder N-te Titel ist ein Wunsch
"playlist_id": "", # Grund-Playlist
"reshuffle_token": 0, # Erhöhen = Playlist neu mischen
"emergency_stop": False, # Not-Aus
"moderation": False, # Wünsche erst nach Freigabe
"wishes_paused": False, # Wunschannahme pausiert
"wish_limit_count": 2, # max. Wünsche pro Gast …
"wish_limit_minutes": 30, # … pro Zeitraum
"repeat_block_minutes": 60, # Sperrzeit für Wiederholungen
"block_explicit": False, # explizite Titel nicht erlauben
"access_enabled": False, # Gastzugang per Einladungslink/QR-Code (anfangs aus)
"access_token": "", # geheimer Teil des Einladungslinks /z/<token>
}
BEFEHL_GUELTIG_S = 60 # nicht abgeholte Befehle verfallen danach (kein verspätetes Überspringen)
def jetzt(delta: timedelta = timedelta()) -> str:
return (datetime.now(timezone.utc) + delta).isoformat(timespec="seconds")
def verbinden(pfad: Path | None = None) -> sqlite3.Connection:
conn = sqlite3.connect(pfad or konfig().db_pfad, timeout=10, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def initialisieren(pfad: Path, playlist_id: str = "") -> None:
pfad.parent.mkdir(parents=True, exist_ok=True)
conn = verbinden(pfad)
try:
conn.execute("PRAGMA journal_mode = WAL")
with conn:
conn.executescript(SCHEMA)
standard = dict(STANDARD_EINSTELLUNGEN, playlist_id=playlist_id)
conn.executemany("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
[(k, json.dumps(v)) for k, v in standard.items()])
conn.execute("INSERT OR IGNORE INTO pi_state (id) VALUES (1)")
# Nachrüsten für bestehende Datenbanken: Bluetooth-Zustand des Players
spalten = {r[1] for r in conn.execute("PRAGMA table_info(pi_state)")}
if "bluetooth" not in spalten:
conn.execute("ALTER TABLE pi_state ADD COLUMN bluetooth TEXT NOT NULL DEFAULT '{}'")
if not json.loads(conn.execute("SELECT value FROM settings WHERE key = 'access_token'").fetchone()[0]):
conn.execute("UPDATE settings SET value = ? WHERE key = 'access_token'",
(json.dumps(neuer_zugangs_token()),))
finally:
conn.close()
def neuer_zugangs_token() -> str:
"""Kurz genug für einen gut scanbaren QR-Code, lang genug gegen Raten (72 Bit)."""
return secrets.token_urlsafe(9)
def get_db() -> Iterator[sqlite3.Connection]:
"""FastAPI-Dependency: eine Verbindung pro Anfrage."""
conn = verbinden()
try:
yield conn
finally:
conn.close()
# --- Einstellungen ---------------------------------------------------------
def einstellungen(conn: sqlite3.Connection) -> dict:
werte = dict(STANDARD_EINSTELLUNGEN)
werte.update({r["key"]: json.loads(r["value"]) for r in conn.execute("SELECT key, value FROM settings")})
return werte
def einstellung_setzen(conn: sqlite3.Connection, key: str, wert) -> None:
if key not in STANDARD_EINSTELLUNGEN:
raise KeyError(key)
with conn:
conn.execute("INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value", (key, json.dumps(wert)))
# --- Befehle an den Player -----------------------------------------------------
def befehl_anlegen(conn: sqlite3.Connection, typ: str, payload: dict | None = None) -> int:
with conn:
cur = conn.execute("INSERT INTO commands (type, payload, created_at) VALUES (?, ?, ?)",
(typ, json.dumps(payload or {}), jetzt()))
return cur.lastrowid
def offene_befehle(conn: sqlite3.Connection) -> list[dict]:
grenze = jetzt(-timedelta(seconds=BEFEHL_GUELTIG_S))
zeilen = conn.execute("SELECT id, type, payload, created_at FROM commands "
"WHERE acked_at IS NULL AND created_at >= ? ORDER BY id", (grenze,))
return [{"id": r["id"], "type": r["type"], "payload": json.loads(r["payload"]), "created_at": r["created_at"]}
for r in zeilen]
# --- Player-Zustand ------------------------------------------------------------
def lebenszeichen(conn: sqlite3.Connection) -> None:
with conn:
conn.execute("UPDATE pi_state SET last_seen = ? WHERE id = 1", (jetzt(),))