Add player functionality with HLS support and API integration

- Implemented a new Player class in player.js to handle audio playback, including HLS support using hls.js.
- Created a shared API module in api.js for making HTTP requests with proper error handling.
- Added DOM utility functions in dom.js for creating and clearing elements.
- Introduced WebSocket connection handling in ws.js for real-time updates.
- Developed a comprehensive CSS stylesheet for styling the application, including a high-contrast theme.
This commit is contained in:
Marco Mooren
2026-05-10 14:43:00 +02:00
commit e0a60f7b64
51 changed files with 9022 additions and 0 deletions

59
server/db/index.js Normal file
View File

@@ -0,0 +1,59 @@
import Database from 'better-sqlite3';
import { readFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
let db;
export function initDb(dbPath) {
const abs = resolve(dbPath);
mkdirSync(dirname(abs), { recursive: true });
db = new Database(abs);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
const schema = readFileSync(resolve(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
runMigrations(db);
return db;
}
export function getDb() {
if (!db) throw new Error('DB not initialized');
return db;
}
// Idempotent migrations for upgrading older DBs that pre-date a column.
function runMigrations(db) {
const stationCols = new Set(db.prepare("PRAGMA table_info(stations)").all().map((c) => c.name));
if (!stationCols.has('uuid')) {
db.exec('ALTER TABLE stations ADD COLUMN uuid TEXT');
}
if (!stationCols.has('category')) {
db.exec('ALTER TABLE stations ADD COLUMN category TEXT');
}
const streamCols = new Set(db.prepare("PRAGMA table_info(streams)").all().map((c) => c.name));
if (!streamCols.has('uuid')) {
db.exec('ALTER TABLE streams ADD COLUMN uuid TEXT');
}
// Backfill UUIDs. For RB stations, prefer the existing source_ref so the
// public UUID matches the upstream Radio-Browser stationuuid.
const setStationUuid = db.prepare('UPDATE stations SET uuid = ? WHERE id = ?');
for (const row of db.prepare("SELECT id, source, source_ref FROM stations WHERE uuid IS NULL OR uuid = ''").all()) {
const u = (row.source === 'radiobrowser' && row.source_ref) ? row.source_ref : randomUUID();
setStationUuid.run(u, row.id);
}
const setStreamUuid = db.prepare('UPDATE streams SET uuid = ? WHERE id = ?');
for (const row of db.prepare("SELECT id FROM streams WHERE uuid IS NULL OR uuid = ''").all()) {
setStreamUuid.run(randomUUID(), row.id);
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_stations_uuid ON stations(uuid)');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_uuid ON streams(uuid)');
db.exec('CREATE INDEX IF NOT EXISTS idx_stations_category ON stations(category)');
}

80
server/db/schema.sql Normal file
View File

@@ -0,0 +1,80 @@
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')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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,
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);