Add API documentation and underground station importer

- Introduced a new HTML documentation page for the oradio API, including a JavaScript file to handle dynamic content and API requests.
- Added a CSS file for styling the documentation page.
- Implemented an underground station importer script that fetches data from Radio-Browser and writes it to a JSON file.
- Created a stats module to compute and manage vote and play statistics for radio stations.
- Added a polyfill for modulepreload to ensure compatibility with older browsers.
This commit is contained in:
Marco Mooren
2026-05-11 02:06:48 +02:00
parent e0a60f7b64
commit 00246389bc
52 changed files with 6280 additions and 2475 deletions

View File

@@ -1,17 +1,92 @@
[ [
{ "id": "starter", "label": "Starter pack", "icon": "★", "order": 0 }, {
{ "id": "dutch-public", "label": "Nederlandse publieke","icon": "🇳🇱", "order": 1 }, "id": "starter",
{ "id": "dutch-commercial", "label": "Nederlandse commercieel","icon": "🇳🇱", "order": 2 }, "label": "Starter pack",
{ "id": "bbc", "label": "BBC family", "icon": "🇬🇧", "order": 3 }, "icon": "",
{ "id": "fip", "label": "FIP family", "icon": "🇫🇷", "order": 4 }, "order": 0
{ "id": "underground", "label": "Underground & curated","icon": "🌐", "order": 5 }, },
{ "id": "ambient", "label": "Ambient & lo-fi", "icon": "🌫", "order": 6 }, {
{ "id": "electronic", "label": "Electronic", "icon": "⚡", "order": 7 }, "id": "dutch-public",
{ "id": "jazz", "label": "Jazz & blues", "icon": "🎷", "order": 8 }, "label": "Nederlandse publieke",
{ "id": "classical", "label": "Classical", "icon": "🎻", "order": 9 }, "icon": "🇳🇱",
{ "id": "rock", "label": "Rock & indie", "icon": "🎸", "order": 10 }, "order": 1
{ "id": "reggae", "label": "Reggae & dub", "icon": "🌴", "order": 11 }, },
{ "id": "world", "label": "World & regional", "icon": "🌍", "order": 12 }, {
{ "id": "soma", "label": "SomaFM channels", "icon": "📻", "order": 13 }, "id": "dutch-commercial",
{ "id": "nts", "label": "NTS infinite mixtapes","icon": "♾", "order": 14 } "label": "Nederlandse commercieel",
"icon": "🇳🇱",
"order": 2
},
{
"id": "bbc",
"label": "BBC family",
"icon": "🇬🇧",
"order": 3
},
{
"id": "fip",
"label": "FIP family",
"icon": "🇫🇷",
"order": 4
},
{
"id": "underground",
"label": "Underground & curated",
"icon": "🌐",
"order": 5
},
{
"id": "ambient",
"label": "Ambient & lo-fi",
"icon": "🌫",
"order": 6
},
{
"id": "electronic",
"label": "Electronic",
"icon": "⚡",
"order": 7
},
{
"id": "jazz",
"label": "Jazz & blues",
"icon": "🎷",
"order": 8
},
{
"id": "classical",
"label": "Classical",
"icon": "🎻",
"order": 9
},
{
"id": "rock",
"label": "Rock & indie",
"icon": "🎸",
"order": 10
},
{
"id": "reggae",
"label": "Reggae & dub",
"icon": "🌴",
"order": 11
},
{
"id": "world",
"label": "World & regional",
"icon": "🌍",
"order": 12
},
{
"id": "soma",
"label": "SomaFM channels",
"icon": "📻",
"order": 13
},
{
"id": "nts",
"label": "NTS infinite mixtapes",
"icon": "♾",
"order": 14
}
] ]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,29 @@
{ {
"name": "online-radio-explorer", "name": "online-radio-explorer",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Touchscreen kiosk + admin for exploring and playing internet radio.", "description": "Touchscreen kiosk + admin for exploring and playing internet radio.",
"scripts": { "scripts": {
"dev": "concurrently -k -n web,api -c blue,green \"npm:dev:web\" \"npm:dev:api\"", "dev": "concurrently -k -n web,api -c blue,green \"npm:dev:web\" \"npm:dev:api\"",
"dev:web": "vite", "dev:web": "vite",
"dev:api": "node --watch server/index.js", "dev:api": "node --watch server/index.js",
"build": "vite build", "build": "vite build",
"start": "node server/index.js", "start": "node server/index.js",
"seed": "node server/scripts/seed.js" "seed": "node server/scripts/seed.js"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"cookie": "^1.0.1", "cookie": "^1.0.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.0", "express": "^4.21.0",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"hls.js": "^1.5.17", "hls.js": "^1.5.17",
"vite": "^5.4.8" "vite": "^5.4.8"
} }
} }

View File

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

View File

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

View File

@@ -78,3 +78,23 @@ CREATE TABLE IF NOT EXISTS play_history (
); );
CREATE INDEX IF NOT EXISTS idx_history_user ON play_history(user_id, started_at DESC); 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.
CREATE TABLE IF NOT EXISTS station_plays (
station_id INTEGER PRIMARY KEY REFERENCES stations(id) ON DELETE CASCADE,
plays INTEGER NOT NULL DEFAULT 0,
last_played_at TEXT
);

View File

@@ -22,8 +22,8 @@ const PORT = Number(process.env.PORT) || 4173;
initDb(process.env.DB_PATH || './data/db/oradio.sqlite'); initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
ensureBootstrapAdmin({ ensureBootstrapAdmin({
username: process.env.ADMIN_BOOTSTRAP_USER, username: process.env.ADMIN_BOOTSTRAP_USER,
password: process.env.ADMIN_BOOTSTRAP_PASSWORD password: process.env.ADMIN_BOOTSTRAP_PASSWORD
}); });
const seedResult = applySeedIfEmpty(); const seedResult = applySeedIfEmpty();
console.log('[seed]', seedResult); console.log('[seed]', seedResult);
@@ -32,23 +32,24 @@ const app = express();
app.use(express.json({ limit: '512kb' })); app.use(express.json({ limit: '512kb' }));
app.use(authMiddleware); app.use(authMiddleware);
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/stations', stationRoutes); app.use('/api/stations', stationRoutes);
app.use('/api/me', meRoutes); app.use('/api/me', meRoutes);
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.use('/api/v1', v1Routes); app.use('/api/v1', v1Routes);
// Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173. // Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173.
const publicDir = resolve(__dirname, 'public'); const publicDir = resolve(__dirname, 'public');
if (existsSync(publicDir)) { if (existsSync(publicDir)) {
app.use(express.static(publicDir)); app.use(express.static(publicDir));
app.get('/admin', (_req, res) => res.sendFile(resolve(publicDir, 'admin/index.html'))); app.get('/admin', (_req, res) => res.sendFile(resolve(publicDir, 'admin/index.html')));
app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html'))); app.get('/docs', (_req, res) => res.sendFile(resolve(publicDir, 'docs/index.html')));
app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html')));
} }
app.use((err, _req, res, _next) => { app.use((err, _req, res, _next) => {
console.error(err); console.error(err);
res.status(500).json({ error: String(err.message || err) }); res.status(500).json({ error: String(err.message || err) });
}); });
const server = createServer(app); const server = createServer(app);
@@ -56,5 +57,5 @@ attachWs(server);
scheduleHealthCheck(process.env.STREAM_CHECK_CRON); scheduleHealthCheck(process.env.STREAM_CHECK_CRON);
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`[oradio] api+ws on http://localhost:${PORT}`); console.log(`[oradio] api+ws on http://localhost:${PORT}`);
}); });

View File

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

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,5 @@
import"./modulepreload-polyfill-B5Qt9EMX.js";const p=`${location.origin}/api/v1`,u=`${location.origin}/api`;document.getElementById("base").textContent=p;const C=[{group:"Public (v1)",items:[{id:"health",method:"GET",path:"/health",summary:"Service heartbeat plus enabled-station count.",tryable:!0},{id:"categories",method:"GET",path:"/categories",summary:"All categories with their station counts.",tryable:!0},{id:"stations-list",method:"GET",path:"/stations",summary:"Paginated station list. Filterable and sortable.",params:[{name:"q",desc:"Substring filter on name / genres / country."},{name:"category",desc:"Category id (see /categories)."},{name:"country",desc:"ISO country code, case-insensitive."},{name:"genre",desc:"Substring match against any genre."},{name:"sort",desc:"hot | top | plays | controversial | name (default: name)."},{name:"limit",desc:"Max items returned (default 200, cap 1000)."}],tryable:!0,tryQuery:"limit=3&sort=hot"},{id:"random",method:"GET",path:"/stations/random",summary:"Pick one random enabled station. Same filters as /stations. Pass redirect=stream for a 302 to the resolved audio URL.",params:[{name:"category",desc:"Restrict pool to a category."},{name:"country",desc:"Restrict pool to a country."},{name:"genre",desc:"Restrict pool by genre substring."},{name:"redirect",desc:'Set to "stream" to 302-redirect to the resolved stream URL.'}],tryable:!0,examples:[`mpv ${p}/stations/random?redirect=stream`,`curl -sLI "${p}/stations/random?redirect=stream" | grep -i location`]},{id:"station",method:"GET",path:"/stations/{uuid}",summary:"Full detail for one station, including its streams.",params:[{name:"uuid",desc:"Station UUID (see list response)."}]},{id:"station-stream",method:"GET",path:"/stations/{uuid}/stream",summary:"302-redirect to the resolved stream URL. Picks the highest-priority stream that was last seen up.",params:[{name:"uuid",desc:"Station UUID."},{name:"format",desc:"Optional preferred format (mp3, aac, ogg, hls)."}]},{id:"stream-by-uuid",method:"GET",path:"/stations/{uuid}/streams/{streamUuid}",summary:"Resolve and 302 to a specific stream. Pass redirect=0 to return JSON metadata instead.",params:[{name:"uuid",desc:"Station UUID."},{name:"streamUuid",desc:"Stream UUID."},{name:"redirect",desc:'Set to "0" to return JSON instead of redirecting.'}]}]},{group:"Authenticated (cookie session)",items:[{id:"me",method:"GET",path:"/auth/me",base:u,summary:"Current signed-in user, or 401.",tryable:!0},{id:"favorites",method:"GET",path:"/me/favorites",base:u,summary:"Your favorites, ordered.",tryable:!0},{id:"favorites-random",method:"GET",path:"/me/favorites/random",base:u,summary:'One random favorite — used by the kiosk dice button in "favorites" mode.',tryable:!0},{id:"history",method:"GET",path:"/me/history",base:u,summary:"Recent play history (last 50).",tryable:!0}]},{group:"Rate limit",items:[{id:"rate",method:"INFO",path:"120 req / minute / IP",summary:"Public /api/v1 endpoints share a per-IP token bucket. Headers X-RateLimit-Limit and X-RateLimit-Remaining tell you where you stand."}]}],f=document.getElementById("app");function e(t,i,...d){const s=document.createElement(t);if(i)for(const[a,o]of Object.entries(i))o==null||o===!1||(a==="class"?s.className=o:a.startsWith("on")&&typeof o=="function"?s.addEventListener(a.slice(2).toLowerCase(),o):s.setAttribute(a,o));for(const a of d)a==null||a===!1||s.appendChild(typeof a=="string"?document.createTextNode(a):a);return s}function E(t){return e("span",{class:`m m-${t.toLowerCase()}`},t)}function T(t){var a,o;const d=`${t.base||p}${t.path}`,s=e("article",{class:"ep",id:t.id});if(s.appendChild(e("header",{class:"ep-head"},E(t.method),e("code",{class:"ep-path"},d))),s.appendChild(e("p",{class:"ep-sum"},t.summary)),(a=t.params)!=null&&a.length){const n=e("table",{class:"params"},e("thead",{},e("tr",{},e("th",{},"Parameter"),e("th",{},"Description"))),e("tbody",{},...t.params.map(m=>e("tr",{},e("td",{},e("code",{},m.name)),e("td",{},m.desc)))));s.appendChild(n)}if((o=t.examples)!=null&&o.length&&s.appendChild(e("div",{class:"examples"},e("div",{class:"examples-h"},"Examples"),...t.examples.map(n=>e("pre",{},e("code",{},n))))),t.tryable&&t.method==="GET"){const n=e("pre",{class:"try-out"},'Click "Try it" to send a live request.'),m=e("input",{class:"try-q",type:"text",placeholder:"?key=value (optional)",value:t.tryQuery?`?${t.tryQuery}`:""}),c=e("button",{class:"try-btn",onClick:async()=>{c.disabled=!0,c.textContent="…";let l=m.value.trim();l&&!l.startsWith("?")&&(l=`?${l}`);const h=`${d}${l}`,g=performance.now();try{const r=await fetch(h,{credentials:"same-origin",redirect:"manual"}),v=Math.round(performance.now()-g);let y;r.type==="opaqueredirect"||r.status>=300&&r.status<400?y="(redirect — open in new tab to follow)":y=(r.headers.get("content-type")||"").includes("json")?JSON.stringify(await r.json(),null,2):(await r.text()).slice(0,4e3),n.textContent=`${r.status} ${r.statusText||""} · ${v} ms
${h}
${y}`}catch(r){n.textContent=`error: ${r.message||r}
${h}`}finally{c.disabled=!1,c.textContent="Try it"}}},"Try it"),b=e("a",{class:"try-open",target:"_blank",rel:"noopener",href:d},"Open ↗");s.appendChild(e("div",{class:"try"},e("div",{class:"try-row"},m,c,b),n))}return s}for(const t of C){f.appendChild(e("h2",{class:"group"},t.group));for(const i of t.items)f.appendChild(T(i))}

View File

@@ -0,0 +1 @@
:root{--bg-0: #07080b;--bg-1: #0e1116;--bg-2: #161a22;--bg-3: #1f242e;--line: #262b36;--fg: #e9ecf2;--muted: #8a90a0;--muted-2: #5d6373;--accent: #ff7a3d;--accent-2: #ffb37a;--good: #4ec9a6;--bad: #ec6a6a;--info: #6ab7ff;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color-scheme:dark}*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg-0);color:var(--fg)}a{color:var(--accent-2);text-decoration:none}a:hover{text-decoration:underline}.docs-header{position:sticky;top:0;z-index:10;background:#07080beb;border-bottom:1px solid var(--line);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.docs-header-inner{max-width:980px;margin:0 auto;padding:14px 20px;display:flex;align-items:center;gap:16px}.docs-header h1{margin:0;font-size:18px;letter-spacing:-.01em}.docs-header .back{color:var(--muted);font-size:13px;padding:6px 10px;border:1px solid var(--line);border-radius:8px}.docs-header .back:hover{color:var(--fg);background:var(--bg-2);text-decoration:none}.docs-header .base{margin-left:auto;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:12px;color:var(--muted);padding:4px 10px;background:var(--bg-2);border:1px solid var(--line);border-radius:8px}#app{max-width:980px;margin:0 auto;padding:24px 20px 80px}h2.group{margin:32px 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted)}h2.group:first-child{margin-top:0}.ep{background:var(--bg-1);border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:14px}.ep-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.ep-path{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:13px;color:var(--fg);background:var(--bg-2);padding:4px 10px;border-radius:6px;border:1px solid var(--line);overflow-wrap:anywhere}.ep-sum{margin:10px 0 0;color:var(--muted);font-size:14px;line-height:1.5}.m{display:inline-block;font-size:11px;font-weight:800;letter-spacing:.06em;padding:4px 8px;border-radius:6px;color:#07080b}.m-get{background:var(--good)}.m-post{background:var(--accent)}.m-put{background:#d9b14a}.m-delete{background:var(--bad);color:#fff}.m-info{background:var(--info)}.params{width:100%;border-collapse:collapse;margin-top:14px;font-size:13px}.params th{text-align:left;font-weight:600;color:var(--muted);text-transform:uppercase;font-size:11px;letter-spacing:.06em;padding:8px 10px;border-bottom:1px solid var(--line)}.params td{padding:8px 10px;border-bottom:1px solid var(--line);color:var(--fg)}.params td:first-child{width:160px}.params code{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:12px;color:var(--accent-2);background:var(--bg-2);padding:2px 6px;border-radius:4px}.examples{margin-top:14px}.examples-h{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}.examples pre{margin:0 0 8px;padding:10px 12px;background:var(--bg-0);border:1px solid var(--line);border-radius:8px;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:12.5px;color:var(--accent-2);overflow-x:auto}.try{margin-top:14px}.try-row{display:flex;gap:8px;align-items:center;margin-bottom:8px}.try-q{flex:1;background:var(--bg-2);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:13px;font-family:ui-monospace,SF Mono,Menlo,monospace;outline:none}.try-q:focus{border-color:var(--accent)}.try-btn{background:var(--accent);color:#1a0a00;border:0;border-radius:8px;padding:8px 16px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit}.try-btn:hover{background:#ff8a55}.try-btn:disabled{opacity:.6;cursor:default}.try-open{color:var(--muted);font-size:12px;padding:6px 10px;border:1px solid var(--line);border-radius:8px}.try-open:hover{color:var(--fg);background:var(--bg-2);text-decoration:none}.try-out{margin:0;padding:12px;background:var(--bg-0);border:1px solid var(--line);border-radius:8px;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:12px;color:var(--fg);max-height:320px;overflow:auto;white-space:pre-wrap;word-break:break-word}

View File

@@ -1 +0,0 @@
(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};

View File

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

File diff suppressed because one or more lines are too long

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 t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();

View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>oradio · API reference</title>
<script type="module" crossorigin src="/assets/docs-CJfnRuXm.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="stylesheet" crossorigin href="/assets/docs-z3ZiwvpP.css">
</head>
<body>
<header class="docs-header">
<div class="docs-header-inner">
<a href="/" class="back">← Kiosk</a>
<h1>oradio API</h1>
<span class="base" id="base"></span>
</div>
</header>
<main id="app"></main>
</body>

View File

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

View File

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

View File

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

View File

@@ -1,63 +1,86 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireUser } from '../auth.js'; import { requireUser } from '../auth.js';
import { getDb } from '../db/index.js'; import { getDb } from '../db/index.js';
import { getStatsMap } from '../stats.js';
export const router = Router(); export const router = Router();
router.use(requireUser); router.use(requireUser);
router.get('/favorites', (req, res) => { router.get('/favorites', (req, res) => {
const rows = getDb().prepare(` const rows = getDb().prepare(`
SELECT s.*, f.position SELECT s.*, f.position
FROM favorites f JOIN stations s ON s.id = f.station_id FROM favorites f JOIN stations s ON s.id = f.station_id
WHERE f.user_id = ? AND s.enabled = 1 WHERE f.user_id = ? AND s.enabled = 1
ORDER BY f.position ASC, f.created_at ASC ORDER BY f.position ASC, f.created_at ASC
`).all(req.user.id); `).all(req.user.id);
res.json(rows.map((r) => ({ const stats = getStatsMap(req.user.id);
id: r.id, uuid: r.uuid, name: r.name, slug: r.slug, homepage: r.homepage, country: r.country, res.json(rows.map((r) => {
genres: r.genres ? JSON.parse(r.genres) : [], image_url: r.image_url, category: r.category, position: r.position const st = stats.get(r.id) || { up: 0, down: 0, plays: 0, myVote: 0, score: 0 };
}))); return {
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,
up: st.up, down: st.down, plays: st.plays, my_vote: st.myVote, score: st.score
};
}));
});
// Pick one random favorite. Returns 404 if the user has none.
router.get('/favorites/random', (req, res) => {
const rows = getDb().prepare(`
SELECT s.* FROM favorites f JOIN stations s ON s.id = f.station_id
WHERE f.user_id = ? AND s.enabled = 1
`).all(req.user.id);
if (!rows.length) return res.status(404).json({ error: 'no favorites' });
const r = rows[Math.floor(Math.random() * rows.length)];
const stats = getStatsMap(req.user.id).get(r.id) || { up: 0, down: 0, plays: 0, myVote: 0, score: 0 };
res.set('Cache-Control', 'no-store');
res.json({
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,
up: stats.up, down: stats.down, plays: stats.plays, my_vote: stats.myVote, score: stats.score
});
}); });
router.put('/favorites/:stationId', (req, res) => { router.put('/favorites/:stationId', (req, res) => {
const stationId = Number(req.params.stationId); const stationId = Number(req.params.stationId);
const position = Number(req.body?.position ?? 0); const position = Number(req.body?.position ?? 0);
getDb().prepare(` getDb().prepare(`
INSERT INTO favorites (user_id, station_id, position) VALUES (?, ?, ?) INSERT INTO favorites (user_id, station_id, position) VALUES (?, ?, ?)
ON CONFLICT(user_id, station_id) DO UPDATE SET position = excluded.position ON CONFLICT(user_id, station_id) DO UPDATE SET position = excluded.position
`).run(req.user.id, stationId, position); `).run(req.user.id, stationId, position);
res.json({ ok: true }); res.json({ ok: true });
}); });
router.delete('/favorites/:stationId', (req, res) => { router.delete('/favorites/:stationId', (req, res) => {
getDb().prepare('DELETE FROM favorites WHERE user_id = ? AND station_id = ?') getDb().prepare('DELETE FROM favorites WHERE user_id = ? AND station_id = ?')
.run(req.user.id, Number(req.params.stationId)); .run(req.user.id, Number(req.params.stationId));
res.json({ ok: true }); res.json({ ok: true });
}); });
router.get('/profile', (req, res) => { router.get('/profile', (req, res) => {
const row = getDb().prepare('SELECT * FROM profiles WHERE user_id = ?').get(req.user.id); 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 }); res.json(row || { user_id: req.user.id, display_name: req.user.username, theme: 'dark', default_volume: 0.7 });
}); });
router.patch('/profile', (req, res) => { router.patch('/profile', (req, res) => {
const { display_name, theme, default_volume } = req.body || {}; const { display_name, theme, default_volume } = req.body || {};
getDb().prepare(` getDb().prepare(`
INSERT INTO profiles (user_id, display_name, theme, default_volume) VALUES (?, ?, ?, ?) INSERT INTO profiles (user_id, display_name, theme, default_volume) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET ON CONFLICT(user_id) DO UPDATE SET
display_name = COALESCE(excluded.display_name, profiles.display_name), display_name = COALESCE(excluded.display_name, profiles.display_name),
theme = COALESCE(excluded.theme, profiles.theme), theme = COALESCE(excluded.theme, profiles.theme),
default_volume = COALESCE(excluded.default_volume, profiles.default_volume) default_volume = COALESCE(excluded.default_volume, profiles.default_volume)
`).run(req.user.id, display_name ?? null, theme ?? null, default_volume ?? null); `).run(req.user.id, display_name ?? null, theme ?? null, default_volume ?? null);
res.json({ ok: true }); res.json({ ok: true });
}); });
router.get('/history', (req, res) => { router.get('/history', (req, res) => {
const rows = getDb().prepare(` const rows = getDb().prepare(`
SELECT h.*, s.name AS station_name, s.slug AS station_slug 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 FROM play_history h JOIN stations s ON s.id = h.station_id
WHERE h.user_id = ? WHERE h.user_id = ?
ORDER BY h.started_at DESC LIMIT 50 ORDER BY h.started_at DESC LIMIT 50
`).all(req.user.id); `).all(req.user.id);
res.json(rows); res.json(rows);
}); });

View File

@@ -1,41 +1,77 @@
import { Router } from 'express'; import { Router } from 'express';
import { import {
listStations, getStation, getStreamsForStation, listStations, getStation, getStreamsForStation,
createStation, updateStation, deleteStation, addStream, deleteStream createStation, updateStation, deleteStation, addStream, deleteStream
} from '../stations.js'; } from '../stations.js';
import { resolveStream } from '../streams/resolver.js'; import { resolveStream } from '../streams/resolver.js';
import { requireAdmin, requireUser } from '../auth.js'; import { requireAdmin, requireUser } from '../auth.js';
import * as radiobrowser from '../sources/radiobrowser.js'; import * as radiobrowser from '../sources/radiobrowser.js';
import { castVote, getStationStats, getStatsMap, recordPlay, sortByMode } from '../stats.js';
export const router = Router(); export const router = Router();
router.get('/', (req, res) => { router.get('/', (req, res) => {
const stations = listStations({ const stations = listStations({
q: req.query.q || undefined, q: req.query.q || undefined,
source: req.query.source || undefined, source: req.query.source || undefined,
enabled: req.query.all ? null : true enabled: req.query.all ? null : true
}); });
res.json(stations); const statsMap = getStatsMap(req.user?.id || null);
for (const s of stations) {
const st = statsMap.get(s.id) || { up: 0, down: 0, plays: 0, myVote: 0, score: 0 };
s.up = st.up; s.down = st.down; s.plays = st.plays;
s.my_vote = st.myVote; s.score = st.score;
}
sortByMode(stations, req.query.sort, statsMap);
res.json(stations);
}); });
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const station = getStation(id); const station = getStation(id);
if (!station) return res.status(404).json({ error: 'not found' }); if (!station) return res.status(404).json({ error: 'not found' });
station.streams = getStreamsForStation(id); station.streams = getStreamsForStation(id);
res.json(station); Object.assign(station, getStationStats(id, req.user?.id || null));
res.json(station);
});
// --- voting ---
router.get('/:id/votes', (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
res.json(getStationStats(id, req.user?.id || null));
});
router.post('/:id/vote', requireUser, (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
const raw = req.body?.value;
const value = raw === 1 || raw === '1' || raw === 'up' ? 1
: raw === -1 || raw === '-1' || raw === 'down' ? -1
: raw === 0 || raw === '0' || raw === null || raw === 'clear' ? 0
: NaN;
if (Number.isNaN(value)) return res.status(400).json({ error: 'value must be 1, -1 or 0' });
res.json(castVote(req.user.id, id, value));
});
// Lightweight play-count ping (called when the kiosk actually starts a station).
router.post('/:id/play', requireUser, (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
recordPlay(id);
res.json(getStationStats(id, req.user.id));
}); });
router.post('/:id/resolve', requireUser, async (req, res) => { router.post('/:id/resolve', requireUser, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const streams = getStreamsForStation(id); const streams = getStreamsForStation(id);
if (!streams.length) return res.status(404).json({ error: 'no streams' }); if (!streams.length) return res.status(404).json({ error: 'no streams' });
const preferred = req.body?.streamId const preferred = req.body?.streamId
? streams.find((s) => s.id === Number(req.body.streamId)) ? streams.find((s) => s.id === Number(req.body.streamId))
: streams[0]; : streams[0];
if (!preferred) return res.status(404).json({ error: 'stream not found' }); if (!preferred) return res.status(404).json({ error: 'stream not found' });
const resolved = await resolveStream({ url: preferred.url, format: preferred.format }); const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
res.json({ stream: preferred, resolved }); res.json({ stream: preferred, resolved });
}); });
// Same-origin streaming proxy. Adds the CORS headers Icecast/SHOUTcast servers // Same-origin streaming proxy. Adds the CORS headers Icecast/SHOUTcast servers
@@ -43,108 +79,108 @@ router.post('/:id/resolve', requireUser, async (req, res) => {
// real spectrum. HLS is excluded — the manifest plus every segment would need // real spectrum. HLS is excluded — the manifest plus every segment would need
// rewriting; clients fall back to the direct URL with no analyser there. // rewriting; clients fall back to the direct URL with no analyser there.
router.get('/:id/proxy', requireUser, async (req, res) => { router.get('/:id/proxy', requireUser, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const streams = getStreamsForStation(id); const streams = getStreamsForStation(id);
if (!streams.length) return res.status(404).json({ error: 'no streams' }); if (!streams.length) return res.status(404).json({ error: 'no streams' });
const preferred = req.query.streamId const preferred = req.query.streamId
? streams.find((s) => s.id === Number(req.query.streamId)) ? streams.find((s) => s.id === Number(req.query.streamId))
: streams[0]; : streams[0];
if (!preferred) return res.status(404).json({ error: 'stream not found' }); if (!preferred) return res.status(404).json({ error: 'stream not found' });
const resolved = await resolveStream({ url: preferred.url, format: preferred.format }); const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
if (resolved.format === 'hls') return res.status(415).json({ error: 'hls not proxied' }); if (resolved.format === 'hls') return res.status(415).json({ error: 'hls not proxied' });
const controller = new AbortController(); const controller = new AbortController();
req.on('close', () => controller.abort()); req.on('close', () => controller.abort());
let upstream; 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 { try {
while (true) { upstream = await fetch(resolved.url, {
const { value, done } = await reader.read(); redirect: 'follow',
if (done) break; signal: controller.signal,
if (!res.write(Buffer.from(value))) { headers: { 'User-Agent': 'oradio-kiosk/1.0', 'Icy-MetaData': '0' }
await new Promise((r) => res.once('drain', r)); });
} } catch (err) {
} return res.status(502).json({ error: `upstream: ${err.message || err}` });
} catch { /* client disconnect or upstream abort */ }
finally {
try { reader.cancel(); } catch {}
res.end();
} }
}; if (!upstream.ok || !upstream.body) {
pump(); 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) { function guessContentType(format) {
switch (format) { switch (format) {
case 'mp3': return 'audio/mpeg'; case 'mp3': return 'audio/mpeg';
case 'aac': return 'audio/aac'; case 'aac': return 'audio/aac';
case 'ogg': return 'audio/ogg'; case 'ogg': return 'audio/ogg';
default: return 'application/octet-stream'; default: return 'application/octet-stream';
} }
} }
// --- admin mutations --- // --- admin mutations ---
router.post('/', requireAdmin, (req, res) => { router.post('/', requireAdmin, (req, res) => {
const station = createStation({ ...req.body, source: req.body.source || 'manual' }, req.user.id); const station = createStation({ ...req.body, source: req.body.source || 'manual' }, req.user.id);
res.status(201).json(station); res.status(201).json(station);
}); });
router.patch('/:id', requireAdmin, (req, res) => { router.patch('/:id', requireAdmin, (req, res) => {
const station = updateStation(Number(req.params.id), req.body || {}); const station = updateStation(Number(req.params.id), req.body || {});
if (!station) return res.status(404).json({ error: 'not found' }); if (!station) return res.status(404).json({ error: 'not found' });
res.json(station); res.json(station);
}); });
router.delete('/:id', requireAdmin, (req, res) => { router.delete('/:id', requireAdmin, (req, res) => {
if (!deleteStation(Number(req.params.id))) return res.status(404).json({ error: 'not found' }); if (!deleteStation(Number(req.params.id))) return res.status(404).json({ error: 'not found' });
res.json({ ok: true }); res.json({ ok: true });
}); });
router.post('/:id/streams', requireAdmin, (req, res) => { router.post('/:id/streams', requireAdmin, (req, res) => {
const stream = addStream(Number(req.params.id), req.body || {}); const stream = addStream(Number(req.params.id), req.body || {});
res.status(201).json(stream); res.status(201).json(stream);
}); });
router.delete('/:id/streams/:streamId', requireAdmin, (req, res) => { router.delete('/:id/streams/:streamId', requireAdmin, (req, res) => {
if (!deleteStream(Number(req.params.streamId))) return res.status(404).json({ error: 'not found' }); if (!deleteStream(Number(req.params.streamId))) return res.status(404).json({ error: 'not found' });
res.json({ ok: true }); res.json({ ok: true });
}); });
// --- Radio-Browser passthrough for the admin importer --- // --- Radio-Browser passthrough for the admin importer ---
router.get('/sources/radiobrowser/search', requireAdmin, async (req, res) => { router.get('/sources/radiobrowser/search', requireAdmin, async (req, res) => {
const results = await radiobrowser.search({ const results = await radiobrowser.search({
name: req.query.q, name: req.query.q,
country: req.query.country, country: req.query.country,
tag: req.query.tag, tag: req.query.tag,
limit: Number(req.query.limit) || 30 limit: Number(req.query.limit) || 30
}); });
res.json(results); res.json(results);
}); });
router.post('/sources/radiobrowser/import', requireAdmin, (req, res) => { router.post('/sources/radiobrowser/import', requireAdmin, (req, res) => {
const station = createStation({ ...req.body, source: 'radiobrowser' }, req.user.id); const station = createStation({ ...req.body, source: 'radiobrowser' }, req.user.id);
res.status(201).json(station); res.status(201).json(station);
}); });

View File

@@ -4,21 +4,22 @@
import { Router } from 'express'; import { Router } from 'express';
import { import {
listStations, getStationByUuid, getStreamsForStation, getStreamByUuid listStations, getStationByUuid, getStreamsForStation, getStreamByUuid
} from '../stations.js'; } from '../stations.js';
import { resolveStream } from '../streams/resolver.js'; import { resolveStream } from '../streams/resolver.js';
import { getDb } from '../db/index.js'; import { getDb } from '../db/index.js';
import { loadCategoriesFile } from '../sources/seed.js'; import { loadCategoriesFile } from '../sources/seed.js';
import { getStationStats, getStatsMap, sortByMode } from '../stats.js';
export const router = Router(); export const router = Router();
// CORS for public endpoints. Browser-side integrations can hit the API // CORS for public endpoints. Browser-side integrations can hit the API
// from any origin; we don't expose any user data here. // from any origin; we don't expose any user data here.
router.use((_req, res, next) => { router.use((_req, res, next) => {
res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); res.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type'); res.set('Access-Control-Allow-Headers', 'Content-Type');
next(); next();
}); });
// Tiny in-memory token bucket per IP. 120 req/min is plenty for human use // Tiny in-memory token bucket per IP. 120 req/min is plenty for human use
@@ -27,141 +28,193 @@ const buckets = new Map();
const RATE = 120; const RATE = 120;
const WINDOW_MS = 60_000; const WINDOW_MS = 60_000;
router.use((req, res, next) => { router.use((req, res, next) => {
const key = req.ip || 'unknown'; const key = req.ip || 'unknown';
const now = Date.now(); const now = Date.now();
const b = buckets.get(key) || { count: 0, reset: now + WINDOW_MS }; const b = buckets.get(key) || { count: 0, reset: now + WINDOW_MS };
if (now > b.reset) { b.count = 0; b.reset = now + WINDOW_MS; } if (now > b.reset) { b.count = 0; b.reset = now + WINDOW_MS; }
b.count += 1; b.count += 1;
buckets.set(key, b); buckets.set(key, b);
res.set('X-RateLimit-Limit', String(RATE)); res.set('X-RateLimit-Limit', String(RATE));
res.set('X-RateLimit-Remaining', String(Math.max(0, RATE - b.count))); res.set('X-RateLimit-Remaining', String(Math.max(0, RATE - b.count)));
if (b.count > RATE) return res.status(429).json({ error: 'rate limited' }); if (b.count > RATE) return res.status(429).json({ error: 'rate limited' });
next(); next();
}); });
function publicStation(s) { function publicStation(s) {
if (!s) return null; if (!s) return null;
return { return {
uuid: s.uuid, uuid: s.uuid,
name: s.name, name: s.name,
slug: s.slug, slug: s.slug,
homepage: s.homepage, homepage: s.homepage,
country: s.country, country: s.country,
genres: s.genres, genres: s.genres,
description: s.description, description: s.description,
image_url: s.image_url, image_url: s.image_url,
category: s.category, category: s.category,
enabled: s.enabled enabled: s.enabled,
}; up: s.up ?? 0,
down: s.down ?? 0,
plays: s.plays ?? 0,
score: s.score ?? 0
};
} }
function publicStream(s) { function publicStream(s) {
if (!s) return null; if (!s) return null;
return { return {
uuid: s.uuid, uuid: s.uuid,
url: s.url, url: s.url,
format: s.format, format: s.format,
bitrate: s.bitrate, bitrate: s.bitrate,
label: s.label, label: s.label,
priority: s.priority, priority: s.priority,
last_status: s.last_status, last_status: s.last_status,
last_checked_at: s.last_checked_at last_checked_at: s.last_checked_at
}; };
} }
router.get('/health', (_req, res) => { router.get('/health', (_req, res) => {
const stations = getDb().prepare('SELECT COUNT(*) AS n FROM stations WHERE enabled = 1').get().n; const stations = getDb().prepare('SELECT COUNT(*) AS n FROM stations WHERE enabled = 1').get().n;
res.json({ ok: true, stations }); res.json({ ok: true, stations });
}); });
router.get('/categories', (_req, res) => { router.get('/categories', (_req, res) => {
const rows = getDb().prepare(` const rows = getDb().prepare(`
SELECT category AS id, COUNT(*) AS count SELECT category AS id, COUNT(*) AS count
FROM stations FROM stations
WHERE enabled = 1 AND category IS NOT NULL AND category <> '' WHERE enabled = 1 AND category IS NOT NULL AND category <> ''
GROUP BY category GROUP BY category
`).all(); `).all();
const counts = new Map(rows.map((r) => [r.id, r.count])); const counts = new Map(rows.map((r) => [r.id, r.count]));
const meta = loadCategoriesFile(); const meta = loadCategoriesFile();
const seen = new Set(); const seen = new Set();
const out = []; const out = [];
for (const m of meta) { for (const m of meta) {
seen.add(m.id); seen.add(m.id);
out.push({ ...m, count: counts.get(m.id) || 0 }); out.push({ ...m, count: counts.get(m.id) || 0 });
} }
for (const [id, count] of counts) { for (const [id, count] of counts) {
if (seen.has(id)) continue; if (seen.has(id)) continue;
out.push({ id, label: id, icon: '', order: 999, count }); 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))); out.sort((a, b) => (a.order ?? 999) - (b.order ?? 999) || String(a.id).localeCompare(String(b.id)));
res.json(out); res.json(out);
}); });
router.get('/stations', (req, res) => { router.get('/stations', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 200, 1000); const limit = Math.min(Number(req.query.limit) || 200, 1000);
let items = listStations({ let items = listStations({
q: req.query.q || undefined, q: req.query.q || undefined,
category: req.query.category || undefined, category: req.query.category || undefined,
enabled: req.query.all ? null : true enabled: req.query.all ? null : true
}); });
if (req.query.country) { if (req.query.country) {
const c = String(req.query.country).toUpperCase(); const c = String(req.query.country).toUpperCase();
items = items.filter((s) => (s.country || '').toUpperCase() === c); items = items.filter((s) => (s.country || '').toUpperCase() === c);
} }
if (req.query.genre) { if (req.query.genre) {
const g = String(req.query.genre).toLowerCase(); const g = String(req.query.genre).toLowerCase();
items = items.filter((s) => (s.genres || []).some((x) => x.toLowerCase().includes(g))); items = items.filter((s) => (s.genres || []).some((x) => x.toLowerCase().includes(g)));
} }
res.json({ const statsMap = getStatsMap(null);
total: items.length, for (const s of items) {
items: items.slice(0, limit).map(publicStation) const st = statsMap.get(s.id) || { up: 0, down: 0, plays: 0, score: 0 };
}); s.up = st.up; s.down = st.down; s.plays = st.plays; s.score = st.score;
}
sortByMode(items, req.query.sort, statsMap);
res.json({
total: items.length,
items: items.slice(0, limit).map(publicStation)
});
});
// Pick a random enabled station. Optional filters narrow the pool.
// `redirect=stream` issues a 302 to the resolved stream URL — handy for
// `mpv http://host/api/v1/stations/random?redirect=stream`.
router.get('/stations/random', async (req, res) => {
let items = listStations({
category: req.query.category || undefined,
enabled: 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)));
}
if (!items.length) return res.status(404).json({ error: 'no stations match' });
const pick = items[Math.floor(Math.random() * items.length)];
Object.assign(pick, getStationStats(pick.id, null));
if (req.query.redirect === 'stream') {
const streams = getStreamsForStation(pick.id);
if (!streams.length) return res.status(404).json({ error: 'no streams' });
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 resolved = await resolveStream({ url: ordered[0].url, format: ordered[0].format });
res.set('Cache-Control', 'no-store');
res.set('X-Station-Uuid', pick.uuid);
res.set('X-Station-Name', encodeURIComponent(pick.name));
return res.redirect(302, resolved.url);
}
const out = publicStation(pick);
out.streams = getStreamsForStation(pick.id).map(publicStream);
res.set('Cache-Control', 'no-store');
res.json(out);
}); });
router.get('/stations/:uuid', (req, res) => { router.get('/stations/:uuid', (req, res) => {
const s = getStationByUuid(req.params.uuid); const s = getStationByUuid(req.params.uuid);
if (!s) return res.status(404).json({ error: 'not found' }); if (!s) return res.status(404).json({ error: 'not found' });
const out = publicStation(s); Object.assign(s, getStationStats(s.id, null));
out.streams = getStreamsForStation(s.id).map(publicStream); const out = publicStation(s);
res.json(out); out.streams = getStreamsForStation(s.id).map(publicStream);
res.json(out);
}); });
// 302 redirect to the resolved stream URL. Pure convenience for CLI players // 302 redirect to the resolved stream URL. Pure convenience for CLI players
// (`mpv http://host/api/v1/stations/<uuid>/stream`) and smart-home scripts. // (`mpv http://host/api/v1/stations/<uuid>/stream`) and smart-home scripts.
router.get('/stations/:uuid/stream', async (req, res) => { router.get('/stations/:uuid/stream', async (req, res) => {
const s = getStationByUuid(req.params.uuid); const s = getStationByUuid(req.params.uuid);
if (!s) return res.status(404).json({ error: 'station not found' }); if (!s) return res.status(404).json({ error: 'station not found' });
let streams = getStreamsForStation(s.id); let streams = getStreamsForStation(s.id);
if (!streams.length) return res.status(404).json({ error: 'no streams' }); if (!streams.length) return res.status(404).json({ error: 'no streams' });
if (req.query.format) { if (req.query.format) {
const fmt = String(req.query.format).toLowerCase(); const fmt = String(req.query.format).toLowerCase();
const filtered = streams.filter((x) => x.format === fmt); const filtered = streams.filter((x) => x.format === fmt);
if (filtered.length) streams = filtered; if (filtered.length) streams = filtered;
} }
// Prefer streams known to be up; fall back to priority order otherwise. // Prefer streams known to be up; fall back to priority order otherwise.
const ordered = [...streams].sort((a, b) => { const ordered = [...streams].sort((a, b) => {
const au = a.last_status === 'up' ? 0 : 1; const au = a.last_status === 'up' ? 0 : 1;
const bu = b.last_status === 'up' ? 0 : 1; const bu = b.last_status === 'up' ? 0 : 1;
return au - bu || a.priority - b.priority; return au - bu || a.priority - b.priority;
}); });
const pick = ordered[0]; const pick = ordered[0];
const resolved = await resolveStream({ url: pick.url, format: pick.format }); const resolved = await resolveStream({ url: pick.url, format: pick.format });
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
res.redirect(302, resolved.url); res.redirect(302, resolved.url);
}); });
router.get('/stations/:uuid/streams/:streamUuid', async (req, res) => { router.get('/stations/:uuid/streams/:streamUuid', async (req, res) => {
const station = getStationByUuid(req.params.uuid); const station = getStationByUuid(req.params.uuid);
if (!station) return res.status(404).json({ error: 'station not found' }); if (!station) return res.status(404).json({ error: 'station not found' });
const stream = getStreamByUuid(req.params.streamUuid); const stream = getStreamByUuid(req.params.streamUuid);
if (!stream || stream.station_id !== station.id) return res.status(404).json({ error: 'stream not found' }); if (!stream || stream.station_id !== station.id) return res.status(404).json({ error: 'stream not found' });
if (req.query.redirect === '0') { if (req.query.redirect === '0') {
return res.json(publicStream(stream)); return res.json(publicStream(stream));
} }
const resolved = await resolveStream({ url: stream.url, format: stream.format }); const resolved = await resolveStream({ url: stream.url, format: stream.format });
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
res.redirect(302, resolved.url); res.redirect(302, resolved.url);
}); });
// Reject any non-GET method explicitly so the public surface can never be // Reject any non-GET method explicitly so the public surface can never be

View File

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

View File

@@ -0,0 +1,189 @@
// One-shot importer: resolves a curated list of underground / experimental /
// DJ-led stations against Radio-Browser, then writes
// data/seed/stations-underground.json. Re-running is safe: the seeder merges
// by Radio-Browser UUID.
//
// Usage: node server/scripts/import-underground.js
//
// All entries are real DJ-broadcast / community / college / free-form stations
// (no playlist bots), spread across punk, house, jazz, eclectic, underground.
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-underground.json');
const RB = 'https://de1.api.radio-browser.info';
const UA = 'OnlineRadioExplorer/0.1 (+import-underground)';
// Each entry: [searchName, countryCodeOrNull, expectedNameSubstring]
// expectedNameSubstring is a lowercase substring used to filter out wrong-station
// hits when the same name is shared (e.g. two "Skylab Radio"s in different countries).
const PICKS = [
// === UK / IE underground & DJ-led ===
['Foundation FM', 'GB', 'foundation fm'],
['Aaja Radio | Channel 1', 'GB', 'aaja'],
['Aaja Radio | Channel 2', 'GB', 'aaja'],
['Bloop London Radio', 'GB', 'bloop london'],
['Reform Radio', 'GB', 'reform radio'],
['1BTN', 'GB', '1btn'],
['Sub.fm', 'GB', 'sub.fm'],
['Radio Wigwam', 'GB', 'wigwam'],
['Skylab Radio', 'GB', 'skylab'],
// === NL underground ===
['Echobox', 'NL', 'echobox'],
['Operator Radio', 'NL', 'operator'],
// === DE / AT / CH underground ===
['byte.fm', 'DE', 'byte'],
['Radio 80000', 'DE', '80000'],
['FluxFM', 'DE', 'fluxfm'],
['FluxFM - Techno Underground', 'DE', 'techno'],
['Radio Eins', 'DE', 'radio eins'],
['Radio Helsinki 98,5 Mhz', 'FI', 'helsinki'],
['Radio Helsinki', 'AT', 'helsinki'],
['RTS Couleur 3', 'CH', 'couleur 3'],
// === GR / HU / FR / EE underground ===
['Movement', 'GR', 'movement.radio 1'],
['Movement', 'GR', 'movement.radio 2'],
['Tilos Rádió', 'HU', 'tilos'],
['Radio Campus Paris', 'FR', 'radio campus paris'],
// === US college & free-form (the punk-friendly ones) ===
['WMBR 88.1', 'US', 'wmbr'],
['WHRB 95.3', 'US', 'whrb'],
['KALX 90.7FM Berkeley', 'US', 'kalx'],
['WREK 91.1', 'US', 'wrek'],
['WXYC 89.3', 'US', 'wxyc'],
['KZSC 88.1', 'US', 'kzsc'],
['KDVS Davis', 'US', 'kdvs'],
['WPRB 103.3 FM', 'US', 'wprb'],
['WZBC', 'US', 'wzbc'],
['KFJC', null, 'kfjc'],
['KXLU 88.9FM', 'US', 'kxlu'],
['KCSB', 'US', 'kcsb'],
['WLUW', 'US', 'wluw'],
['WUSB 90.1', 'US', 'wusb'],
['BFF.fm', 'US', 'bff.fm'],
// === Punk / shoegaze / noise leaning ===
['DKFM Shoegaze Radio', 'CA', 'dkfm'],
['idobi Anthm', 'US', 'anthm'],
['idobi Howl', 'US', 'howl'],
['Radio Free Brooklyn', 'US', 'radio free brooklyn'],
// === AU community indie ===
['Triple R 102.7', 'AU', 'triple r'],
['PBS 106.7FM', 'AU', 'pbs 106.7'],
['FBi Radio', 'AU', 'fbi radio'],
['2SER 107.3 FM', 'AU', '2ser'],
['4zzz', 'AU', '4zzz'],
['RTRFM', 'AU', 'rtrfm']
];
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, country) {
const params = new URLSearchParams({
name, limit: '8', hidebroken: 'true', order: 'clickcount', reverse: 'true'
});
if (country) params.set('countrycode', country);
const res = await fetch(`${RB}/json/stations/search?${params}`, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error(`RB ${res.status}`);
return res.json();
}
function pickBest(list, target, expectSub) {
if (!list.length) return null;
const sub = (expectSub || '').toLowerCase();
const t = target.toLowerCase().trim();
// 1. exact name match
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === t);
if (exact) return exact;
// 2. expected substring match
if (sub) {
const subMatch = list.find((s) => (s.name || '').toLowerCase().includes(sub));
if (subMatch) return subMatch;
}
// 3. fall back to top hit
return list[0];
}
function toEntry(s) {
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: 'underground',
country: s.countrycode || null,
homepage: s.homepage || null,
genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean).slice(0, 6),
description: null,
image_url: s.favicon || null,
source: 'radiobrowser',
source_ref: s.stationuuid,
streams: [stream]
};
}
async function main() {
const out = [];
const seenUuids = new Set();
let okCount = 0;
let missCount = 0;
let dupCount = 0;
for (const [name, country, expectSub] of PICKS) {
try {
const hits = await rbSearch(name, country);
const pick = pickBest(hits, name, expectSub);
if (!pick) { console.warn(' miss:', name); missCount++; continue; }
if (seenUuids.has(pick.stationuuid)) {
console.warn(' dup :', name, '->', pick.name);
dupCount++;
continue;
}
seenUuids.add(pick.stationuuid);
out.push(toEntry(pick));
console.log(' ok :', name.padEnd(32), '->', pick.name, `(${pick.codec || '?'} ${pick.bitrate || ''})`);
okCount++;
} catch (err) {
console.warn(' err :', name, err.message);
missCount++;
}
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)}`);
console.log(` ok=${okCount} miss=${missCount} dup=${dupCount}`);
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

@@ -7,6 +7,6 @@ const rows = db.prepare(`
ORDER BY (st.last_status = 'up'), s.name ORDER BY (st.last_status = 'up'), s.name
`).all(); `).all();
for (const r of rows) { for (const r of rows) {
const tag = r.last_status === 'up' ? 'OK ' : 'BAD'; const tag = r.last_status === 'up' ? 'OK ' : 'BAD';
console.log(tag, (r.last_status || '').padEnd(14), r.format.padEnd(5), r.name, '->', r.url); console.log(tag, (r.last_status || '').padEnd(14), r.format.padEnd(5), r.name, '->', r.url);
} }

View File

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

View File

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

View File

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

View File

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

125
server/stats.js Normal file
View File

@@ -0,0 +1,125 @@
// Vote + play stats and the ranking algorithm.
//
// Score combines two signals:
// - voteZ = (up - down) / sqrt(up + down + 1) z-like, penalizes small N
// - playLog = log10(plays + 1) gentle popularity boost
// - score = voteZ + 0.5 * playLog
//
// Net effect:
// * A handful of downvotes on an obscure station sinks it hard.
// * One stray upvote on a brand new station barely moves it.
// * Popular stations float up only if they aren't being actively buried.
// * Established + positively-voted stations dominate the top.
import { getDb } from './db/index.js';
export function computeScore({ up = 0, down = 0, plays = 0 } = {}) {
const n = up + down;
const voteZ = n === 0 ? 0 : (up - down) / Math.sqrt(n + 1);
const playLog = Math.log10(plays + 1);
return voteZ + 0.5 * playLog;
}
export function getStationStats(stationId, userId = null) {
const db = getDb();
const v = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS up,
COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS down
FROM station_votes WHERE station_id = ?
`).get(stationId) || { up: 0, down: 0 };
const p = db.prepare('SELECT plays FROM station_plays WHERE station_id = ?').get(stationId);
const plays = p?.plays || 0;
let myVote = 0;
if (userId) {
const r = db.prepare('SELECT value FROM station_votes WHERE user_id = ? AND station_id = ?').get(userId, stationId);
myVote = r?.value || 0;
}
return { up: v.up, down: v.down, plays, myVote, score: computeScore({ up: v.up, down: v.down, plays }) };
}
// Bulk stats for many stations in one query. Returns a Map<station_id, stats>.
export function getStatsMap(userId = null) {
const db = getDb();
const rows = db.prepare(`
SELECT
s.id AS station_id,
COALESCE(v.up, 0) AS up,
COALESCE(v.down, 0) AS down,
COALESCE(p.plays, 0) AS plays
FROM stations s
LEFT JOIN (
SELECT station_id,
SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END) AS up,
SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END) AS down
FROM station_votes GROUP BY station_id
) v ON v.station_id = s.id
LEFT JOIN station_plays p ON p.station_id = s.id
`).all();
const my = new Map();
if (userId) {
for (const r of db.prepare('SELECT station_id, value FROM station_votes WHERE user_id = ?').all(userId)) {
my.set(r.station_id, r.value);
}
}
const out = new Map();
for (const r of rows) {
const myVote = my.get(r.station_id) || 0;
out.set(r.station_id, {
up: r.up, down: r.down, plays: r.plays, myVote,
score: computeScore({ up: r.up, down: r.down, plays: r.plays })
});
}
return out;
}
export function castVote(userId, stationId, value) {
const db = getDb();
if (value === 0 || value == null) {
db.prepare('DELETE FROM station_votes WHERE user_id = ? AND station_id = ?').run(userId, stationId);
} else if (value === 1 || value === -1) {
db.prepare(`
INSERT INTO station_votes (user_id, station_id, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, station_id) DO UPDATE SET value = excluded.value, created_at = CURRENT_TIMESTAMP
`).run(userId, stationId, value);
} else {
throw new Error('vote value must be -1, 0 or 1');
}
return getStationStats(stationId, userId);
}
export function recordPlay(stationId) {
getDb().prepare(`
INSERT INTO station_plays (station_id, plays, last_played_at) VALUES (?, 1, datetime('now'))
ON CONFLICT(station_id) DO UPDATE SET
plays = station_plays.plays + 1,
last_played_at = datetime('now')
`).run(stationId);
}
// Sort helper used by routes. Mutates the array.
export function sortByMode(items, mode, statsMap) {
const s = (id) => statsMap.get(id) || { up: 0, down: 0, plays: 0, score: 0 };
switch (mode) {
case 'hot':
items.sort((a, b) => s(b.id).score - s(a.id).score || a.name.localeCompare(b.name));
break;
case 'top':
items.sort((a, b) => (s(b.id).up - s(b.id).down) - (s(a.id).up - s(a.id).down) || a.name.localeCompare(b.name));
break;
case 'plays':
items.sort((a, b) => s(b.id).plays - s(a.id).plays || a.name.localeCompare(b.name));
break;
case 'controversial':
items.sort((a, b) => {
const A = s(a.id), B = s(b.id);
return Math.min(B.up, B.down) - Math.min(A.up, A.down) || a.name.localeCompare(b.name);
});
break;
case 'name':
default:
items.sort((a, b) => a.name.localeCompare(b.name));
}
return items;
}

View File

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

View File

@@ -11,58 +11,58 @@ const TIMEOUT = 8000;
const UA = 'Mozilla/5.0 OnlineRadioExplorer/0.1'; const UA = 'Mozilla/5.0 OnlineRadioExplorer/0.1';
export function probeStream(rawUrl) { export function probeStream(rawUrl) {
return new Promise((resolve) => { return new Promise((resolve) => {
let url; let url;
try { url = new URL(rawUrl); } catch { return resolve('err-badurl'); } try { url = new URL(rawUrl); } catch { return resolve('err-badurl'); }
const isTls = url.protocol === 'https:'; const isTls = url.protocol === 'https:';
const port = Number(url.port) || (isTls ? 443 : 80); const port = Number(url.port) || (isTls ? 443 : 80);
const path = (url.pathname || '/') + (url.search || ''); const path = (url.pathname || '/') + (url.search || '');
const host = url.hostname; const host = url.hostname;
const opts = { host, port, servername: host }; const opts = { host, port, servername: host };
const connect = isTls ? tls.connect : net.connect; const connect = isTls ? tls.connect : net.connect;
const sock = connect(opts); const sock = connect(opts);
let settled = false; let settled = false;
const finish = (status) => { const finish = (status) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
try { sock.destroy(); } catch {} try { sock.destroy(); } catch { }
resolve(status); resolve(status);
}; };
sock.setTimeout(TIMEOUT); sock.setTimeout(TIMEOUT);
sock.on('timeout', () => finish('err-timeout')); sock.on('timeout', () => finish('err-timeout'));
sock.on('error', () => finish('err-fetch')); sock.on('error', () => finish('err-fetch'));
sock.on('connect', () => { sock.on('connect', () => {
const req = const req =
`GET ${path} HTTP/1.0\r\n` + `GET ${path} HTTP/1.0\r\n` +
`Host: ${host}\r\n` + `Host: ${host}\r\n` +
`User-Agent: ${UA}\r\n` + `User-Agent: ${UA}\r\n` +
`Icy-MetaData: 1\r\n` + `Icy-MetaData: 1\r\n` +
`Accept: */*\r\n` + `Accept: */*\r\n` +
`Connection: close\r\n\r\n`; `Connection: close\r\n\r\n`;
sock.write(req); 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');
});
}); });
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

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

View File

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

View File

@@ -2,23 +2,24 @@ import { defineConfig } from 'vite';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
export default defineConfig({ export default defineConfig({
root: 'web', root: 'web',
publicDir: false, publicDir: false,
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
'/api': 'http://localhost:4173', '/api': 'http://localhost:4173',
'/ws': { target: 'ws://localhost:4173', ws: true } '/ws': { target: 'ws://localhost:4173', ws: true }
}
},
build: {
outDir: '../server/public',
emptyOutDir: true,
rollupOptions: {
input: {
kiosk: resolve(__dirname, 'web/index.html'),
admin: resolve(__dirname, 'web/admin/index.html'),
docs: resolve(__dirname, 'web/docs/index.html')
}
}
} }
},
build: {
outDir: '../server/public',
emptyOutDir: true,
rollupOptions: {
input: {
kiosk: resolve(__dirname, 'web/index.html'),
admin: resolve(__dirname, 'web/admin/index.html')
}
}
}
}); });

View File

@@ -1,13 +1,16 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Admin</title> <title>Radio Admin</title>
<link rel="stylesheet" href="./style.css" /> <link rel="stylesheet" href="./style.css" />
</head> </head>
<body>
<body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="./main.js"></script> <script type="module" src="./main.js"></script>
</body> </body>
</html> </html>

View File

@@ -5,289 +5,313 @@ const app = document.getElementById('app');
const state = { user: null, view: 'stations', stations: [], users: [], system: null, search: '' }; const state = { user: null, view: 'stations', stations: [], users: [], system: null, search: '' };
async function bootstrap() { async function bootstrap() {
try { state.user = await api.get('/api/auth/me'); } try { state.user = await api.get('/api/auth/me'); }
catch { return showLogin(); } catch { return showLogin(); }
if (state.user.role !== 'admin') { if (state.user.role !== 'admin') {
app.innerHTML = `<div class="login"><div><h1>Admin only</h1><p>Signed in as ${state.user.username} (${state.user.role}).</p></div></div>`; app.innerHTML = `<div class="login"><div><h1>Admin only</h1><p>Signed in as ${state.user.username} (${state.user.role}).</p></div></div>`;
return; return;
} }
await refresh(); await refresh();
render(); render();
} }
async function refresh() { async function refresh() {
const tasks = [api.get('/api/stations?all=1')]; const tasks = [api.get('/api/stations?all=1')];
if (state.view === 'users') tasks.push(api.get('/api/auth/users')); if (state.view === 'users') tasks.push(api.get('/api/auth/users'));
if (state.view === 'system') tasks.push(api.get('/api/admin/system')); if (state.view === 'system') tasks.push(api.get('/api/admin/system'));
const [stations, more1, more2] = await Promise.all(tasks); const [stations, more1, more2] = await Promise.all(tasks);
state.stations = stations; state.stations = stations;
if (state.view === 'users') state.users = more1 || []; if (state.view === 'users') state.users = more1 || [];
if (state.view === 'system') state.system = more1 || more2 || null; if (state.view === 'system') state.system = more1 || more2 || null;
} }
function showLogin() { function showLogin() {
clear(app); clear(app);
app.appendChild(el('div', { class: 'login' }, app.appendChild(el('div', { class: 'login' },
el('form', { onSubmit: async (e) => { el('form', {
e.preventDefault(); onSubmit: async (e) => {
const fd = new FormData(e.target); e.preventDefault();
try { const fd = new FormData(e.target);
state.user = await api.post('/api/auth/login', { username: fd.get('username'), password: fd.get('password') }); try {
await bootstrap(); state.user = await api.post('/api/auth/login', { username: fd.get('username'), password: fd.get('password') });
} catch (err) { e.target.querySelector('.err').textContent = err.message; } await bootstrap();
} }, } catch (err) { e.target.querySelector('.err').textContent = err.message; }
el('h1', {}, 'Admin sign in'), }
el('input', { name: 'username', placeholder: 'Username', required: true }), },
el('input', { name: 'password', type: 'password', placeholder: 'Password', required: true }), el('h1', {}, 'Admin sign in'),
el('div', { class: 'err' }), el('input', { name: 'username', placeholder: 'Username', required: true }),
el('button', { class: 'btn primary', type: 'submit' }, 'Sign in') el('input', { name: 'password', type: 'password', placeholder: 'Password', required: true }),
))); el('div', { class: 'err' }),
el('button', { class: 'btn primary', type: 'submit' }, 'Sign in')
)));
} }
function render() { function render() {
clear(app); clear(app);
const side = el('aside', { class: 'side' }, const side = el('aside', { class: 'side' },
el('h1', {}, 'Online Radio Explorer'), el('h1', {}, 'Online Radio Explorer'),
...['stations', 'import', 'users', 'system'].map((v) => ...['stations', 'import', 'users', 'system'].map((v) =>
el('button', { class: `nav ${state.view === v ? 'active' : ''}`, el('button', {
onClick: async () => { state.view = v; await refresh(); render(); } }, label(v))), class: `nav ${state.view === v ? 'active' : ''}`,
el('div', { class: 'me' }, `Signed in as ${state.user.username}`, onClick: async () => { state.view = v; await refresh(); render(); }
el('br'), }, label(v))),
el('a', { href: '#', onClick: async (e) => { e.preventDefault(); await api.post('/api/auth/logout'); location.reload(); } }, 'Sign out')) el('div', { class: 'me' }, `Signed in as ${state.user.username}`,
); el('br'),
const main = el('main', { class: 'main' }); el('a', { href: '#', onClick: async (e) => { e.preventDefault(); await api.post('/api/auth/logout'); location.reload(); } }, 'Sign out'))
if (state.view === 'stations') renderStations(main); );
else if (state.view === 'import') renderImport(main); const main = el('main', { class: 'main' });
else if (state.view === 'users') renderUsers(main); if (state.view === 'stations') renderStations(main);
else if (state.view === 'system') renderSystem(main); else if (state.view === 'import') renderImport(main);
app.appendChild(el('div', { class: 'shell' }, side, main)); else if (state.view === 'users') renderUsers(main);
else if (state.view === 'system') renderSystem(main);
app.appendChild(el('div', { class: 'shell' }, side, main));
} }
function label(v) { function label(v) {
return ({ stations: 'Stations', import: 'Import', users: 'Users', system: 'System' })[v]; return ({ stations: 'Stations', import: 'Import', users: 'Users', system: 'System' })[v];
} }
// ---------- Stations ---------- // ---------- Stations ----------
function renderStations(root) { function renderStations(root) {
root.appendChild(el('div', { class: 'bar' }, root.appendChild(el('div', { class: 'bar' },
el('input', { placeholder: 'Search…', value: state.search, el('input', {
onInput: (e) => { state.search = e.target.value; renderStationsTable(); } }), placeholder: 'Search…', value: state.search,
el('button', { class: 'btn primary', onClick: () => openStationDialog() }, '+ Add station'), onInput: (e) => { state.search = e.target.value; renderStationsTable(); }
el('button', { class: 'btn', onClick: async () => { await api.post('/api/admin/health-check'); alert('Health check finished'); await refresh(); render(); } }, 'Run health check') }),
)); el('button', { class: 'btn primary', onClick: () => openStationDialog() }, '+ Add station'),
const tableWrap = el('div', { id: 'tableWrap' }); el('button', { class: 'btn', onClick: async () => { await api.post('/api/admin/health-check'); alert('Health check finished'); await refresh(); render(); } }, 'Run health check')
root.appendChild(tableWrap); ));
renderStationsTable(); const tableWrap = el('div', { id: 'tableWrap' });
root.appendChild(tableWrap);
renderStationsTable();
} }
function renderStationsTable() { function renderStationsTable() {
const wrap = document.getElementById('tableWrap'); const wrap = document.getElementById('tableWrap');
if (!wrap) return; if (!wrap) return;
clear(wrap); clear(wrap);
const q = state.search.toLowerCase(); const q = state.search.toLowerCase();
const filtered = state.stations.filter((s) => const filtered = state.stations.filter((s) =>
!q || s.name.toLowerCase().includes(q) || (s.country || '').toLowerCase().includes(q) || !q || s.name.toLowerCase().includes(q) || (s.country || '').toLowerCase().includes(q) ||
(s.genres || []).some((g) => g.toLowerCase().includes(q)) (s.genres || []).some((g) => g.toLowerCase().includes(q))
); );
const table = el('table', {}, const table = el('table', {},
el('thead', {}, el('tr', {}, el('thead', {}, el('tr', {},
el('th', {}, 'Name'), el('th', {}, 'Source'), el('th', {}, 'Genres'), el('th', {}, 'Name'), el('th', {}, 'Source'), el('th', {}, 'Genres'),
el('th', {}, 'Country'), el('th', {}, 'Enabled'), el('th', {}, 'Actions'))), el('th', {}, 'Country'), el('th', {}, 'Enabled'), el('th', {}, 'Actions'))),
el('tbody', {}, ...filtered.map((s) => el('tr', {}, el('tbody', {}, ...filtered.map((s) => el('tr', {},
el('td', {}, el('strong', {}, s.name), el('br'), el('small', {}, s.homepage || '')), el('td', {}, el('strong', {}, s.name), el('br'), el('small', {}, s.homepage || '')),
el('td', {}, s.source), el('td', {}, s.source),
el('td', {}, ...(s.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))), el('td', {}, ...(s.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))),
el('td', {}, s.country || ''), el('td', {}, s.country || ''),
el('td', {}, s.enabled ? '✅' : '⛔'), el('td', {}, s.enabled ? '✅' : '⛔'),
el('td', {}, el('td', {},
el('button', { class: 'btn', onClick: () => openStationDialog(s.id) }, 'Edit'), el('button', { class: 'btn', onClick: () => openStationDialog(s.id) }, 'Edit'),
' ', ' ',
el('button', { class: 'btn danger', onClick: async () => { el('button', {
if (confirm(`Delete ${s.name}?`)) { await api.del(`/api/stations/${s.id}`); await refresh(); render(); } class: 'btn danger', onClick: async () => {
} }, 'Delete') if (confirm(`Delete ${s.name}?`)) { await api.del(`/api/stations/${s.id}`); await refresh(); render(); }
) }
))) }, 'Delete')
); )
wrap.appendChild(table); )))
);
wrap.appendChild(table);
} }
async function openStationDialog(id) { async function openStationDialog(id) {
const station = id ? await api.get(`/api/stations/${id}`) : { name: '', genres: [], streams: [], enabled: true }; const station = id ? await api.get(`/api/stations/${id}`) : { name: '', genres: [], streams: [], enabled: true };
const dlg = el('dialog'); const dlg = el('dialog');
const streamsBox = el('div', { class: 'streams' }); const streamsBox = el('div', { class: 'streams' });
function paintStreams() { function paintStreams() {
clear(streamsBox); clear(streamsBox);
streamsBox.appendChild(el('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'Streams')); streamsBox.appendChild(el('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'Streams'));
if (!station.streams?.length) streamsBox.appendChild(el('div', { style: { color: '#6b7280' } }, 'No streams yet.')); if (!station.streams?.length) streamsBox.appendChild(el('div', { style: { color: '#6b7280' } }, 'No streams yet.'));
for (const s of station.streams || []) { for (const s of station.streams || []) {
streamsBox.appendChild(el('div', { class: 'stream-row' }, streamsBox.appendChild(el('div', { class: 'stream-row' },
el('select', { onChange: (e) => s.format = e.target.value }, el('select', { onChange: (e) => s.format = e.target.value },
...['mp3','aac','hls','m3u','pls','ogg','unknown'].map((f) => ...['mp3', 'aac', 'hls', 'm3u', 'pls', 'ogg', 'unknown'].map((f) =>
el('option', { value: f, selected: s.format === f }, f))), el('option', { value: f, selected: s.format === f }, f))),
el('input', { value: s.url, placeholder: 'https://…', onInput: (e) => s.url = e.target.value }), el('input', { value: s.url, placeholder: 'https://…', onInput: (e) => s.url = e.target.value }),
el('input', { type: 'number', placeholder: 'kbps', value: s.bitrate || '', onInput: (e) => s.bitrate = Number(e.target.value) || null }), el('input', { type: 'number', placeholder: 'kbps', value: s.bitrate || '', onInput: (e) => s.bitrate = Number(e.target.value) || null }),
el('input', { value: s.label || '', placeholder: 'Label', onInput: (e) => s.label = e.target.value }), el('input', { value: s.label || '', placeholder: 'Label', onInput: (e) => s.label = e.target.value }),
s.last_status ? el('span', { class: `pill ${s.last_status === 'up' ? 'up' : 'down'}` }, s.last_status) : el('span'), s.last_status ? el('span', { class: `pill ${s.last_status === 'up' ? 'up' : 'down'}` }, s.last_status) : el('span'),
el('button', { class: 'btn danger', type: 'button', onClick: () => { station.streams = station.streams.filter((x) => x !== s); paintStreams(); } }, '×') el('button', { class: 'btn danger', type: 'button', onClick: () => { station.streams = station.streams.filter((x) => x !== s); paintStreams(); } }, '×')
)); ));
}
streamsBox.appendChild(el('button', {
class: 'btn', type: 'button', onClick: () => {
station.streams = [...(station.streams || []), { url: '', format: 'mp3', priority: (station.streams?.length || 0) }];
paintStreams();
}
}, '+ Add stream'));
} }
streamsBox.appendChild(el('button', { class: 'btn', type: 'button', onClick: () => {
station.streams = [...(station.streams || []), { url: '', format: 'mp3', priority: (station.streams?.length || 0) }];
paintStreams();
} }, '+ Add stream'));
}
const form = el('form', { method: 'dialog', onSubmit: async (e) => { const form = el('form', {
e.preventDefault(); method: 'dialog', onSubmit: async (e) => {
const payload = { e.preventDefault();
name: station.name, homepage: station.homepage, country: station.country, const payload = {
genres: station.genres, description: station.description, image_url: station.image_url, name: station.name, homepage: station.homepage, country: station.country,
enabled: station.enabled genres: station.genres, description: station.description, image_url: station.image_url,
}; enabled: station.enabled
if (id) { };
await api.patch(`/api/stations/${id}`, payload); if (id) {
// sync streams: simple approach — delete all & re-add await api.patch(`/api/stations/${id}`, payload);
const fresh = await api.get(`/api/stations/${id}`); // sync streams: simple approach — delete all & re-add
for (const s of fresh.streams || []) await api.del(`/api/stations/${id}/streams/${s.id}`); const fresh = await api.get(`/api/stations/${id}`);
for (const s of station.streams || []) if (s.url) await api.post(`/api/stations/${id}/streams`, s); for (const s of fresh.streams || []) await api.del(`/api/stations/${id}/streams/${s.id}`);
} else { for (const s of station.streams || []) if (s.url) await api.post(`/api/stations/${id}/streams`, s);
payload.streams = (station.streams || []).filter((s) => s.url); } else {
await api.post('/api/stations', payload); payload.streams = (station.streams || []).filter((s) => s.url);
} await api.post('/api/stations', payload);
dlg.close(); }
await refresh(); dlg.close();
render(); await refresh();
} }, render();
el('h2', {}, id ? 'Edit station' : 'Add station'), }
el('div', { class: 'row' }, el('label', {}, 'Name'), el('input', { value: station.name, onInput: (e) => station.name = e.target.value, required: true })), },
el('div', { class: 'row' }, el('label', {}, 'Homepage'), el('input', { value: station.homepage || '', onInput: (e) => station.homepage = e.target.value })), el('h2', {}, id ? 'Edit station' : 'Add station'),
el('div', { class: 'row' }, el('label', {}, 'Country'), el('input', { value: station.country || '', maxlength: 4, onInput: (e) => station.country = e.target.value })), el('div', { class: 'row' }, el('label', {}, 'Name'), el('input', { value: station.name, onInput: (e) => station.name = e.target.value, required: true })),
el('div', { class: 'row' }, el('label', {}, 'Genres'), el('input', { value: (station.genres || []).join(', '), onInput: (e) => station.genres = e.target.value.split(',').map((s) => s.trim()).filter(Boolean) })), el('div', { class: 'row' }, el('label', {}, 'Homepage'), el('input', { value: station.homepage || '', onInput: (e) => station.homepage = e.target.value })),
el('div', { class: 'row' }, el('label', {}, 'Image URL'),el('input', { value: station.image_url || '', onInput: (e) => station.image_url = e.target.value })), el('div', { class: 'row' }, el('label', {}, 'Country'), el('input', { value: station.country || '', maxlength: 4, onInput: (e) => station.country = e.target.value })),
el('div', { class: 'row col' }, el('textarea', { rows: 2, placeholder: 'Description', onInput: (e) => station.description = e.target.value }, station.description || '')), el('div', { class: 'row' }, el('label', {}, 'Genres'), el('input', { value: (station.genres || []).join(', '), onInput: (e) => station.genres = e.target.value.split(',').map((s) => s.trim()).filter(Boolean) })),
el('div', { class: 'row' }, el('label', {}, 'Enabled'), el('input', { type: 'checkbox', checked: station.enabled, onChange: (e) => station.enabled = e.target.checked })), el('div', { class: 'row' }, el('label', {}, 'Image URL'), el('input', { value: station.image_url || '', onInput: (e) => station.image_url = e.target.value })),
streamsBox, el('div', { class: 'row col' }, el('textarea', { rows: 2, placeholder: 'Description', onInput: (e) => station.description = e.target.value }, station.description || '')),
el('div', { class: 'actions' }, el('div', { class: 'row' }, el('label', {}, 'Enabled'), el('input', { type: 'checkbox', checked: station.enabled, onChange: (e) => station.enabled = e.target.checked })),
el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'), streamsBox,
el('button', { class: 'btn primary', type: 'submit' }, 'Save')) el('div', { class: 'actions' },
); el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
paintStreams(); el('button', { class: 'btn primary', type: 'submit' }, 'Save'))
dlg.appendChild(form); );
document.body.appendChild(dlg); paintStreams();
dlg.showModal(); dlg.appendChild(form);
dlg.addEventListener('close', () => dlg.remove()); document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
} }
// ---------- Import (Radio-Browser) ---------- // ---------- Import (Radio-Browser) ----------
function renderImport(root) { function renderImport(root) {
let results = []; let results = [];
const resultsBox = el('div'); const resultsBox = el('div');
root.appendChild(el('h2', {}, 'Import from Radio-Browser')); root.appendChild(el('h2', {}, 'Import from Radio-Browser'));
root.appendChild(el('div', { class: 'bar' }, root.appendChild(el('div', { class: 'bar' },
el('input', { id: 'rbq', placeholder: 'Search by name…' }), el('input', { id: 'rbq', placeholder: 'Search by name…' }),
el('input', { id: 'rbcountry', placeholder: 'Country (e.g. NL)', style: { minWidth: '120px' } }), el('input', { id: 'rbcountry', placeholder: 'Country (e.g. NL)', style: { minWidth: '120px' } }),
el('input', { id: 'rbtag', placeholder: 'Tag/genre' }), el('input', { id: 'rbtag', placeholder: 'Tag/genre' }),
el('button', { class: 'btn primary', onClick: async () => { el('button', {
const params = new URLSearchParams({ class: 'btn primary', onClick: async () => {
q: document.getElementById('rbq').value, const params = new URLSearchParams({
country: document.getElementById('rbcountry').value, q: document.getElementById('rbq').value,
tag: document.getElementById('rbtag').value country: document.getElementById('rbcountry').value,
}); tag: document.getElementById('rbtag').value
results = await api.get(`/api/stations/sources/radiobrowser/search?${params}`); });
paint(); results = await api.get(`/api/stations/sources/radiobrowser/search?${params}`);
} }, 'Search') paint();
)); }
root.appendChild(resultsBox); }, 'Search')
function paint() { ));
clear(resultsBox); root.appendChild(resultsBox);
if (!results.length) { resultsBox.appendChild(el('p', {}, 'No results yet.')); return; } function paint() {
const table = el('table', {}, clear(resultsBox);
el('thead', {}, el('tr', {}, el('th', {}, 'Name'), el('th', {}, 'Country'), el('th', {}, 'Tags'), el('th', {}, 'Stream'), el('th', {}, ''))), if (!results.length) { resultsBox.appendChild(el('p', {}, 'No results yet.')); return; }
el('tbody', {}, ...results.map((s) => el('tr', {}, const table = el('table', {},
el('td', {}, s.name), el('thead', {}, el('tr', {}, el('th', {}, 'Name'), el('th', {}, 'Country'), el('th', {}, 'Tags'), el('th', {}, 'Stream'), el('th', {}, ''))),
el('td', {}, s.country || ''), el('tbody', {}, ...results.map((s) => el('tr', {},
el('td', {}, ...(s.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))), el('td', {}, s.name),
el('td', {}, el('small', {}, (s.streams[0]?.format || '') + ' ' + (s.streams[0]?.bitrate || ''))), el('td', {}, s.country || ''),
el('td', {}, el('button', { class: 'btn primary', onClick: async () => { el('td', {}, ...(s.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))),
await api.post('/api/stations/sources/radiobrowser/import', s); el('td', {}, el('small', {}, (s.streams[0]?.format || '') + ' ' + (s.streams[0]?.bitrate || ''))),
alert(`Imported ${s.name}`); el('td', {}, el('button', {
} }, 'Import')) class: 'btn primary', onClick: async () => {
))) await api.post('/api/stations/sources/radiobrowser/import', s);
); alert(`Imported ${s.name}`);
resultsBox.appendChild(table); }
} }, 'Import'))
)))
);
resultsBox.appendChild(table);
}
} }
// ---------- Users ---------- // ---------- Users ----------
function renderUsers(root) { function renderUsers(root) {
root.appendChild(el('div', { class: 'bar' }, root.appendChild(el('div', { class: 'bar' },
el('h2', { style: { margin: 0, flex: 1 } }, 'Users'), el('h2', { style: { margin: 0, flex: 1 } }, 'Users'),
el('button', { class: 'btn primary', onClick: openUserDialog }, '+ Add user') el('button', { class: 'btn primary', onClick: openUserDialog }, '+ Add user')
)); ));
root.appendChild(el('table', {}, root.appendChild(el('table', {},
el('thead', {}, el('tr', {}, el('th', {}, 'Username'), el('th', {}, 'Role'), el('th', {}, 'Created'), el('th', {}, ''))), el('thead', {}, el('tr', {}, el('th', {}, 'Username'), el('th', {}, 'Role'), el('th', {}, 'Created'), el('th', {}, ''))),
el('tbody', {}, ...state.users.map((u) => el('tr', {}, el('tbody', {}, ...state.users.map((u) => el('tr', {},
el('td', {}, u.username), el('td', {}, u.username),
el('td', {}, u.role), el('td', {}, u.role),
el('td', {}, u.created_at), el('td', {}, u.created_at),
el('td', {}, el('td', {},
el('button', { class: 'btn', onClick: async () => { el('button', {
const pw = prompt(`New password for ${u.username}:`); class: 'btn', onClick: async () => {
if (pw) { await api.patch(`/api/auth/users/${u.id}`, { password: pw }); alert('Updated'); } const pw = prompt(`New password for ${u.username}:`);
} }, 'Reset PW'), if (pw) { await api.patch(`/api/auth/users/${u.id}`, { password: pw }); alert('Updated'); }
' ', }
el('button', { class: 'btn', onClick: async () => { }, 'Reset PW'),
const r = u.role === 'admin' ? 'user' : 'admin'; ' ',
await api.patch(`/api/auth/users/${u.id}`, { role: r }); el('button', {
await refresh(); render(); class: 'btn', onClick: async () => {
} }, 'Toggle role'), const r = u.role === 'admin' ? 'user' : 'admin';
' ', await api.patch(`/api/auth/users/${u.id}`, { role: r });
u.id !== state.user.id ? el('button', { class: 'btn danger', onClick: async () => { await refresh(); render();
if (confirm(`Delete ${u.username}?`)) { await api.del(`/api/auth/users/${u.id}`); await refresh(); render(); } }
} }, 'Delete') : null }, 'Toggle role'),
) ' ',
))) u.id !== state.user.id ? el('button', {
)); class: 'btn danger', onClick: async () => {
if (confirm(`Delete ${u.username}?`)) { await api.del(`/api/auth/users/${u.id}`); await refresh(); render(); }
}
}, 'Delete') : null
)
)))
));
} }
function openUserDialog() { function openUserDialog() {
const dlg = el('dialog'); const dlg = el('dialog');
dlg.appendChild(el('form', { method: 'dialog', onSubmit: async (e) => { dlg.appendChild(el('form', {
e.preventDefault(); method: 'dialog', onSubmit: async (e) => {
const fd = new FormData(e.target); e.preventDefault();
await api.post('/api/auth/users', { const fd = new FormData(e.target);
username: fd.get('username'), password: fd.get('password'), role: fd.get('role') await api.post('/api/auth/users', {
}); username: fd.get('username'), password: fd.get('password'), role: fd.get('role')
dlg.close(); });
await refresh(); render(); dlg.close();
} }, await refresh(); render();
el('h2', {}, 'New user'), }
el('div', { class: 'row' }, el('label', {}, 'Username'), el('input', { name: 'username', required: true })), },
el('div', { class: 'row' }, el('label', {}, 'Password'), el('input', { name: 'password', type: 'password', required: true })), el('h2', {}, 'New user'),
el('div', { class: 'row' }, el('label', {}, 'Role'), el('div', { class: 'row' }, el('label', {}, 'Username'), el('input', { name: 'username', required: true })),
el('select', { name: 'role' }, el('option', { value: 'user' }, 'user'), el('option', { value: 'admin' }, 'admin'))), el('div', { class: 'row' }, el('label', {}, 'Password'), el('input', { name: 'password', type: 'password', required: true })),
el('div', { class: 'actions' }, el('div', { class: 'row' }, el('label', {}, 'Role'),
el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'), el('select', { name: 'role' }, el('option', { value: 'user' }, 'user'), el('option', { value: 'admin' }, 'admin'))),
el('button', { class: 'btn primary', type: 'submit' }, 'Create')) el('div', { class: 'actions' },
)); el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
document.body.appendChild(dlg); el('button', { class: 'btn primary', type: 'submit' }, 'Create'))
dlg.showModal(); ));
dlg.addEventListener('close', () => dlg.remove()); document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
} }
// ---------- System ---------- // ---------- System ----------
function renderSystem(root) { function renderSystem(root) {
const s = state.system || {}; const s = state.system || {};
root.appendChild(el('h2', {}, 'System')); root.appendChild(el('h2', {}, 'System'));
root.appendChild(el('div', { class: 'system-grid' }, root.appendChild(el('div', { class: 'system-grid' },
stat('Stations', s.stations), stat('Streams', s.streams), stat('Users', s.users), stat('Stations', s.stations), stat('Streams', s.streams), stat('Users', s.users),
stat('Favorites', s.favorites), stat('Node', s.node), stat('Uptime (s)', s.uptime_s) stat('Favorites', s.favorites), stat('Node', s.node), stat('Uptime (s)', s.uptime_s)
)); ));
root.appendChild(el('div', { class: 'bar', style: { marginTop: '16px' } }, root.appendChild(el('div', { class: 'bar', style: { marginTop: '16px' } },
el('button', { class: 'btn', onClick: async () => { await api.post('/api/admin/health-check'); alert('Health check finished'); await refresh(); render(); } }, 'Run health check'), el('button', { class: 'btn', onClick: async () => { await api.post('/api/admin/health-check'); alert('Health check finished'); await refresh(); render(); } }, 'Run health check'),
el('button', { class: 'btn', onClick: async () => { const r = await api.post('/api/admin/reseed'); alert(JSON.stringify(r)); } }, 'Reseed (if empty)') el('button', { class: 'btn', onClick: async () => { const r = await api.post('/api/admin/reseed'); alert(JSON.stringify(r)); } }, 'Reseed (if empty)')
)); ));
} }
function stat(k, v) { return el('div', { class: 'stat' }, el('div', { class: 'k' }, k), el('div', { class: 'v' }, v ?? '—')); } function stat(k, v) { return el('div', { class: 'stat' }, el('div', { class: 'k' }, k), el('div', { class: 'v' }, v ?? '—')); }

23
web/docs/index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>oradio · API reference</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<header class="docs-header">
<div class="docs-header-inner">
<a href="/" class="back">← Kiosk</a>
<h1>oradio API</h1>
<span class="base" id="base"></span>
</div>
</header>
<main id="app"></main>
<script type="module" src="./main.js"></script>
</body>
</html>

241
web/docs/main.js Normal file
View File

@@ -0,0 +1,241 @@
// Minimal, dependency-free API reference page for oradio.
// Lists every public endpoint, lets the user fire a live request and inspect
// the response. Intended as a reference companion to the kiosk.
const BASE = `${location.origin}/api/v1`;
const INTERNAL = `${location.origin}/api`;
document.getElementById('base').textContent = BASE;
const endpoints = [
{
group: 'Public (v1)',
items: [
{
id: 'health',
method: 'GET',
path: '/health',
summary: 'Service heartbeat plus enabled-station count.',
tryable: true
},
{
id: 'categories',
method: 'GET',
path: '/categories',
summary: 'All categories with their station counts.',
tryable: true
},
{
id: 'stations-list',
method: 'GET',
path: '/stations',
summary: 'Paginated station list. Filterable and sortable.',
params: [
{ name: 'q', desc: 'Substring filter on name / genres / country.' },
{ name: 'category', desc: 'Category id (see /categories).' },
{ name: 'country', desc: 'ISO country code, case-insensitive.' },
{ name: 'genre', desc: 'Substring match against any genre.' },
{ name: 'sort', desc: 'hot | top | plays | controversial | name (default: name).' },
{ name: 'limit', desc: 'Max items returned (default 200, cap 1000).' }
],
tryable: true,
tryQuery: 'limit=3&sort=hot'
},
{
id: 'random',
method: 'GET',
path: '/stations/random',
summary: 'Pick one random enabled station. Same filters as /stations. Pass redirect=stream for a 302 to the resolved audio URL.',
params: [
{ name: 'category', desc: 'Restrict pool to a category.' },
{ name: 'country', desc: 'Restrict pool to a country.' },
{ name: 'genre', desc: 'Restrict pool by genre substring.' },
{ name: 'redirect', desc: 'Set to "stream" to 302-redirect to the resolved stream URL.' }
],
tryable: true,
examples: [
`mpv ${BASE}/stations/random?redirect=stream`,
`curl -sLI "${BASE}/stations/random?redirect=stream" | grep -i location`
]
},
{
id: 'station',
method: 'GET',
path: '/stations/{uuid}',
summary: 'Full detail for one station, including its streams.',
params: [{ name: 'uuid', desc: 'Station UUID (see list response).' }]
},
{
id: 'station-stream',
method: 'GET',
path: '/stations/{uuid}/stream',
summary: '302-redirect to the resolved stream URL. Picks the highest-priority stream that was last seen up.',
params: [
{ name: 'uuid', desc: 'Station UUID.' },
{ name: 'format', desc: 'Optional preferred format (mp3, aac, ogg, hls).' }
]
},
{
id: 'stream-by-uuid',
method: 'GET',
path: '/stations/{uuid}/streams/{streamUuid}',
summary: 'Resolve and 302 to a specific stream. Pass redirect=0 to return JSON metadata instead.',
params: [
{ name: 'uuid', desc: 'Station UUID.' },
{ name: 'streamUuid', desc: 'Stream UUID.' },
{ name: 'redirect', desc: 'Set to "0" to return JSON instead of redirecting.' }
]
}
]
},
{
group: 'Authenticated (cookie session)',
items: [
{
id: 'me',
method: 'GET',
path: '/auth/me',
base: INTERNAL,
summary: 'Current signed-in user, or 401.',
tryable: true
},
{
id: 'favorites',
method: 'GET',
path: '/me/favorites',
base: INTERNAL,
summary: 'Your favorites, ordered.',
tryable: true
},
{
id: 'favorites-random',
method: 'GET',
path: '/me/favorites/random',
base: INTERNAL,
summary: 'One random favorite — used by the kiosk dice button in "favorites" mode.',
tryable: true
},
{
id: 'history',
method: 'GET',
path: '/me/history',
base: INTERNAL,
summary: 'Recent play history (last 50).',
tryable: true
}
]
},
{
group: 'Rate limit',
items: [
{
id: 'rate',
method: 'INFO',
path: '120 req / minute / IP',
summary: 'Public /api/v1 endpoints share a per-IP token bucket. Headers X-RateLimit-Limit and X-RateLimit-Remaining tell you where you stand.'
}
]
}
];
const app = document.getElementById('app');
function el(tag, attrs, ...children) {
const n = document.createElement(tag);
if (attrs) {
for (const [k, v] of Object.entries(attrs)) {
if (v == null || v === false) continue;
if (k === 'class') n.className = v;
else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2).toLowerCase(), v);
else n.setAttribute(k, v);
}
}
for (const c of children) {
if (c == null || c === false) continue;
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return n;
}
function methodChip(m) {
return el('span', { class: `m m-${m.toLowerCase()}` }, m);
}
function renderEndpoint(ep) {
const base = ep.base || BASE;
const fullUrl = `${base}${ep.path}`;
const card = el('article', { class: 'ep', id: ep.id });
card.appendChild(el('header', { class: 'ep-head' },
methodChip(ep.method),
el('code', { class: 'ep-path' }, fullUrl)
));
card.appendChild(el('p', { class: 'ep-sum' }, ep.summary));
if (ep.params?.length) {
const tbl = el('table', { class: 'params' },
el('thead', {}, el('tr', {}, el('th', {}, 'Parameter'), el('th', {}, 'Description'))),
el('tbody', {}, ...ep.params.map((p) =>
el('tr', {}, el('td', {}, el('code', {}, p.name)), el('td', {}, p.desc))
))
);
card.appendChild(tbl);
}
if (ep.examples?.length) {
card.appendChild(el('div', { class: 'examples' },
el('div', { class: 'examples-h' }, 'Examples'),
...ep.examples.map((e) => el('pre', {}, el('code', {}, e)))
));
}
if (ep.tryable && ep.method === 'GET') {
const out = el('pre', { class: 'try-out' }, 'Click "Try it" to send a live request.');
const queryInput = el('input', {
class: 'try-q',
type: 'text',
placeholder: '?key=value (optional)',
value: ep.tryQuery ? `?${ep.tryQuery}` : ''
});
const button = el('button', {
class: 'try-btn',
onClick: async () => {
button.disabled = true;
button.textContent = '…';
let q = queryInput.value.trim();
if (q && !q.startsWith('?')) q = `?${q}`;
const url = `${fullUrl}${q}`;
const t0 = performance.now();
try {
const res = await fetch(url, { credentials: 'same-origin', redirect: 'manual' });
const ms = Math.round(performance.now() - t0);
let body;
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
body = `(redirect — open in new tab to follow)`;
} else {
const ct = res.headers.get('content-type') || '';
body = ct.includes('json')
? JSON.stringify(await res.json(), null, 2)
: (await res.text()).slice(0, 4000);
}
out.textContent = `${res.status} ${res.statusText || ''} · ${ms} ms\n${url}\n\n${body}`;
} catch (err) {
out.textContent = `error: ${err.message || err}\n${url}`;
} finally {
button.disabled = false;
button.textContent = 'Try it';
}
}
}, 'Try it');
const openLink = el('a', { class: 'try-open', target: '_blank', rel: 'noopener', href: fullUrl }, 'Open ↗');
card.appendChild(el('div', { class: 'try' },
el('div', { class: 'try-row' }, queryInput, button, openLink),
out
));
}
return card;
}
for (const group of endpoints) {
app.appendChild(el('h2', { class: 'group' }, group.group));
for (const ep of group.items) app.appendChild(renderEndpoint(ep));
}

171
web/docs/style.css Normal file
View File

@@ -0,0 +1,171 @@
:root {
--bg-0: #07080b;
--bg-1: #0e1116;
--bg-2: #161a22;
--bg-3: #1f242e;
--line: #262b36;
--fg: #e9ecf2;
--muted: #8a90a0;
--muted-2: #5d6373;
--accent: #ff7a3d;
--accent-2: #ffb37a;
--good: #4ec9a6;
--bad: #ec6a6a;
--info: #6ab7ff;
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg-0); color: var(--fg); }
a { color: var(--accent-2); text-decoration: none; }
a:hover { text-decoration: underline; }
.docs-header {
position: sticky; top: 0; z-index: 10;
background: rgba(7, 8, 11, 0.92);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(8px);
}
.docs-header-inner {
max-width: 980px; margin: 0 auto;
padding: 14px 20px;
display: flex; align-items: center; gap: 16px;
}
.docs-header h1 {
margin: 0; font-size: 18px; letter-spacing: -0.01em;
}
.docs-header .back {
color: var(--muted); font-size: 13px;
padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px;
}
.docs-header .back:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; }
.docs-header .base {
margin-left: auto;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px; color: var(--muted);
padding: 4px 10px; background: var(--bg-2);
border: 1px solid var(--line); border-radius: 8px;
}
#app {
max-width: 980px; margin: 0 auto;
padding: 24px 20px 80px;
}
h2.group {
margin: 32px 0 12px;
font-size: 13px; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--muted);
}
h2.group:first-child { margin-top: 0; }
.ep {
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: 12px;
padding: 16px;
margin-bottom: 14px;
}
.ep-head {
display: flex; align-items: center; gap: 10px;
flex-wrap: wrap;
}
.ep-path {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 13px; color: var(--fg);
background: var(--bg-2);
padding: 4px 10px; border-radius: 6px;
border: 1px solid var(--line);
overflow-wrap: anywhere;
}
.ep-sum {
margin: 10px 0 0; color: var(--muted);
font-size: 14px; line-height: 1.5;
}
.m {
display: inline-block;
font-size: 11px; font-weight: 800; letter-spacing: 0.06em;
padding: 4px 8px; border-radius: 6px;
color: #07080b;
}
.m-get { background: var(--good); }
.m-post { background: var(--accent); }
.m-put { background: #d9b14a; }
.m-delete { background: var(--bad); color: #fff; }
.m-info { background: var(--info); }
.params {
width: 100%; border-collapse: collapse;
margin-top: 14px;
font-size: 13px;
}
.params th {
text-align: left; font-weight: 600; color: var(--muted);
text-transform: uppercase; font-size: 11px; letter-spacing: 0.06em;
padding: 8px 10px; border-bottom: 1px solid var(--line);
}
.params td {
padding: 8px 10px; border-bottom: 1px solid var(--line);
color: var(--fg);
}
.params td:first-child { width: 160px; }
.params code {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px; color: var(--accent-2);
background: var(--bg-2);
padding: 2px 6px; border-radius: 4px;
}
.examples { margin-top: 14px; }
.examples-h {
font-size: 11px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 6px;
}
.examples pre {
margin: 0 0 8px; padding: 10px 12px;
background: var(--bg-0); border: 1px solid var(--line);
border-radius: 8px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12.5px; color: var(--accent-2);
overflow-x: auto;
}
.try { margin-top: 14px; }
.try-row {
display: flex; gap: 8px; align-items: center;
margin-bottom: 8px;
}
.try-q {
flex: 1;
background: var(--bg-2); color: var(--fg);
border: 1px solid var(--line); border-radius: 8px;
padding: 8px 12px; font-size: 13px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
outline: none;
}
.try-q:focus { border-color: var(--accent); }
.try-btn {
background: var(--accent); color: #1a0a00;
border: 0; border-radius: 8px;
padding: 8px 16px; font-size: 13px; font-weight: 700;
cursor: pointer;
font-family: inherit;
}
.try-btn:hover { background: #ff8a55; }
.try-btn:disabled { opacity: 0.6; cursor: default; }
.try-open {
color: var(--muted); font-size: 12px;
padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px;
}
.try-open:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; }
.try-out {
margin: 0; padding: 12px;
background: var(--bg-0); border: 1px solid var(--line);
border-radius: 8px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px; color: var(--fg);
max-height: 320px; overflow: auto;
white-space: pre-wrap; word-break: break-word;
}

View File

@@ -1,13 +1,16 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" /> <meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Radio Kiosk</title> <title>Radio Kiosk</title>
<link rel="stylesheet" href="./style.css" /> <link rel="stylesheet" href="./style.css" />
</head> </head>
<body class="kiosk">
<body class="kiosk">
<div id="app"></div> <div id="app"></div>
<script type="module" src="./main.js"></script> <script type="module" src="./main.js"></script>
</body> </body>
</html> </html>

View File

@@ -13,7 +13,9 @@ const state = {
favorites: [], favorites: [],
history: [], history: [],
query: '', query: '',
player: { stationId: null, stationName: null, genres: [], playing: false, loading: false, volume: 0.7 } sort: 'hot', // hot | top | plays | name | controversial — applied in Browse
randomMode: localStorage.getItem('oradio.randomMode') === 'favorites' ? 'favorites' : 'all',
player: { stationId: null, stationName: null, genres: [], playing: false, loading: false, volume: 0.7, votes: null }
}; };
const player = new Player({ const player = new Player({
@@ -39,7 +41,7 @@ async function bootstrap() {
async function refreshAll() { async function refreshAll() {
const [stations, favs, history, categories] = await Promise.all([ const [stations, favs, history, categories] = await Promise.all([
api.get('/api/stations'), api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`),
api.get('/api/me/favorites').catch(() => []), api.get('/api/me/favorites').catch(() => []),
api.get('/api/me/history').catch(() => []), api.get('/api/me/history').catch(() => []),
api.get('/api/v1/categories').catch(() => []) api.get('/api/v1/categories').catch(() => [])
@@ -50,11 +52,15 @@ async function refreshAll() {
state.categories = categories; state.categories = categories;
} }
async function refreshStations() {
state.stations = await api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`);
}
function handleWs(msg) { function handleWs(msg) {
if (msg.type === 'command') { if (msg.type === 'command') {
if (msg.action === 'play' && msg.stationId) { if (msg.action === 'play' && msg.stationId) {
const st = state.stations.find((s) => s.id === msg.stationId); const st = state.stations.find((s) => s.id === msg.stationId);
if (st) player.play(st); if (st) playStation(st);
} else if (msg.action === 'pause') player.togglePause(); } else if (msg.action === 'pause') player.togglePause();
else if (msg.action === 'volume') player.setVolume(msg.value); else if (msg.action === 'volume') player.setVolume(msg.value);
else if (msg.action === 'stop') player.stop(); else if (msg.action === 'stop') player.stop();
@@ -99,6 +105,7 @@ function render() {
const p = state.player; const p = state.player;
const favIds = new Set(state.favorites.map((f) => f.id)); const favIds = new Set(state.favorites.map((f) => f.id));
const v = p.votes; // { up, down, plays, myVote, score } or null
const now = el('section', { class: 'now' }, const now = el('section', { class: 'now' },
el('div', { class: 'meta' }, el('div', { class: 'meta' },
@@ -108,10 +115,26 @@ function render() {
el('div', { class: 'tags' }, ...(p.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))) el('div', { class: 'tags' }, ...(p.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g)))
), ),
el('div', { class: 'controls' }, el('div', { class: 'controls' },
el('div', { class: 'vote-group', title: 'Vote on current station' },
el('button', {
class: `vote up ${v?.myVote === 1 ? 'on' : ''}`,
disabled: !p.stationId,
title: 'Upvote',
onClick: () => votePlayer(1)
}, el('span', { class: 'vote-icon' }, '▲'),
el('span', { class: 'vote-count' }, String(v?.up ?? 0))),
el('button', {
class: `vote down ${v?.myVote === -1 ? 'on' : ''}`,
disabled: !p.stationId,
title: 'Downvote',
onClick: () => votePlayer(-1)
}, el('span', { class: 'vote-icon' }, '▼'),
el('span', { class: 'vote-count' }, String(v?.down ?? 0)))
),
el('button', { el('button', {
class: `btn-play ${p.loading ? 'loading' : ''}`, class: `btn-play ${p.loading ? 'loading' : ''}`,
title: p.playing ? 'Pause' : 'Play', title: p.playing ? 'Pause' : 'Play',
onClick: () => p.stationId ? player.togglePause() : (state.favorites[0] && player.play(state.favorites[0])) onClick: () => p.stationId ? player.togglePause() : (state.favorites[0] && playStation(state.favorites[0]))
}, p.playing ? '❚❚' : '▶'), }, p.playing ? '❚❚' : '▶'),
el('button', { el('button', {
class: 'btn-stop', class: 'btn-stop',
@@ -143,10 +166,36 @@ function render() {
) )
), ),
el('div', { class: 'header-tools' }, el('div', { class: 'header-tools' },
state.tab === 'browse'
? el('select', {
class: 'sort',
title: 'Sort browse list',
onChange: (e) => { state.sort = e.target.value; savedGridScroll = 0; refreshStations().then(render); }
},
el('option', { value: 'hot', selected: state.sort === 'hot' }, '🔥 Hot (smart)'),
el('option', { value: 'top', selected: state.sort === 'top' }, '▲ Top voted'),
el('option', { value: 'plays', selected: state.sort === 'plays' }, '▶ Most played'),
el('option', { value: 'controversial', selected: state.sort === 'controversial' }, '⚡ Controversial'),
el('option', { value: 'name', selected: state.sort === 'name' }, 'A → Z')
)
: null,
el('input', { el('input', {
class: 'search', type: 'search', placeholder: 'Search…', value: state.query, class: 'search', type: 'search', placeholder: 'Search…', value: state.query,
onInput: (e) => { state.query = e.target.value; renderGrid(); } onInput: (e) => { state.query = e.target.value; renderGrid(); }
}), }),
el('button', {
class: 'btn-random',
title: `Play random station (mode: ${state.randomMode}). Right-click to switch mode.`,
onClick: playRandom,
onContextMenu: (e) => { e.preventDefault(); toggleRandomMode(); }
},
el('span', { class: 'rand-icon' }, '🎲'),
el('span', { class: 'rand-mode' }, state.randomMode === 'favorites' ? '★' : 'All')
),
el('a', {
class: 'btn-docs', href: '/docs/', target: '_blank', rel: 'noopener',
title: 'Open API reference'
}, 'API'),
isAdmin ? el('button', { class: 'btn-add', title: 'Add station', onClick: openAddStation }, '+') : null isAdmin ? el('button', { class: 'btn-add', title: 'Add station', onClick: openAddStation }, '+') : null
) )
); );
@@ -222,11 +271,14 @@ function paintGrid(grid, favIds) {
} }
const p = state.player; const p = state.player;
for (const s of items) { for (const s of items) {
const score = typeof s.score === 'number' ? s.score : 0;
const net = (s.up ?? 0) - (s.down ?? 0);
const badgeClass = net > 0 ? 'pos' : net < 0 ? 'neg' : 'neu';
const card = el('div', { const card = el('div', {
class: `card ${p.stationId === s.id ? 'playing' : ''}`, class: `card ${p.stationId === s.id ? 'playing' : ''}`,
role: 'button', role: 'button',
tabindex: 0, tabindex: 0,
onClick: () => { player.play(s); recordHistory(s.id); }, onClick: () => playStation(s),
onContextMenu: (e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, s); } onContextMenu: (e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, s); }
}, },
el('div', { class: 'art' }, el('div', { class: 'art' },
@@ -249,6 +301,9 @@ function paintGrid(grid, favIds) {
el('div', { class: 'g' }, el('div', { class: 'g' },
(s.genres || []).slice(0, 3).join(' · ') || (s.country || '—')) (s.genres || []).slice(0, 3).join(' · ') || (s.country || '—'))
), ),
el('div', { class: `score-badge ${badgeClass}`, title: `${s.up ?? 0} · ▼${s.down ?? 0} · ▶${s.plays ?? 0} · score ${score.toFixed(2)}` },
net > 0 ? `+${net}` : String(net)
),
el('button', { el('button', {
class: `fav ${favIds.has(s.id) ? 'on' : ''}`, class: `fav ${favIds.has(s.id) ? 'on' : ''}`,
title: favIds.has(s.id) ? 'Remove favorite' : 'Add favorite', title: favIds.has(s.id) ? 'Remove favorite' : 'Add favorite',
@@ -276,35 +331,135 @@ async function toggleFavorite(station) {
render(); render();
} }
function toggleRandomMode() {
state.randomMode = state.randomMode === 'favorites' ? 'all' : 'favorites';
localStorage.setItem('oradio.randomMode', state.randomMode);
toast(`Random mode: ${state.randomMode === 'favorites' ? 'favorites only' : 'all stations'}`);
render();
}
// Pick a random station and play it. Uses the public /api/v1/stations/random
// for "all" mode, /api/me/favorites/random for "favorites" mode. Falls back
// to a local random pick if the network call fails.
async function playRandom() {
try {
const ep = state.randomMode === 'favorites'
? '/api/me/favorites/random'
: '/api/v1/stations/random';
const remote = await api.get(ep);
// The kiosk player needs the internal numeric id (used by /resolve etc.).
// The favorites endpoint returns it directly; the v1 endpoint does not,
// so resolve via the cached station list (or skip if missing).
let station = remote;
if (station.id == null) {
station = state.stations.find((s) => s.uuid === remote.uuid) || null;
}
if (!station) { toast('Random station not in cache'); return; }
playStation(station);
} catch (err) {
// Fallback: pick locally so the button still does something offline.
const pool = state.randomMode === 'favorites' ? state.favorites : state.stations;
if (!pool.length) { toast(err.message || 'No stations available'); return; }
playStation(pool[Math.floor(Math.random() * pool.length)]);
}
}
function recordHistory(stationId) { function recordHistory(stationId) {
// Server-side history insertion can be added later; for now, optimistic local insert. // Server-side history insertion can be added later; for now, optimistic local insert.
state.history.unshift({ station_id: stationId, started_at: new Date().toISOString() }); state.history.unshift({ station_id: stationId, started_at: new Date().toISOString() });
} }
// Play wrapper: starts playback, pings server play counter, fetches vote state
// so the up/down buttons in the now-playing bar reflect the current station.
async function playStation(station) {
state.player.votes = null;
player.play(station);
recordHistory(station.id);
try {
const stats = await api.post(`/api/stations/${station.id}/play`);
// Only apply if user hasn't switched stations in the meantime.
if (state.player.stationId === station.id) {
state.player.votes = stats;
// Refresh listing stats in the background so the score badge updates.
mergeStats(station.id, stats);
render();
}
} catch (err) {
try {
const stats = await api.get(`/api/stations/${station.id}/votes`);
if (state.player.stationId === station.id) {
state.player.votes = stats;
mergeStats(station.id, stats);
render();
}
} catch { /* ignore */ }
}
}
async function votePlayer(value) {
const id = state.player.stationId;
if (!id) return;
// Toggle off when clicking the already-active button.
const cur = state.player.votes?.myVote || 0;
const next = cur === value ? 0 : value;
try {
const stats = await api.post(`/api/stations/${id}/vote`, { value: next });
state.player.votes = stats;
mergeStats(id, stats);
render();
} catch (err) {
toast(err.message || 'Vote failed');
}
}
function mergeStats(stationId, stats) {
const list = [state.stations, state.favorites];
for (const arr of list) {
const hit = arr.find((s) => s.id === stationId);
if (hit) {
hit.up = stats.up; hit.down = stats.down;
hit.plays = stats.plays; hit.score = stats.score;
hit.my_vote = stats.myVote;
}
}
}
// ---- API endpoints context menu ---- // ---- API endpoints context menu ----
let menuEl = null; let menuEl = null;
function closeContextMenu() { function closeContextMenu() {
if (menuEl) { menuEl.remove(); menuEl = null; } if (menuEl) { menuEl.remove(); menuEl = null; }
} }
function apiEndpoints(s) { function apiEndpoints(s) {
if (!s.uuid) return []; const origin = location.origin;
const base = `${location.origin}/api/v1`; const base = `${origin}/api/v1`;
return [ const items = [];
{ label: 'Station detail', url: `${base}/stations/${s.uuid}` }, // Original (internal) endpoint — always available, keyed by station id.
{ label: 'Stream redirect', url: `${base}/stations/${s.uuid}/stream` }, if (s.id != null) {
{ label: 'MP3 stream', url: `${base}/stations/${s.uuid}/stream?format=mp3` }, items.push({ label: 'Station (original)', url: `${origin}/api/stations/${s.id}` });
{ label: 'AAC stream', url: `${base}/stations/${s.uuid}/stream?format=aac` }, }
{ label: 'HLS stream', url: `${base}/stations/${s.uuid}/stream?format=hls` }, // Public v1 endpoints — require uuid.
if (s.uuid) {
items.push(
{ label: 'Station detail', url: `${base}/stations/${s.uuid}` },
{ label: 'Stream redirect', url: `${base}/stations/${s.uuid}/stream` },
{ label: 'MP3 stream', url: `${base}/stations/${s.uuid}/stream?format=mp3` },
{ label: 'AAC stream', url: `${base}/stations/${s.uuid}/stream?format=aac` },
{ label: 'HLS stream', url: `${base}/stations/${s.uuid}/stream?format=hls` }
);
}
items.push(
{ label: 'All stations', url: `${base}/stations` }, { label: 'All stations', url: `${base}/stations` },
{ label: 'Health', url: `${base}/health` } { label: 'Health', url: `${base}/health` }
]; );
return items;
} }
function openContextMenu(x, y, station) { function openContextMenu(x, y, station) {
closeContextMenu(); closeContextMenu();
const items = apiEndpoints(station); const items = apiEndpoints(station);
menuEl = el('div', { class: 'ctx-menu', role: 'menu' }, menuEl = el('div', { class: 'ctx-menu', role: 'menu' },
el('div', { class: 'ctx-title' }, station.name), el('div', { class: 'ctx-title' }, station.name),
el('div', { class: 'ctx-sub' }, station.uuid ? `uuid · ${station.uuid}` : 'no uuid'), el('div', { class: 'ctx-sub' },
station.uuid ? `uuid · ${station.uuid}` : (station.id != null ? `id · ${station.id} (no uuid — public v1 hidden)` : 'no identifier')),
...(items.length ? items.map((it) => el('div', { class: 'ctx-row' }, ...(items.length ? items.map((it) => el('div', { class: 'ctx-row' },
el('div', { class: 'ctx-row-text' }, el('div', { class: 'ctx-row-text' },
el('div', { class: 'ctx-label' }, it.label), el('div', { class: 'ctx-label' }, it.label),

View File

@@ -1,21 +1,21 @@
async function http(method, path, body) { async function http(method, path, body) {
const res = await fetch(path, { const res = await fetch(path, {
method, method,
credentials: 'same-origin', credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : {}, headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined body: body ? JSON.stringify(body) : undefined
}); });
if (res.status === 204) return null; if (res.status === 204) return null;
const ct = res.headers.get('content-type') || ''; const ct = res.headers.get('content-type') || '';
const data = ct.includes('json') ? await res.json() : await res.text(); const data = ct.includes('json') ? await res.json() : await res.text();
if (!res.ok) throw Object.assign(new Error(data?.error || res.statusText), { status: res.status, data }); if (!res.ok) throw Object.assign(new Error(data?.error || res.statusText), { status: res.status, data });
return data; return data;
} }
export const api = { export const api = {
get: (p) => http('GET', p), get: (p) => http('GET', p),
post: (p, b) => http('POST', p, b), post: (p, b) => http('POST', p, b),
put: (p, b) => http('PUT', p, b), put: (p, b) => http('PUT', p, b),
patch: (p, b) => http('PATCH', p, b), patch: (p, b) => http('PATCH', p, b),
del: (p) => http('DELETE', p) del: (p) => http('DELETE', p)
}; };

View File

@@ -1,17 +1,17 @@
export function el(tag, props = {}, ...children) { export function el(tag, props = {}, ...children) {
const node = document.createElement(tag); const node = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) { for (const [k, v] of Object.entries(props || {})) {
if (k === 'class') node.className = v; if (k === 'class') node.className = v;
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v); else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v); else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
else if (k === 'html') node.innerHTML = v; else if (k === 'html') node.innerHTML = v;
else if (v !== false && v != null) node.setAttribute(k, v === true ? '' : v); else if (v !== false && v != null) node.setAttribute(k, v === true ? '' : v);
} }
for (const c of children.flat()) { for (const c of children.flat()) {
if (c == null || c === false) continue; if (c == null || c === false) continue;
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c))); node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
} }
return node; return node;
} }
export function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); } export function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }

View File

@@ -1,22 +1,22 @@
export function connectWs(onMessage) { export function connectWs(onMessage) {
let ws, retry = 0, closed = false; let ws, retry = 0, closed = false;
function open() { function open() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`); ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.addEventListener('open', () => { retry = 0; }); ws.addEventListener('open', () => { retry = 0; });
ws.addEventListener('message', (ev) => { ws.addEventListener('message', (ev) => {
try { onMessage(JSON.parse(ev.data)); } catch {} try { onMessage(JSON.parse(ev.data)); } catch { }
}); });
ws.addEventListener('close', () => { ws.addEventListener('close', () => {
if (closed) return; if (closed) return;
retry = Math.min(retry + 1, 6); retry = Math.min(retry + 1, 6);
setTimeout(open, 500 * 2 ** retry); setTimeout(open, 500 * 2 ** retry);
}); });
ws.addEventListener('error', () => ws.close()); ws.addEventListener('error', () => ws.close());
} }
open(); open();
return { return {
send(msg) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }, send(msg) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); },
close() { closed = true; ws?.close(); } close() { closed = true; ws?.close(); }
}; };
} }

View File

@@ -189,6 +189,37 @@ input, select, textarea { font: inherit; color: inherit; }
.btn-add:hover { background: #ff8a55; } .btn-add:hover { background: #ff8a55; }
.btn-add:active { transform: scale(0.94); } .btn-add:active { transform: scale(0.94); }
.btn-random {
display: flex; align-items: center; gap: 6px;
height: 36px; padding: 0 12px;
border-radius: 999px;
background: var(--bg-2); color: var(--fg);
border: 1px solid var(--line);
font-size: 13px; font-weight: 600;
transition: background 120ms, border-color 120ms, transform 80ms;
}
.btn-random:hover { background: var(--bg-3); border-color: rgba(255,122,61,0.4); }
.btn-random:active { transform: scale(0.96); }
.btn-random .rand-icon { font-size: 15px; line-height: 1; }
.btn-random .rand-mode {
font-size: 11px; padding: 2px 6px; border-radius: 999px;
background: rgba(255,122,61,0.18); color: var(--accent-2);
border: 1px solid rgba(255,122,61,0.30);
letter-spacing: 0.02em;
}
.btn-docs {
display: flex; align-items: center; justify-content: center;
height: 36px; padding: 0 12px;
border-radius: 999px;
background: var(--bg-2); color: var(--muted);
border: 1px solid var(--line);
font-size: 12px; font-weight: 700; letter-spacing: 0.06em;
text-decoration: none;
transition: background 120ms, color 120ms, border-color 120ms;
}
.btn-docs:hover { background: var(--bg-3); color: var(--fg); border-color: rgba(78,201,166,0.4); }
.chips { .chips {
display: flex; flex-wrap: wrap; gap: 5px; display: flex; flex-wrap: wrap; gap: 5px;
max-height: 64px; overflow-y: auto; max-height: 64px; overflow-y: auto;
@@ -656,3 +687,83 @@ dialog.add-station select:focus { border-color: var(--accent) !important; box-sh
box-shadow: none !important; box-shadow: none !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800; font-size: 12px;
} }
/* ============================================================
Voting: up/down buttons in the now-playing bar, score badge
on every card, and a sort dropdown for the Browse tab.
============================================================ */
.vote-group {
display: flex;
gap: 0;
margin-right: 6px;
}
.vote {
display: flex; align-items: center; gap: 6px;
height: 46px; min-width: 64px;
padding: 0 12px;
background: var(--bg-2) !important;
color: var(--fg);
border: 1px solid var(--line) !important;
font-weight: 800; font-size: 14px;
font-variant-numeric: tabular-nums;
transition: background 80ms linear, color 80ms linear, border-color 80ms linear !important;
}
.vote + .vote { margin-left: -1px; }
.vote:disabled { opacity: 0.35; cursor: not-allowed; }
.vote .vote-icon { font-size: 17px; line-height: 1; }
.vote .vote-count { font-size: 13px; letter-spacing: 0.02em; }
.vote.up:not(:disabled):hover {
background: var(--good) !important; color: #000 !important; border-color: var(--good) !important;
}
.vote.down:not(:disabled):hover {
background: var(--bad) !important; color: #000 !important; border-color: var(--bad) !important;
}
.vote.up.on {
background: var(--good) !important; color: #000 !important; border-color: var(--good) !important;
}
.vote.down.on {
background: var(--bad) !important; color: #000 !important; border-color: var(--bad) !important;
}
/* Sort dropdown next to search */
.sort {
height: 36px;
padding: 0 28px 0 12px;
background: var(--bg-2) !important;
color: var(--fg);
border: 1px solid var(--line) !important;
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
outline: none;
cursor: pointer;
}
.sort:focus, .sort:hover { border-color: var(--accent) !important; }
.sort option { background: var(--bg-1); color: var(--fg); }
/* Grid card: add a column for the score badge. */
.card {
grid-template-columns: 44px 1fr auto auto auto !important;
}
.score-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 36px; height: 26px;
padding: 0 8px;
background: var(--bg-2);
border: 1px solid var(--line);
color: var(--muted);
font-weight: 800; font-size: 12px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.score-badge.pos { color: var(--good); border-color: rgba(0,210,122,0.35); }
.score-badge.neg { color: var(--bad); border-color: rgba(255,48,48,0.35); }
.score-badge.neu { color: var(--muted-2); }
.card.playing .score-badge { border-color: var(--accent); }