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>
202 lines
7.7 KiB
Python
202 lines
7.7 KiB
Python
"""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(),))
|