Files
radio-explorer/server/db/schema.sql
2026-05-27 12:54:56 +02:00

173 lines
7.5 KiB
SQL

CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
is_main INTEGER NOT NULL DEFAULT 0, -- exactly one user is the shared "main" identity
avatar_color TEXT, -- cosmetic, used by avatar picker
avatar_emoji TEXT, -- cosmetic, used by avatar picker
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Note: the partial unique index on users(is_main) is created by runMigrations()
-- after legacy DBs have the is_main column added via ALTER TABLE.
-- Trusted kiosk/browser devices. A device cookie (random token) is set after
-- an admin marks the device trusted. Users on the device's whitelist can
-- fast-switch without re-entering a password.
CREATE TABLE IF NOT EXISTS kiosk_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
label TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TEXT
);
CREATE TABLE IF NOT EXISTS kiosk_device_users (
device_id INTEGER NOT NULL REFERENCES kiosk_devices(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (device_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_kiosk_device_users_user ON kiosk_device_users(user_id);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS profiles (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
display_name TEXT,
theme TEXT DEFAULT 'dark',
default_volume REAL DEFAULT 0.7
);
CREATE TABLE IF NOT EXISTS stations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
homepage TEXT,
country TEXT,
genres TEXT, -- JSON array
description TEXT,
image_url TEXT,
image_path TEXT, -- relative path under data/images, e.g. "stations/12.jpg"
image_source TEXT, -- 'remote' | 'scraped' | 'upload'
source TEXT NOT NULL CHECK (source IN ('seed','radiobrowser','manual')),
source_ref TEXT,
category TEXT,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_stations_enabled ON stations(enabled);
CREATE INDEX IF NOT EXISTS idx_stations_source ON stations(source);
CREATE INDEX IF NOT EXISTS idx_stations_category ON stations(category);
CREATE UNIQUE INDEX IF NOT EXISTS idx_stations_uuid ON stations(uuid);
CREATE TABLE IF NOT EXISTS streams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE,
station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE,
url TEXT NOT NULL,
format TEXT NOT NULL CHECK (format IN ('mp3','aac','hls','m3u','pls','ogg','unknown')),
bitrate INTEGER,
label TEXT,
priority INTEGER NOT NULL DEFAULT 0,
last_checked_at TEXT,
last_status TEXT
);
CREATE INDEX IF NOT EXISTS idx_streams_station ON streams(station_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_uuid ON streams(uuid);
CREATE TABLE IF NOT EXISTS favorites (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, station_id)
);
CREATE TABLE IF NOT EXISTS play_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE,
stream_id INTEGER REFERENCES streams(id) ON DELETE SET NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_history_user ON play_history(user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_history_station ON play_history(station_id);
-- One vote per user per station. value is +1 (up) or -1 (down). Row is
-- deleted entirely when the user clears their vote, so the COUNT is exact.
CREATE TABLE IF NOT EXISTS station_votes (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE,
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, station_id)
);
CREATE INDEX IF NOT EXISTS idx_votes_station ON station_votes(station_id);
-- Aggregate play counter. Cheaper than COUNT(*) over play_history every render
-- and lets anonymous/public listing show play counts without exposing history.
-- `total_play_ms` and `sessions` accumulate from closed play_history rows so
-- the leaderboard can rank by actual listen time, not just play-button taps.
CREATE TABLE IF NOT EXISTS station_plays (
station_id INTEGER PRIMARY KEY REFERENCES stations(id) ON DELETE CASCADE,
plays INTEGER NOT NULL DEFAULT 0,
sessions INTEGER NOT NULL DEFAULT 0,
total_play_ms INTEGER NOT NULL DEFAULT 0,
last_played_at TEXT
);
-- Named listening rooms. One "display" client + many controller/panel clients
-- per room share state (now-playing, volume, votes). A personal room is
-- auto-provisioned per user so single-user kiosks Just Work.
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS room_members (
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner','member','guest')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (room_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id);
-- Last-known playback state per room. Persisted so clients reconnecting see
-- the same now-playing card immediately, even after a server restart.
CREATE TABLE IF NOT EXISTS room_state (
room_id INTEGER PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
station_id INTEGER REFERENCES stations(id) ON DELETE SET NULL,
playing INTEGER NOT NULL DEFAULT 0,
volume REAL NOT NULL DEFAULT 0.7,
started_at INTEGER, -- epoch ms when playback (or current station) began; anchors cross-client sync
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- API keys for programmatic / script / StreamDeck access.
-- The actual key is shown only once at creation; only a SHA-256 hash is stored.
-- Send via: X-Api-Key: <key> or Authorization: Bearer <key>
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key_hash TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT '',
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TEXT
);