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

88
server/auth.js Normal file
View File

@@ -0,0 +1,88 @@
import bcrypt from 'bcryptjs';
import { randomBytes } from 'node:crypto';
import { getDb } from './db/index.js';
const SESSION_DAYS = 30;
const COOKIE_NAME = 'oradio_sid';
export function hashPassword(plain) {
return bcrypt.hashSync(plain, 10);
}
export function verifyPassword(plain, hash) {
return bcrypt.compareSync(plain, hash);
}
export function createSession(userId) {
const token = randomBytes(32).toString('hex');
const expires = new Date(Date.now() + SESSION_DAYS * 86400e3).toISOString();
getDb().prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)')
.run(token, userId, expires);
return { token, expires };
}
export function destroySession(token) {
if (token) getDb().prepare('DELETE FROM sessions WHERE token = ?').run(token);
}
export function getUserBySession(token) {
if (!token) return null;
return getDb().prepare(`
SELECT u.id, u.username, u.role
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > datetime('now')
`).get(token);
}
export function readSessionToken(req) {
const raw = req.headers.cookie || '';
for (const part of raw.split(';')) {
const [k, v] = part.trim().split('=');
if (k === COOKIE_NAME) return decodeURIComponent(v || '');
}
return null;
}
export function setSessionCookie(res, token, expires) {
const attrs = [
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
`Expires=${new Date(expires).toUTCString()}`
];
res.setHeader('Set-Cookie', attrs.join('; '));
}
export function clearSessionCookie(res) {
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
}
export function authMiddleware(req, _res, next) {
const token = readSessionToken(req);
req.session = { token };
req.user = getUserBySession(token);
next();
}
export function requireUser(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'auth required' });
next();
}
export function requireAdmin(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'auth required' });
if (req.user.role !== 'admin') return res.status(403).json({ error: 'admin only' });
next();
}
export function ensureBootstrapAdmin({ username, password }) {
if (!username || !password) return;
const db = getDb();
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) return;
const info = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(username, hashPassword(password), 'admin');
db.prepare('INSERT INTO profiles (user_id, display_name) VALUES (?, ?)')
.run(info.lastInsertRowid, username);
console.log(`[auth] bootstrap admin '${username}' created`);
}

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);

60
server/index.js Normal file
View File

@@ -0,0 +1,60 @@
import 'dotenv/config';
import express from 'express';
import { createServer } from 'node:http';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';
import { initDb } from './db/index.js';
import { authMiddleware, ensureBootstrapAdmin } from './auth.js';
import { applySeedIfEmpty } from './sources/seed.js';
import { scheduleHealthCheck } from './streams/checker.js';
import { attachWs } from './ws.js';
import { router as authRoutes } from './routes/auth.js';
import { router as stationRoutes } from './routes/stations.js';
import { router as meRoutes } from './routes/me.js';
import { router as adminRoutes } from './routes/admin.js';
import { router as v1Routes } from './routes/v1.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT) || 4173;
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
ensureBootstrapAdmin({
username: process.env.ADMIN_BOOTSTRAP_USER,
password: process.env.ADMIN_BOOTSTRAP_PASSWORD
});
const seedResult = applySeedIfEmpty();
console.log('[seed]', seedResult);
const app = express();
app.use(express.json({ limit: '512kb' }));
app.use(authMiddleware);
app.use('/api/auth', authRoutes);
app.use('/api/stations', stationRoutes);
app.use('/api/me', meRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/v1', v1Routes);
// Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173.
const publicDir = resolve(__dirname, 'public');
if (existsSync(publicDir)) {
app.use(express.static(publicDir));
app.get('/admin', (_req, res) => res.sendFile(resolve(publicDir, 'admin/index.html')));
app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html')));
}
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).json({ error: String(err.message || err) });
});
const server = createServer(app);
attachWs(server);
scheduleHealthCheck(process.env.STREAM_CHECK_CRON);
server.listen(PORT, () => {
console.log(`[oradio] api+ws on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Admin</title>
<script type="module" crossorigin src="/assets/admin-CVu6KAFb.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dom-BZgKDOeX.js">
<link rel="stylesheet" crossorigin href="/assets/admin-CJZ4D7u-.css">
</head>
<body>
<div id="app"></div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))s(e);new MutationObserver(e=>{for(const t of e)if(t.type==="childList")for(const c of t.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&s(c)}).observe(document,{childList:!0,subtree:!0});function o(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?t.credentials="include":e.crossOrigin==="anonymous"?t.credentials="omit":t.credentials="same-origin",t}function s(e){if(e.ep)return;e.ep=!0;const t=o(e);fetch(e.href,t)}})();async function i(r,n,o){const s=await fetch(n,{method:r,credentials:"same-origin",headers:o?{"Content-Type":"application/json"}:{},body:o?JSON.stringify(o):void 0});if(s.status===204)return null;const t=(s.headers.get("content-type")||"").includes("json")?await s.json():await s.text();if(!s.ok)throw Object.assign(new Error((t==null?void 0:t.error)||s.statusText),{status:s.status,data:t});return t}const l={get:r=>i("GET",r),post:(r,n)=>i("POST",r,n),put:(r,n)=>i("PUT",r,n),patch:(r,n)=>i("PATCH",r,n),del:r=>i("DELETE",r)};function a(r,n={},...o){const s=document.createElement(r);for(const[e,t]of Object.entries(n||{}))e==="class"?s.className=t:e==="style"&&typeof t=="object"?Object.assign(s.style,t):e.startsWith("on")&&typeof t=="function"?s.addEventListener(e.slice(2).toLowerCase(),t):e==="html"?s.innerHTML=t:t!==!1&&t!=null&&s.setAttribute(e,t===!0?"":t);for(const e of o.flat())e==null||e===!1||s.appendChild(e instanceof Node?e:document.createTextNode(String(e)));return s}function f(r){for(;r.firstChild;)r.removeChild(r.firstChild)}export{l as a,f as c,a as e};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
server/public/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Radio Kiosk</title>
<script type="module" crossorigin src="/assets/kiosk-DBnbAN5w.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dom-BZgKDOeX.js">
<link rel="stylesheet" crossorigin href="/assets/kiosk-CL6_kPws.css">
</head>
<body class="kiosk">
<div id="app"></div>

73
server/routes/admin.js Normal file
View File

@@ -0,0 +1,73 @@
import { Router } from 'express';
import { requireAdmin } from '../auth.js';
import { runHealthCheck } from '../streams/checker.js';
import { applySeedIfEmpty } from '../sources/seed.js';
import { getDb } from '../db/index.js';
import { scrapeIcon } from '../sources/iconScraper.js';
import { listStations, getStation, updateStation } from '../stations.js';
export const router = Router();
router.use(requireAdmin);
router.post('/health-check', async (_req, res) => {
const n = await runHealthCheck();
res.json({ checked: n });
});
router.post('/reseed', (_req, res) => {
res.json(applySeedIfEmpty());
});
router.get('/system', (_req, res) => {
const db = getDb();
res.json({
stations: db.prepare('SELECT COUNT(*) AS n FROM stations').get().n,
streams: db.prepare('SELECT COUNT(*) AS n FROM streams').get().n,
users: db.prepare('SELECT COUNT(*) AS n FROM users').get().n,
favorites: db.prepare('SELECT COUNT(*) AS n FROM favorites').get().n,
node: process.version,
uptime_s: Math.round(process.uptime())
});
});
// Scrape an icon for a single station.
router.post('/stations/:id/scrape-icon', async (req, res) => {
const id = Number(req.params.id);
const st = getStation(id);
if (!st) return res.status(404).json({ error: 'not found' });
const url = await scrapeIcon(st);
if (!url) return res.status(404).json({ error: 'no icon found' });
const updated = updateStation(id, { image_url: url });
res.json({ id, image_url: url, station: updated });
});
// Bulk: scrape icons for every station (optionally only those missing one).
router.post('/scrape-icons', async (req, res) => {
const onlyMissing = req.query.all !== '1';
const stations = listStations({ enabled: null }).filter((s) => !onlyMissing || !s.image_url);
const results = { total: stations.length, updated: 0, skipped: 0, failed: 0, items: [] };
// Limit concurrency to avoid hammering hosts.
const concurrency = 4;
let i = 0;
async function worker() {
while (i < stations.length) {
const s = stations[i++];
try {
const url = await scrapeIcon(s);
if (url) {
updateStation(s.id, { image_url: url });
results.updated++;
results.items.push({ id: s.id, name: s.name, image_url: url });
} else {
results.failed++;
results.items.push({ id: s.id, name: s.name, image_url: null });
}
} catch (err) {
results.failed++;
results.items.push({ id: s.id, name: s.name, error: String(err?.message || err) });
}
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
res.json(results);
});

71
server/routes/auth.js Normal file
View File

@@ -0,0 +1,71 @@
import { Router } from 'express';
import {
verifyPassword, createSession, destroySession, setSessionCookie, clearSessionCookie,
hashPassword, requireAdmin
} from '../auth.js';
import { getDb } from '../db/index.js';
export const router = Router();
router.post('/login', (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username + password required' });
const user = getDb().prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !verifyPassword(password, user.password_hash)) {
return res.status(401).json({ error: 'invalid credentials' });
}
const { token, expires } = createSession(user.id);
setSessionCookie(res, token, expires);
res.json({ id: user.id, username: user.username, role: user.role });
});
router.post('/logout', (req, res) => {
destroySession(req.session?.token);
clearSessionCookie(res);
res.json({ ok: true });
});
router.get('/me', (req, res) => {
if (!req.user) return res.status(401).json({ error: 'not signed in' });
res.json(req.user);
});
// Admin-only user management
router.get('/users', requireAdmin, (_req, res) => {
const users = getDb().prepare('SELECT id, username, role, created_at FROM users ORDER BY username').all();
res.json(users);
});
router.post('/users', requireAdmin, (req, res) => {
const { username, password, role = 'user' } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username + password required' });
if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'bad role' });
try {
const info = getDb().prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(username, hashPassword(password), role);
getDb().prepare('INSERT INTO profiles (user_id, display_name) VALUES (?, ?)')
.run(info.lastInsertRowid, username);
res.status(201).json({ id: info.lastInsertRowid, username, role });
} catch (err) {
if (String(err).includes('UNIQUE')) return res.status(409).json({ error: 'username taken' });
throw err;
}
});
router.patch('/users/:id', requireAdmin, (req, res) => {
const id = Number(req.params.id);
const { password, role } = req.body || {};
const db = getDb();
if (password) db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id);
if (role && ['admin', 'user'].includes(role)) {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
}
res.json({ ok: true });
});
router.delete('/users/:id', requireAdmin, (req, res) => {
const id = Number(req.params.id);
if (id === req.user.id) return res.status(400).json({ error: 'cannot delete self' });
getDb().prepare('DELETE FROM users WHERE id = ?').run(id);
res.json({ ok: true });
});

63
server/routes/me.js Normal file
View File

@@ -0,0 +1,63 @@
import { Router } from 'express';
import { requireUser } from '../auth.js';
import { getDb } from '../db/index.js';
export const router = Router();
router.use(requireUser);
router.get('/favorites', (req, res) => {
const rows = getDb().prepare(`
SELECT s.*, f.position
FROM favorites f JOIN stations s ON s.id = f.station_id
WHERE f.user_id = ? AND s.enabled = 1
ORDER BY f.position ASC, f.created_at ASC
`).all(req.user.id);
res.json(rows.map((r) => ({
id: r.id, uuid: r.uuid, name: r.name, slug: r.slug, homepage: r.homepage, country: r.country,
genres: r.genres ? JSON.parse(r.genres) : [], image_url: r.image_url, category: r.category, position: r.position
})));
});
router.put('/favorites/:stationId', (req, res) => {
const stationId = Number(req.params.stationId);
const position = Number(req.body?.position ?? 0);
getDb().prepare(`
INSERT INTO favorites (user_id, station_id, position) VALUES (?, ?, ?)
ON CONFLICT(user_id, station_id) DO UPDATE SET position = excluded.position
`).run(req.user.id, stationId, position);
res.json({ ok: true });
});
router.delete('/favorites/:stationId', (req, res) => {
getDb().prepare('DELETE FROM favorites WHERE user_id = ? AND station_id = ?')
.run(req.user.id, Number(req.params.stationId));
res.json({ ok: true });
});
router.get('/profile', (req, res) => {
const row = getDb().prepare('SELECT * FROM profiles WHERE user_id = ?').get(req.user.id);
res.json(row || { user_id: req.user.id, display_name: req.user.username, theme: 'dark', default_volume: 0.7 });
});
router.patch('/profile', (req, res) => {
const { display_name, theme, default_volume } = req.body || {};
getDb().prepare(`
INSERT INTO profiles (user_id, display_name, theme, default_volume) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
display_name = COALESCE(excluded.display_name, profiles.display_name),
theme = COALESCE(excluded.theme, profiles.theme),
default_volume = COALESCE(excluded.default_volume, profiles.default_volume)
`).run(req.user.id, display_name ?? null, theme ?? null, default_volume ?? null);
res.json({ ok: true });
});
router.get('/history', (req, res) => {
const rows = getDb().prepare(`
SELECT h.*, s.name AS station_name, s.slug AS station_slug
FROM play_history h JOIN stations s ON s.id = h.station_id
WHERE h.user_id = ?
ORDER BY h.started_at DESC LIMIT 50
`).all(req.user.id);
res.json(rows);
});

150
server/routes/stations.js Normal file
View File

@@ -0,0 +1,150 @@
import { Router } from 'express';
import {
listStations, getStation, getStreamsForStation,
createStation, updateStation, deleteStation, addStream, deleteStream
} from '../stations.js';
import { resolveStream } from '../streams/resolver.js';
import { requireAdmin, requireUser } from '../auth.js';
import * as radiobrowser from '../sources/radiobrowser.js';
export const router = Router();
router.get('/', (req, res) => {
const stations = listStations({
q: req.query.q || undefined,
source: req.query.source || undefined,
enabled: req.query.all ? null : true
});
res.json(stations);
});
router.get('/:id', (req, res) => {
const id = Number(req.params.id);
const station = getStation(id);
if (!station) return res.status(404).json({ error: 'not found' });
station.streams = getStreamsForStation(id);
res.json(station);
});
router.post('/:id/resolve', requireUser, async (req, res) => {
const id = Number(req.params.id);
const streams = getStreamsForStation(id);
if (!streams.length) return res.status(404).json({ error: 'no streams' });
const preferred = req.body?.streamId
? streams.find((s) => s.id === Number(req.body.streamId))
: streams[0];
if (!preferred) return res.status(404).json({ error: 'stream not found' });
const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
res.json({ stream: preferred, resolved });
});
// Same-origin streaming proxy. Adds the CORS headers Icecast/SHOUTcast servers
// almost never send, which lets the kiosk wire a Web-Audio AnalyserNode for a
// real spectrum. HLS is excluded — the manifest plus every segment would need
// rewriting; clients fall back to the direct URL with no analyser there.
router.get('/:id/proxy', requireUser, async (req, res) => {
const id = Number(req.params.id);
const streams = getStreamsForStation(id);
if (!streams.length) return res.status(404).json({ error: 'no streams' });
const preferred = req.query.streamId
? streams.find((s) => s.id === Number(req.query.streamId))
: streams[0];
if (!preferred) return res.status(404).json({ error: 'stream not found' });
const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
if (resolved.format === 'hls') return res.status(415).json({ error: 'hls not proxied' });
const controller = new AbortController();
req.on('close', () => controller.abort());
let upstream;
try {
upstream = await fetch(resolved.url, {
redirect: 'follow',
signal: controller.signal,
headers: { 'User-Agent': 'oradio-kiosk/1.0', 'Icy-MetaData': '0' }
});
} catch (err) {
return res.status(502).json({ error: `upstream: ${err.message || err}` });
}
if (!upstream.ok || !upstream.body) {
return res.status(502).json({ error: `upstream HTTP ${upstream.status}` });
}
const ct = upstream.headers.get('content-type') || guessContentType(resolved.format);
res.status(200);
res.set('Content-Type', ct);
res.set('Cache-Control', 'no-store');
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Expose-Headers', 'Content-Type');
// Pipe the WHATWG ReadableStream into the Express response.
const reader = upstream.body.getReader();
const pump = async () => {
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!res.write(Buffer.from(value))) {
await new Promise((r) => res.once('drain', r));
}
}
} catch { /* client disconnect or upstream abort */ }
finally {
try { reader.cancel(); } catch {}
res.end();
}
};
pump();
});
function guessContentType(format) {
switch (format) {
case 'mp3': return 'audio/mpeg';
case 'aac': return 'audio/aac';
case 'ogg': return 'audio/ogg';
default: return 'application/octet-stream';
}
}
// --- admin mutations ---
router.post('/', requireAdmin, (req, res) => {
const station = createStation({ ...req.body, source: req.body.source || 'manual' }, req.user.id);
res.status(201).json(station);
});
router.patch('/:id', requireAdmin, (req, res) => {
const station = updateStation(Number(req.params.id), req.body || {});
if (!station) return res.status(404).json({ error: 'not found' });
res.json(station);
});
router.delete('/:id', requireAdmin, (req, res) => {
if (!deleteStation(Number(req.params.id))) return res.status(404).json({ error: 'not found' });
res.json({ ok: true });
});
router.post('/:id/streams', requireAdmin, (req, res) => {
const stream = addStream(Number(req.params.id), req.body || {});
res.status(201).json(stream);
});
router.delete('/:id/streams/:streamId', requireAdmin, (req, res) => {
if (!deleteStream(Number(req.params.streamId))) return res.status(404).json({ error: 'not found' });
res.json({ ok: true });
});
// --- Radio-Browser passthrough for the admin importer ---
router.get('/sources/radiobrowser/search', requireAdmin, async (req, res) => {
const results = await radiobrowser.search({
name: req.query.q,
country: req.query.country,
tag: req.query.tag,
limit: Number(req.query.limit) || 30
});
res.json(results);
});
router.post('/sources/radiobrowser/import', requireAdmin, (req, res) => {
const station = createStation({ ...req.body, source: 'radiobrowser' }, req.user.id);
res.status(201).json(station);
});

169
server/routes/v1.js Normal file
View File

@@ -0,0 +1,169 @@
// Public read-only API mounted at /api/v1.
// Stable per-station UUIDs let third-party tools (mpv, smart-home, scripts)
// reference stations independently of internal numeric IDs.
import { Router } from 'express';
import {
listStations, getStationByUuid, getStreamsForStation, getStreamByUuid
} from '../stations.js';
import { resolveStream } from '../streams/resolver.js';
import { getDb } from '../db/index.js';
import { loadCategoriesFile } from '../sources/seed.js';
export const router = Router();
// CORS for public endpoints. Browser-side integrations can hit the API
// from any origin; we don't expose any user data here.
router.use((_req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
next();
});
// Tiny in-memory token bucket per IP. 120 req/min is plenty for human use
// and clearly throttles a runaway script. Resets on process restart.
const buckets = new Map();
const RATE = 120;
const WINDOW_MS = 60_000;
router.use((req, res, next) => {
const key = req.ip || 'unknown';
const now = Date.now();
const b = buckets.get(key) || { count: 0, reset: now + WINDOW_MS };
if (now > b.reset) { b.count = 0; b.reset = now + WINDOW_MS; }
b.count += 1;
buckets.set(key, b);
res.set('X-RateLimit-Limit', String(RATE));
res.set('X-RateLimit-Remaining', String(Math.max(0, RATE - b.count)));
if (b.count > RATE) return res.status(429).json({ error: 'rate limited' });
next();
});
function publicStation(s) {
if (!s) return null;
return {
uuid: s.uuid,
name: s.name,
slug: s.slug,
homepage: s.homepage,
country: s.country,
genres: s.genres,
description: s.description,
image_url: s.image_url,
category: s.category,
enabled: s.enabled
};
}
function publicStream(s) {
if (!s) return null;
return {
uuid: s.uuid,
url: s.url,
format: s.format,
bitrate: s.bitrate,
label: s.label,
priority: s.priority,
last_status: s.last_status,
last_checked_at: s.last_checked_at
};
}
router.get('/health', (_req, res) => {
const stations = getDb().prepare('SELECT COUNT(*) AS n FROM stations WHERE enabled = 1').get().n;
res.json({ ok: true, stations });
});
router.get('/categories', (_req, res) => {
const rows = getDb().prepare(`
SELECT category AS id, COUNT(*) AS count
FROM stations
WHERE enabled = 1 AND category IS NOT NULL AND category <> ''
GROUP BY category
`).all();
const counts = new Map(rows.map((r) => [r.id, r.count]));
const meta = loadCategoriesFile();
const seen = new Set();
const out = [];
for (const m of meta) {
seen.add(m.id);
out.push({ ...m, count: counts.get(m.id) || 0 });
}
for (const [id, count] of counts) {
if (seen.has(id)) continue;
out.push({ id, label: id, icon: '', order: 999, count });
}
out.sort((a, b) => (a.order ?? 999) - (b.order ?? 999) || String(a.id).localeCompare(String(b.id)));
res.json(out);
});
router.get('/stations', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 200, 1000);
let items = listStations({
q: req.query.q || undefined,
category: req.query.category || undefined,
enabled: req.query.all ? null : true
});
if (req.query.country) {
const c = String(req.query.country).toUpperCase();
items = items.filter((s) => (s.country || '').toUpperCase() === c);
}
if (req.query.genre) {
const g = String(req.query.genre).toLowerCase();
items = items.filter((s) => (s.genres || []).some((x) => x.toLowerCase().includes(g)));
}
res.json({
total: items.length,
items: items.slice(0, limit).map(publicStation)
});
});
router.get('/stations/:uuid', (req, res) => {
const s = getStationByUuid(req.params.uuid);
if (!s) return res.status(404).json({ error: 'not found' });
const out = publicStation(s);
out.streams = getStreamsForStation(s.id).map(publicStream);
res.json(out);
});
// 302 redirect to the resolved stream URL. Pure convenience for CLI players
// (`mpv http://host/api/v1/stations/<uuid>/stream`) and smart-home scripts.
router.get('/stations/:uuid/stream', async (req, res) => {
const s = getStationByUuid(req.params.uuid);
if (!s) return res.status(404).json({ error: 'station not found' });
let streams = getStreamsForStation(s.id);
if (!streams.length) return res.status(404).json({ error: 'no streams' });
if (req.query.format) {
const fmt = String(req.query.format).toLowerCase();
const filtered = streams.filter((x) => x.format === fmt);
if (filtered.length) streams = filtered;
}
// Prefer streams known to be up; fall back to priority order otherwise.
const ordered = [...streams].sort((a, b) => {
const au = a.last_status === 'up' ? 0 : 1;
const bu = b.last_status === 'up' ? 0 : 1;
return au - bu || a.priority - b.priority;
});
const pick = ordered[0];
const resolved = await resolveStream({ url: pick.url, format: pick.format });
res.set('Cache-Control', 'no-store');
res.redirect(302, resolved.url);
});
router.get('/stations/:uuid/streams/:streamUuid', async (req, res) => {
const station = getStationByUuid(req.params.uuid);
if (!station) return res.status(404).json({ error: 'station not found' });
const stream = getStreamByUuid(req.params.streamUuid);
if (!stream || stream.station_id !== station.id) return res.status(404).json({ error: 'stream not found' });
if (req.query.redirect === '0') {
return res.json(publicStream(stream));
}
const resolved = await resolveStream({ url: stream.url, format: stream.format });
res.set('Cache-Control', 'no-store');
res.redirect(302, resolved.url);
});
// Reject any non-GET method explicitly so the public surface can never be
// abused for mutations even if a bug ever wires one in.
router.all('*', (_req, res) => res.status(405).json({ error: 'method not allowed' }));

View File

@@ -0,0 +1,73 @@
import 'dotenv/config';
import Database from 'better-sqlite3';
const db = new Database(process.env.DB_PATH || './data/db/oradio.sqlite');
const rows = db.prepare(`SELECT id, name, image_url FROM stations WHERE image_url IS NOT NULL AND image_url != ''`).all();
const TIMEOUT_MS = 8000;
const CONCURRENCY = 12;
const apply = process.argv.includes('--apply');
async function check(url) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
const headers = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9'
};
try {
// Many CDNs don't support HEAD or return wrong content-type for HEAD; use a ranged GET.
const res = await fetch(url, {
method: 'GET',
redirect: 'follow',
signal: ctrl.signal,
headers: { ...headers, Range: 'bytes=0-1023' }
});
if (!res.ok && res.status !== 206) {
// Treat 4xx (except 429 rate-limit) as broken; 5xx as transient → keep.
if (res.status === 429) return { ok: true, transient: true };
if (res.status >= 500) return { ok: true, transient: true };
return { ok: false, reason: `HTTP ${res.status}` };
}
const ct = (res.headers.get('content-type') || '').toLowerCase();
if (ct && !ct.startsWith('image/') && !ct.includes('octet-stream')) {
return { ok: false, reason: `bad content-type ${ct}` };
}
// Drain a small amount so the body is closed cleanly.
try { await res.arrayBuffer(); } catch { }
return { ok: true };
} catch (err) {
const code = err.cause?.code || err.code || err.name;
// DNS / connection failures are definitive.
if (['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ETIMEDOUT'].includes(code)) {
return { ok: false, reason: code };
}
if (err.name === 'AbortError') return { ok: true, transient: true }; // timeout, keep
return { ok: false, reason: code || err.message };
} finally {
clearTimeout(t);
}
}
const upd = db.prepare('UPDATE stations SET image_url = NULL WHERE id = ?');
let bad = 0, ok = 0;
async function worker(queue) {
while (queue.length) {
const r = queue.shift();
const res = await check(r.image_url);
if (res.ok) {
ok++;
} else {
bad++;
console.log(`BAD ${r.name} :: ${res.reason} :: ${r.image_url}`);
if (apply) upd.run(r.id);
}
}
}
const queue = rows.slice();
console.log(`Checking ${queue.length} image_url values (apply=${apply})...`);
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker(queue)));
console.log(`Done. ok=${ok} bad=${bad}${apply ? ' (cleared)' : ' (dry run; pass --apply to clear)'}`);

View File

@@ -0,0 +1,203 @@
// One-shot importer: resolves a list of Dutch station names (from allradio.net)
// against Radio-Browser, plus adds Vintage Obscura by direct URL,
// then writes data/seed/stations-allradio-nl.json.
//
// Usage: node server/scripts/import-allradio-nl.js
//
// Re-running is safe: existing entries are matched by RB UUID via the
// merge-by-UUID seeder. This script does NOT touch the database directly.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', '..');
const OUT = path.join(ROOT, 'data', 'seed', 'stations-allradio-nl.json');
const RB = 'https://de1.api.radio-browser.info';
const UA = 'OnlineRadioExplorer/0.1 (+import-allradio-nl)';
// Station names taken from https://www.allradio.net/country/3 (pages 1+2),
// minus duplicates and minus ones already seeded under stations-extended.json.
const NAMES = [
// public broadcasters not yet seeded
['NPO 3FM Alternative', 'dutch-public'],
['NPO 3FM KX', 'dutch-public'],
['NPO FunX NL', 'dutch-public'],
['NPO FunX Reggae', 'dutch-public'],
['NPO 2', 'dutch-public'],
['Radio Rijnmond', 'dutch-public'],
['Omroep Gelderland', 'dutch-public'],
['Omroep West', 'dutch-public'],
// commercials
['Radio 10', 'dutch-commercial'],
['Radio 10 80\'s Hits', 'dutch-commercial'],
['Radio 10 60\'s & 70\'s Hits', 'dutch-commercial'],
['Radio 538 Nonstop', 'dutch-commercial'],
['538 Dance Department', 'dutch-commercial'],
['538 TOP 50', 'dutch-commercial'],
['Sky Radio Hits', 'dutch-commercial'],
['Sky Radio 90\'s Hits', 'dutch-commercial'],
['Sky Radio 101 FM', 'dutch-commercial'],
['SLAM FM', 'dutch-commercial'],
['Veronica Rockradio', 'dutch-commercial'],
['Veronica TOP1000 AllerTijden', 'dutch-commercial'],
['JAMM FM', 'dutch-commercial'],
['RADIONL', 'dutch-commercial'],
['Grand Prix Radio', 'dutch-commercial'],
['XXL Stenders', 'dutch-commercial'],
['Sublime - Live', 'dutch-commercial'],
['Sublime - Soul', 'dutch-commercial'],
// rock & alt
['KINK', 'rock'],
['KINK CLASSICS', 'rock'],
['Baars classic Rock', 'rock'],
['ISKC Rock Radio', 'rock'],
['ICE RADIO', 'rock'],
// electronic / dance / hard
['Jungletrain.net', 'electronic'],
['Real Hardstyle Radio', 'electronic'],
['Hardstyle Radio NL', 'electronic'],
['Hardcore Power', 'electronic'],
['Freak31', 'electronic'],
['Decibel', 'electronic'],
['Decibel EURODANCE', 'electronic'],
['Intense Radio', 'electronic'],
['Deep Radio', 'electronic'],
['Fantasy Radio - Italo Disco Euro Dance HiNRG', 'electronic'],
['MixPerfect Radio', 'electronic'],
['Dancegroove Radio', 'electronic'],
['DANCEableRADIO', 'electronic'],
// jazz / lounge / classical
['Jazz de Ville - Jazz', 'jazz'],
['Jazz de Ville - Chill', 'jazz'],
['Hi On Line Jazz Radio', 'jazz'],
['Hi On Line Classical Radio', 'classical'],
['Hi On Line Lounge Radio', 'ambient'],
['Hi On Line World Radio', 'world'],
['Hi On Line Latin Radio', 'world'],
['Hi On Line Radio - Pop', 'dutch-commercial'],
['ClassicFM - Chillout', 'classical'],
['Classic NL', 'classical'],
// niche / community / piraten
['Pinguin Blues', 'jazz'],
['Pinguin Ska World', 'reggae'],
['Lachende Piraat', 'world'],
['Oude Piraten Hits', 'world'],
['Radio Caroline 319 Gold', 'world'],
['Radio Nostalgia', 'world'],
['Slow Radio Gold', 'world'],
['Olympia Classics', 'world'],
['Peaceful Radio', 'ambient'],
['Amsterdam Funk Channel', 'electronic'],
['247Spice', 'world'],
['SH Radio', 'world'],
['Rivierenland Radio', 'world'],
['Grolloo Radio', 'rock'],
['All Oldies Channel', 'world'],
['i-turn radio', 'world'],
['NPO 3FM Serious Radio', 'dutch-public']
];
function detectFormat(codec, url) {
const c = (codec || '').toLowerCase();
if (c.includes('mp3')) return 'mp3';
if (c.includes('aac')) return 'aac';
if (c.includes('ogg') || c.includes('vorbis') || c.includes('opus')) return 'ogg';
if (url?.endsWith('.m3u8')) return 'hls';
if (url?.endsWith('.m3u')) return 'm3u';
if (url?.endsWith('.pls')) return 'pls';
return 'unknown';
}
function slugify(name) {
return name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
async function rbSearch(name) {
const url = `${RB}/json/stations/search?name=${encodeURIComponent(name)}&countrycode=NL&limit=5&hidebroken=true&order=clickcount&reverse=true`;
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error(`RB ${res.status}`);
const list = await res.json();
// also try without country filter as fallback (some entries have wrong country)
if (!list.length) {
const r2 = await fetch(`${RB}/json/stations/search?name=${encodeURIComponent(name)}&limit=5&hidebroken=true&order=clickcount&reverse=true`, { headers: { 'User-Agent': UA } });
if (r2.ok) return r2.json();
}
return list;
}
function pickBest(list, target) {
if (!list.length) return null;
const t = target.toLowerCase().trim();
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === t);
return exact || list[0];
}
function toEntry(s, category) {
const stream = {
url: s.url_resolved || s.url,
format: detectFormat(s.codec, s.url_resolved || s.url),
bitrate: s.bitrate || null,
label: s.codec ? `${s.codec} ${s.bitrate || ''}`.trim() : null,
priority: 0
};
return {
uuid: s.stationuuid,
slug: `rb-${s.stationuuid.slice(0, 8)}-${slugify(s.name).slice(0, 40)}`,
name: s.name,
category,
country: s.countrycode || 'NL',
homepage: s.homepage || null,
genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean).slice(0, 5),
description: null,
image_url: s.favicon || null,
source: 'radiobrowser',
source_ref: s.stationuuid,
streams: [stream]
};
}
async function main() {
const out = [];
const seenUuids = new Set();
// Vintage Obscura — direct, no RB.
out.push({
slug: 'vintage-obscura',
name: 'Vintage Obscura Radio',
category: 'underground',
country: 'US',
homepage: 'https://vintageobscura.net/',
genres: ['vintage', 'obscure', 'curated', 'reddit'],
description: 'Curated rare music discovered daily by /r/vintageobscura. All tracks <30k YouTube views, pre-2000.',
image_url: 'https://vintageobscura.net/img/vintage-obscura-logo.png',
streams: [
{ url: 'https://radio.vintageobscura.net/stream', format: 'mp3', bitrate: 128, label: 'MP3 128', priority: 0 }
]
});
for (const [name, category] of NAMES) {
try {
const hits = await rbSearch(name);
const pick = pickBest(hits, name);
if (!pick) { console.warn(' miss:', name); continue; }
if (seenUuids.has(pick.stationuuid)) { console.warn(' dup:', name, '->', pick.name); continue; }
seenUuids.add(pick.stationuuid);
out.push(toEntry(pick, category));
console.log(' ok :', name, '->', pick.name, `(${pick.codec || '?'} ${pick.bitrate || ''})`);
} catch (err) {
console.warn(' err:', name, err.message);
}
// gentle pacing
await new Promise((r) => setTimeout(r, 80));
}
fs.writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
console.log(`\nwrote ${out.length} entries to ${path.relative(ROOT, OUT)}`);
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,12 @@
import 'dotenv/config';
import Database from 'better-sqlite3';
const db = new Database(process.env.DB_PATH || './data/db/oradio.sqlite');
const rows = db.prepare(`
SELECT s.name, st.format, st.url, st.last_status
FROM streams st JOIN stations s ON s.id = st.station_id
ORDER BY (st.last_status = 'up'), s.name
`).all();
for (const r of rows) {
const tag = r.last_status === 'up' ? 'OK ' : 'BAD';
console.log(tag, (r.last_status || '').padEnd(14), r.format.padEnd(5), r.name, '->', r.url);
}

View File

@@ -0,0 +1,48 @@
// Restore image_url from seed JSON files for any station where it is currently NULL.
// Match priority: explicit uuid → uuidFromSlug(slug) → exact name.
import 'dotenv/config';
import Database from 'better-sqlite3';
import { readFileSync, readdirSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SEED_DIR = resolve(__dirname, '../../data/seed');
function uuidFromSlug(slug) {
const h = createHash('sha1').update('oradio:' + slug).digest('hex');
return [h.slice(0, 8), h.slice(8, 12), '5' + h.slice(13, 16), '8' + h.slice(17, 20), h.slice(20, 32)].join('-');
}
const db = new Database(process.env.DB_PATH || './data/db/oradio.sqlite');
const apply = process.argv.includes('--apply');
const entries = [];
for (const f of readdirSync(SEED_DIR).filter((x) => x.startsWith('stations') && x.endsWith('.json'))) {
try {
const data = JSON.parse(readFileSync(join(SEED_DIR, f), 'utf8'));
if (Array.isArray(data)) entries.push(...data);
} catch { }
}
const byUuid = new Map();
const byName = new Map();
for (const e of entries) {
if (!e.image_url) continue;
const u = e.uuid || (e.slug ? uuidFromSlug(e.slug) : null);
if (u) byUuid.set(u, e.image_url);
if (e.name) byName.set(e.name.toLowerCase(), e.image_url);
}
const rows = db.prepare(`SELECT id, uuid, name FROM stations WHERE image_url IS NULL OR image_url = ''`).all();
const upd = db.prepare('UPDATE stations SET image_url = ? WHERE id = ?');
let restored = 0;
for (const r of rows) {
const url = (r.uuid && byUuid.get(r.uuid)) || byName.get(r.name?.toLowerCase());
if (!url) continue;
console.log(`restore ${r.name} -> ${url}`);
if (apply) upd.run(url, r.id);
restored++;
}
console.log(`Done. restored=${restored}${apply ? '' : ' (dry run; pass --apply to write)'}`);

7
server/scripts/seed.js Normal file
View File

@@ -0,0 +1,7 @@
// Standalone seed runner: `npm run seed`
import 'dotenv/config';
import { initDb } from '../db/index.js';
import { applySeedIfEmpty } from '../sources/seed.js';
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
console.log(applySeedIfEmpty());

View File

@@ -0,0 +1,141 @@
// Best-effort icon resolver for radio stations.
// Order:
// 1. Radio-Browser favicon by exact-ish name (only if station.source !== 'radiobrowser', else
// we already have it).
// 2. Scrape <link rel="icon">, <link rel="apple-touch-icon">, <meta property="og:image">
// from the homepage HTML.
// 3. HEAD-probe /favicon.ico at the homepage origin.
// Returns the best absolute URL found, or null.
const UA = 'OnlineRadioExplorer/0.1 (+icon-scraper)';
const FETCH_TIMEOUT_MS = 8000;
const MAX_HTML_BYTES = 256 * 1024;
const RB_BASE = 'https://de1.api.radio-browser.info';
function withTimeout(ms) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), ms);
return { signal: ctl.signal, done: () => clearTimeout(t) };
}
async function fetchText(url) {
const t = withTimeout(FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml' },
redirect: 'follow',
signal: t.signal
});
if (!res.ok) return null;
const reader = res.body?.getReader();
if (!reader) return null;
let received = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.length;
chunks.push(value);
if (received >= MAX_HTML_BYTES) { try { await reader.cancel(); } catch {} break; }
}
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
} catch {
return null;
} finally { t.done(); }
}
async function head(url) {
const t = withTimeout(FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { method: 'HEAD', headers: { 'User-Agent': UA }, signal: t.signal, redirect: 'follow' });
return res.ok;
} catch { return false; } finally { t.done(); }
}
function abs(base, href) {
if (!href) return null;
try { return new URL(href, base).toString(); } catch { return null; }
}
// Extract candidate icon URLs from raw HTML. Returns array of { href, size } sorted best-first.
function parseIconCandidates(html, baseUrl) {
const out = [];
// <link rel="...icon..." href="..." sizes="...">
const linkRe = /<link\b([^>]*?)\/?>/gi;
let m;
while ((m = linkRe.exec(html))) {
const attrs = m[1];
const rel = (/\brel\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
if (!/icon/i.test(rel)) continue;
const href = (/\bhref\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1];
if (!href) continue;
const sizes = (/\bsizes\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
const sz = parseInt((/(\d+)x\d+/.exec(sizes) || [])[1] || '0', 10);
const apple = /apple-touch-icon/i.test(rel) ? 64 : 0; // bias: apple-touch-icons usually larger PNGs
const u = abs(baseUrl, href);
if (u) out.push({ href: u, score: sz + apple });
}
// <meta property="og:image" content="...">
const metaRe = /<meta\b([^>]*?)\/?>/gi;
while ((m = metaRe.exec(html))) {
const attrs = m[1];
const prop = (/\b(?:property|name)\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
if (!/^og:image|^twitter:image/i.test(prop)) continue;
const content = (/\bcontent\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1];
const u = abs(baseUrl, content);
if (u) out.push({ href: u, score: 200 }); // og:image preferred
}
out.sort((a, b) => b.score - a.score);
// de-dupe preserving order
const seen = new Set();
return out.filter((c) => (seen.has(c.href) ? false : (seen.add(c.href), true)));
}
async function fromRadioBrowserByName(name) {
if (!name) return null;
try {
const url = `${RB_BASE}/json/stations/search?name=${encodeURIComponent(name)}&limit=3&hidebroken=true&order=clickcount&reverse=true`;
const t = withTimeout(FETCH_TIMEOUT_MS);
const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: t.signal });
t.done();
if (!res.ok) return null;
const list = await res.json();
const target = name.toLowerCase().trim();
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === target);
const pick = exact || list[0];
if (pick?.favicon) return pick.favicon;
} catch {}
return null;
}
async function fromHomepage(homepage) {
if (!homepage) return null;
let base;
try { base = new URL(homepage); } catch { return null; }
const html = await fetchText(base.toString());
if (html) {
const cands = parseIconCandidates(html, base.toString());
for (const c of cands) {
if (await head(c.href)) return c.href;
}
}
// last resort: /favicon.ico
const ico = `${base.origin}/favicon.ico`;
if (await head(ico)) return ico;
return null;
}
/**
* Try to find an icon URL for a station.
* @param {{ name?: string, homepage?: string|null, source?: string }} station
* @returns {Promise<string|null>}
*/
export async function scrapeIcon(station) {
if (!station) return null;
// For non-RB stations, RB often still has an entry → cheap win.
if (station.source !== 'radiobrowser') {
const rb = await fromRadioBrowserByName(station.name);
if (rb) return rb;
}
return fromHomepage(station.homepage);
}

View File

@@ -0,0 +1,65 @@
// Thin wrapper around the Radio-Browser community API.
// Docs: https://api.radio-browser.info/
const SERVERS = [
'https://de1.api.radio-browser.info',
'https://nl1.api.radio-browser.info',
'https://at1.api.radio-browser.info'
];
let activeServer = SERVERS[0];
async function rb(path, params) {
const url = new URL(path, activeServer);
if (params) for (const [k, v] of Object.entries(params)) {
if (v != null) url.searchParams.set(k, String(v));
}
const res = await fetch(url, { headers: { 'User-Agent': 'OnlineRadioExplorer/0.1' } });
if (!res.ok) throw new Error(`Radio-Browser ${res.status}`);
return res.json();
}
export async function search({ name, country, tag, limit = 30 }) {
const list = await rb('/json/stations/search', {
name, country, tag, limit, hidebroken: true, order: 'votes', reverse: true
});
return list.map(toCanonical);
}
export async function byUuid(uuid) {
const list = await rb('/json/stations/byuuid', { uuids: uuid });
return list[0] ? toCanonical(list[0]) : null;
}
function detectFormat(codec, url) {
const c = (codec || '').toLowerCase();
if (c.includes('mp3')) return 'mp3';
if (c.includes('aac')) return 'aac';
if (c.includes('ogg') || c.includes('vorbis') || c.includes('opus')) return 'ogg';
if (url?.endsWith('.m3u8')) return 'hls';
if (url?.endsWith('.m3u')) return 'm3u';
if (url?.endsWith('.pls')) return 'pls';
return 'unknown';
}
function toCanonical(s) {
return {
uuid: s.stationuuid || undefined,
name: s.name,
slug: `rb-${s.stationuuid}`,
homepage: s.homepage || null,
country: s.countrycode || s.country || null,
genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean),
description: null,
image_url: s.favicon || null,
source: 'radiobrowser',
source_ref: s.stationuuid,
streams: [{
url: s.url_resolved || s.url,
format: detectFormat(s.codec, s.url_resolved || s.url),
bitrate: s.bitrate || null,
label: s.codec ? `${s.codec} ${s.bitrate || ''}`.trim() : null,
priority: 0
}]
};
}

122
server/sources/seed.js Normal file
View File

@@ -0,0 +1,122 @@
import { readFileSync, readdirSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash, randomUUID } from 'node:crypto';
import { getDb } from '../db/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SEED_DIR = resolve(__dirname, '../../data/seed');
// Deterministic UUID v5-style derived from slug; stable across DB rebuilds.
function uuidFromSlug(slug) {
const h = createHash('sha1').update('oradio:' + slug).digest('hex');
return [
h.slice(0, 8),
h.slice(8, 12),
'5' + h.slice(13, 16),
'8' + h.slice(17, 20),
h.slice(20, 32)
].join('-');
}
function loadAllSeedFiles() {
const files = readdirSync(SEED_DIR)
.filter((f) => f.startsWith('stations') && f.endsWith('.json'))
.sort();
const all = [];
for (const f of files) {
try {
const data = JSON.parse(readFileSync(join(SEED_DIR, f), 'utf8'));
if (Array.isArray(data)) all.push(...data);
} catch (err) {
console.warn(`[seed] failed to load ${f}:`, err.message);
}
}
return all;
}
export function loadSeedFile() {
return loadAllSeedFiles();
}
export function loadCategoriesFile() {
try {
const txt = readFileSync(join(SEED_DIR, 'categories.json'), 'utf8');
return JSON.parse(txt);
} catch {
return [];
}
}
/**
* Merge-by-UUID seeder. Inserts stations and streams whose UUIDs are not yet in
* the database. Existing stations are left untouched (admin edits are preserved).
*/
export function applySeed() {
const db = getDb();
const stationByUuid = db.prepare('SELECT id FROM stations WHERE uuid = ?');
const streamByUuid = db.prepare('SELECT id FROM streams WHERE uuid = ?');
const insertStation = db.prepare(`
INSERT INTO stations (uuid, name, slug, homepage, country, genres, description, image_url, category, source, source_ref)
VALUES (@uuid, @name, @slug, @homepage, @country, @genres, @description, @image_url, @category, 'seed', @slug)
`);
const insertStream = db.prepare(`
INSERT INTO streams (uuid, station_id, url, format, bitrate, label, priority)
VALUES (@uuid, @station_id, @url, @format, @bitrate, @label, @priority)
`);
const entries = loadAllSeedFiles();
let inserted = 0;
let streamsInserted = 0;
let skipped = 0;
const tx = db.transaction((list) => {
for (const s of list) {
const uuid = s.uuid || uuidFromSlug(s.slug);
const existing = stationByUuid.get(uuid);
if (existing) {
skipped++;
continue;
}
const info = insertStation.run({
uuid,
name: s.name,
slug: s.slug,
homepage: s.homepage ?? null,
country: s.country ?? null,
genres: JSON.stringify(s.genres ?? []),
description: s.description ?? null,
image_url: s.image_url ?? null,
category: s.category ?? null
});
const stationId = info.lastInsertRowid;
let priority = 0;
for (const st of s.streams ?? []) {
const streamUuid = st.uuid || randomUUID();
if (streamByUuid.get(streamUuid)) continue;
insertStream.run({
uuid: streamUuid,
station_id: stationId,
url: st.url,
format: st.format ?? 'unknown',
bitrate: st.bitrate ?? null,
label: st.label ?? null,
priority: st.priority ?? priority
});
streamsInserted++;
priority++;
}
inserted++;
}
});
tx(entries);
return { inserted, streamsInserted, skipped, total: entries.length };
}
// Back-compat shim: bootstrap and reseed call applySeedIfEmpty(); now always merges.
export function applySeedIfEmpty() {
return applySeed();
}

142
server/stations.js Normal file
View File

@@ -0,0 +1,142 @@
import { randomUUID } from 'node:crypto';
import { getDb } from './db/index.js';
function rowToStation(row) {
if (!row) return null;
return {
id: row.id,
uuid: row.uuid,
name: row.name,
slug: row.slug,
homepage: row.homepage,
country: row.country,
genres: row.genres ? JSON.parse(row.genres) : [],
description: row.description,
image_url: row.image_url,
source: row.source,
source_ref: row.source_ref,
category: row.category,
enabled: !!row.enabled,
created_at: row.created_at,
updated_at: row.updated_at
};
}
export function listStations({ q, source, category, enabled = true } = {}) {
const db = getDb();
const where = [];
const params = [];
if (enabled !== null) { where.push('enabled = ?'); params.push(enabled ? 1 : 0); }
if (source) { where.push('source = ?'); params.push(source); }
if (category) { where.push('category = ?'); params.push(category); }
if (q) { where.push('(name LIKE ? OR genres LIKE ? OR country LIKE ?)'); params.push(`%${q}%`, `%${q}%`, `%${q}%`); }
const sql = `SELECT * FROM stations ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY name COLLATE NOCASE`;
return db.prepare(sql).all(...params).map(rowToStation);
}
export function getStation(id) {
return rowToStation(getDb().prepare('SELECT * FROM stations WHERE id = ?').get(id));
}
export function getStationByUuid(uuid) {
return rowToStation(getDb().prepare('SELECT * FROM stations WHERE uuid = ?').get(uuid));
}
export function getStreamsForStation(stationId) {
return getDb().prepare(
'SELECT * FROM streams WHERE station_id = ? ORDER BY priority ASC, id ASC'
).all(stationId);
}
export function getStreamByUuid(uuid) {
return getDb().prepare('SELECT * FROM streams WHERE uuid = ?').get(uuid);
}
export function slugify(name) {
return name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || `station-${Date.now()}`;
}
export function uniqueSlug(base) {
const db = getDb();
let slug = base, n = 1;
while (db.prepare('SELECT 1 FROM stations WHERE slug = ?').get(slug)) {
n += 1;
slug = `${base}-${n}`;
}
return slug;
}
export function createStation(input, userId) {
const db = getDb();
const slug = input.slug || uniqueSlug(slugify(input.name));
const uuid = input.uuid || randomUUID();
const info = db.prepare(`
INSERT INTO stations (uuid, name, slug, homepage, country, genres, description, image_url, source, source_ref, category, created_by)
VALUES (@uuid, @name, @slug, @homepage, @country, @genres, @description, @image_url, @source, @source_ref, @category, @created_by)
`).run({
uuid,
name: input.name,
slug,
homepage: input.homepage ?? null,
country: input.country ?? null,
genres: JSON.stringify(input.genres ?? []),
description: input.description ?? null,
image_url: input.image_url ?? null,
source: input.source ?? 'manual',
source_ref: input.source_ref ?? null,
category: input.category ?? null,
created_by: userId ?? null
});
const id = info.lastInsertRowid;
for (const s of input.streams ?? []) {
db.prepare(`INSERT INTO streams (uuid, station_id, url, format, bitrate, label, priority)
VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(s.uuid || randomUUID(), id, s.url, s.format ?? 'unknown', s.bitrate ?? null, s.label ?? null, s.priority ?? 0);
}
return getStation(id);
}
export function updateStation(id, patch) {
const db = getDb();
const cur = getStation(id);
if (!cur) return null;
const next = { ...cur, ...patch };
db.prepare(`
UPDATE stations
SET name=@name, homepage=@homepage, country=@country, genres=@genres,
description=@description, image_url=@image_url, category=@category,
enabled=@enabled, updated_at=datetime('now')
WHERE id=@id
`).run({
id,
name: next.name,
homepage: next.homepage ?? null,
country: next.country ?? null,
genres: JSON.stringify(next.genres ?? []),
description: next.description ?? null,
image_url: next.image_url ?? null,
category: next.category ?? null,
enabled: next.enabled ? 1 : 0
});
return getStation(id);
}
export function deleteStation(id) {
return getDb().prepare('DELETE FROM stations WHERE id = ?').run(id).changes > 0;
}
export function addStream(stationId, s) {
const uuid = s.uuid || randomUUID();
const info = getDb().prepare(`
INSERT INTO streams (uuid, station_id, url, format, bitrate, label, priority)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(uuid, stationId, s.url, s.format ?? 'unknown', s.bitrate ?? null, s.label ?? null, s.priority ?? 0);
return getDb().prepare('SELECT * FROM streams WHERE id = ?').get(info.lastInsertRowid);
}
export function deleteStream(streamId) {
return getDb().prepare('DELETE FROM streams WHERE id = ?').run(streamId).changes > 0;
}

25
server/streams/checker.js Normal file
View File

@@ -0,0 +1,25 @@
import cron from 'node-cron';
import { getDb } from '../db/index.js';
import { probeStream } from './probe.js';
const probe = probeStream;
export async function runHealthCheck() {
const db = getDb();
const streams = db.prepare('SELECT id, url FROM streams').all();
const update = db.prepare(
"UPDATE streams SET last_status = ?, last_checked_at = datetime('now') WHERE id = ?"
);
for (const s of streams) {
const status = await probe(s.url);
update.run(status, s.id);
}
return streams.length;
}
export function scheduleHealthCheck(expr) {
if (!expr) return null;
return cron.schedule(expr, () => {
runHealthCheck().catch((err) => console.error('[health]', err));
});
}

68
server/streams/probe.js Normal file
View File

@@ -0,0 +1,68 @@
// Low-level stream probe.
// Icecast/SHOUTcast servers commonly answer with `ICY 200 OK` instead of
// `HTTP/1.1 200 OK`, which Node's built-in fetch refuses to parse. We open
// a raw TCP/TLS socket, send a minimal HTTP/1.0 GET, and inspect the first
// status line ourselves.
import net from 'node:net';
import tls from 'node:tls';
const TIMEOUT = 8000;
const UA = 'Mozilla/5.0 OnlineRadioExplorer/0.1';
export function probeStream(rawUrl) {
return new Promise((resolve) => {
let url;
try { url = new URL(rawUrl); } catch { return resolve('err-badurl'); }
const isTls = url.protocol === 'https:';
const port = Number(url.port) || (isTls ? 443 : 80);
const path = (url.pathname || '/') + (url.search || '');
const host = url.hostname;
const opts = { host, port, servername: host };
const connect = isTls ? tls.connect : net.connect;
const sock = connect(opts);
let settled = false;
const finish = (status) => {
if (settled) return;
settled = true;
try { sock.destroy(); } catch {}
resolve(status);
};
sock.setTimeout(TIMEOUT);
sock.on('timeout', () => finish('err-timeout'));
sock.on('error', () => finish('err-fetch'));
sock.on('connect', () => {
const req =
`GET ${path} HTTP/1.0\r\n` +
`Host: ${host}\r\n` +
`User-Agent: ${UA}\r\n` +
`Icy-MetaData: 1\r\n` +
`Accept: */*\r\n` +
`Connection: close\r\n\r\n`;
sock.write(req);
});
let buf = '';
sock.on('data', (chunk) => {
buf += chunk.toString('latin1');
const eol = buf.indexOf('\n');
if (eol < 0) return;
const statusLine = buf.slice(0, eol).trim();
// Accept: HTTP/1.x 2xx, ICY 2xx, SOURCE 2xx
const m = statusLine.match(/^(?:HTTP\/\d\.\d|ICY|SOURCE)\s+(\d{3})/i);
if (!m) return finish(`bad-${statusLine.slice(0, 16)}`);
const code = Number(m[1]);
if (code >= 200 && code < 400) finish('up');
else finish(`http-${code}`);
});
sock.on('end', () => {
if (!settled) finish(buf ? 'err-empty' : 'err-fetch');
});
});
}

View File

@@ -0,0 +1,40 @@
// Resolve playlist files (.pls / .m3u) to a direct stream URL.
// HLS (.m3u8) is left as-is so hls.js can fetch it.
export function detectFormatFromUrl(url) {
const u = url.toLowerCase().split('?')[0];
if (u.endsWith('.m3u8')) return 'hls';
if (u.endsWith('.m3u')) return 'm3u';
if (u.endsWith('.pls')) return 'pls';
if (u.endsWith('.aac')) return 'aac';
if (u.endsWith('.mp3')) return 'mp3';
if (u.endsWith('.ogg') || u.endsWith('.opus')) return 'ogg';
return 'unknown';
}
function parsePls(text) {
const m = text.match(/^File\d+\s*=\s*(.+)$/im);
return m ? m[1].trim() : null;
}
function parseM3u(text) {
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
return lines.find((l) => !l.startsWith('#')) || null;
}
export async function resolveStream({ url, format }) {
const fmt = format && format !== 'unknown' ? format : detectFormatFromUrl(url);
if (fmt === 'pls' || fmt === 'm3u') {
try {
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
const direct = fmt === 'pls' ? parsePls(text) : parseM3u(text);
if (!direct) throw new Error('No direct URL found in playlist');
return { url: direct, format: detectFormatFromUrl(direct) };
} catch (err) {
return { url, format: fmt, error: String(err.message || err) };
}
}
return { url, format: fmt };
}

56
server/ws.js Normal file
View File

@@ -0,0 +1,56 @@
import { WebSocketServer } from 'ws';
import { getUserBySession, readSessionToken } from './auth.js';
// per-user channel hub: any client of user U receives messages targeted to U.
const channels = new Map(); // userId -> Set<ws>
export function attachWs(server) {
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
if (!req.url.startsWith('/ws')) return socket.destroy();
const token = readSessionToken(req);
const user = getUserBySession(token);
if (!user) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.user = user;
addClient(user.id, ws);
ws.on('close', () => removeClient(user.id, ws));
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw.toString()); } catch { return; }
// Re-broadcast every message to all connections of the same user.
// (e.g. phone sends `{type:"command", action:"play", stationId:7}` → kiosk receives)
broadcastToUser(user.id, msg, ws);
});
ws.send(JSON.stringify({ type: 'hello', user: { id: user.id, username: user.username, role: user.role } }));
});
});
return wss;
}
function addClient(userId, ws) {
if (!channels.has(userId)) channels.set(userId, new Set());
channels.get(userId).add(ws);
}
function removeClient(userId, ws) {
const set = channels.get(userId);
if (!set) return;
set.delete(ws);
if (!set.size) channels.delete(userId);
}
export function broadcastToUser(userId, msg, except) {
const set = channels.get(userId);
if (!set) return;
const payload = JSON.stringify(msg);
for (const ws of set) {
if (ws === except) continue;
if (ws.readyState === ws.OPEN) ws.send(payload);
}
}