"""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(),))