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