Files
99admandClaude Opus 5.5 fc15f2b933 Umbenennung: Audiofy heißt jetzt Playwish
Name, Seitentitel, Dienste (playwish-web, playwish-controller), Dienst-Benutzer
(playwish), Pfade (/opt/playwish, /usr/local/src/playwish), Vorlagen und
Repository-Adresse. „Audiofy“ war bereits von mehreren Produkten belegt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:27:59 +01:00

203 lines
7.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Playwish-Web – SQLite-Datenbank (playwish.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 (Wünsche)
"playlist_repeat_percent": 50, # Playlist-Titel erst wieder nach X % der Playlist
"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(),))