Add player functionality with HLS support and API integration
- Implemented a new Player class in player.js to handle audio playback, including HLS support using hls.js. - Created a shared API module in api.js for making HTTP requests with proper error handling. - Added DOM utility functions in dom.js for creating and clearing elements. - Introduced WebSocket connection handling in ws.js for real-time updates. - Developed a comprehensive CSS stylesheet for styling the application, including a high-contrast theme.
This commit is contained in:
88
server/auth.js
Normal file
88
server/auth.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { getDb } from './db/index.js';
|
||||
|
||||
const SESSION_DAYS = 30;
|
||||
const COOKIE_NAME = 'oradio_sid';
|
||||
|
||||
export function hashPassword(plain) {
|
||||
return bcrypt.hashSync(plain, 10);
|
||||
}
|
||||
export function verifyPassword(plain, hash) {
|
||||
return bcrypt.compareSync(plain, hash);
|
||||
}
|
||||
|
||||
export function createSession(userId) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const expires = new Date(Date.now() + SESSION_DAYS * 86400e3).toISOString();
|
||||
getDb().prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)')
|
||||
.run(token, userId, expires);
|
||||
return { token, expires };
|
||||
}
|
||||
|
||||
export function destroySession(token) {
|
||||
if (token) getDb().prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
}
|
||||
|
||||
export function getUserBySession(token) {
|
||||
if (!token) return null;
|
||||
return getDb().prepare(`
|
||||
SELECT u.id, u.username, u.role
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ? AND s.expires_at > datetime('now')
|
||||
`).get(token);
|
||||
}
|
||||
|
||||
export function readSessionToken(req) {
|
||||
const raw = req.headers.cookie || '';
|
||||
for (const part of raw.split(';')) {
|
||||
const [k, v] = part.trim().split('=');
|
||||
if (k === COOKIE_NAME) return decodeURIComponent(v || '');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setSessionCookie(res, token, expires) {
|
||||
const attrs = [
|
||||
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
`Expires=${new Date(expires).toUTCString()}`
|
||||
];
|
||||
res.setHeader('Set-Cookie', attrs.join('; '));
|
||||
}
|
||||
|
||||
export function clearSessionCookie(res) {
|
||||
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
}
|
||||
|
||||
export function authMiddleware(req, _res, next) {
|
||||
const token = readSessionToken(req);
|
||||
req.session = { token };
|
||||
req.user = getUserBySession(token);
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireUser(req, res, next) {
|
||||
if (!req.user) return res.status(401).json({ error: 'auth required' });
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (!req.user) return res.status(401).json({ error: 'auth required' });
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'admin only' });
|
||||
next();
|
||||
}
|
||||
|
||||
export function ensureBootstrapAdmin({ username, password }) {
|
||||
if (!username || !password) return;
|
||||
const db = getDb();
|
||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||
if (existing) return;
|
||||
const info = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
||||
.run(username, hashPassword(password), 'admin');
|
||||
db.prepare('INSERT INTO profiles (user_id, display_name) VALUES (?, ?)')
|
||||
.run(info.lastInsertRowid, username);
|
||||
console.log(`[auth] bootstrap admin '${username}' created`);
|
||||
}
|
||||
59
server/db/index.js
Normal file
59
server/db/index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let db;
|
||||
|
||||
export function initDb(dbPath) {
|
||||
const abs = resolve(dbPath);
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
db = new Database(abs);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
const schema = readFileSync(resolve(__dirname, 'schema.sql'), 'utf8');
|
||||
db.exec(schema);
|
||||
runMigrations(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getDb() {
|
||||
if (!db) throw new Error('DB not initialized');
|
||||
return db;
|
||||
}
|
||||
|
||||
// Idempotent migrations for upgrading older DBs that pre-date a column.
|
||||
function runMigrations(db) {
|
||||
const stationCols = new Set(db.prepare("PRAGMA table_info(stations)").all().map((c) => c.name));
|
||||
if (!stationCols.has('uuid')) {
|
||||
db.exec('ALTER TABLE stations ADD COLUMN uuid TEXT');
|
||||
}
|
||||
if (!stationCols.has('category')) {
|
||||
db.exec('ALTER TABLE stations ADD COLUMN category TEXT');
|
||||
}
|
||||
const streamCols = new Set(db.prepare("PRAGMA table_info(streams)").all().map((c) => c.name));
|
||||
if (!streamCols.has('uuid')) {
|
||||
db.exec('ALTER TABLE streams ADD COLUMN uuid TEXT');
|
||||
}
|
||||
|
||||
// Backfill UUIDs. For RB stations, prefer the existing source_ref so the
|
||||
// public UUID matches the upstream Radio-Browser stationuuid.
|
||||
const setStationUuid = db.prepare('UPDATE stations SET uuid = ? WHERE id = ?');
|
||||
for (const row of db.prepare("SELECT id, source, source_ref FROM stations WHERE uuid IS NULL OR uuid = ''").all()) {
|
||||
const u = (row.source === 'radiobrowser' && row.source_ref) ? row.source_ref : randomUUID();
|
||||
setStationUuid.run(u, row.id);
|
||||
}
|
||||
|
||||
const setStreamUuid = db.prepare('UPDATE streams SET uuid = ? WHERE id = ?');
|
||||
for (const row of db.prepare("SELECT id FROM streams WHERE uuid IS NULL OR uuid = ''").all()) {
|
||||
setStreamUuid.run(randomUUID(), row.id);
|
||||
}
|
||||
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_stations_uuid ON stations(uuid)');
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_uuid ON streams(uuid)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_stations_category ON stations(category)');
|
||||
}
|
||||
|
||||
80
server/db/schema.sql
Normal file
80
server/db/schema.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
display_name TEXT,
|
||||
theme TEXT DEFAULT 'dark',
|
||||
default_volume REAL DEFAULT 0.7
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
homepage TEXT,
|
||||
country TEXT,
|
||||
genres TEXT, -- JSON array
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
source TEXT NOT NULL CHECK (source IN ('seed','radiobrowser','manual')),
|
||||
source_ref TEXT,
|
||||
category TEXT,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stations_enabled ON stations(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_stations_source ON stations(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_stations_category ON stations(category);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_stations_uuid ON stations(uuid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS streams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT UNIQUE,
|
||||
station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
format TEXT NOT NULL CHECK (format IN ('mp3','aac','hls','m3u','pls','ogg','unknown')),
|
||||
bitrate INTEGER,
|
||||
label TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
last_checked_at TEXT,
|
||||
last_status TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_streams_station ON streams(station_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_uuid ON streams(uuid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, station_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS play_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE,
|
||||
stream_id INTEGER REFERENCES streams(id) ON DELETE SET NULL,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_history_user ON play_history(user_id, started_at DESC);
|
||||
60
server/index.js
Normal file
60
server/index.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import { createServer } from 'node:http';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { initDb } from './db/index.js';
|
||||
import { authMiddleware, ensureBootstrapAdmin } from './auth.js';
|
||||
import { applySeedIfEmpty } from './sources/seed.js';
|
||||
import { scheduleHealthCheck } from './streams/checker.js';
|
||||
import { attachWs } from './ws.js';
|
||||
|
||||
import { router as authRoutes } from './routes/auth.js';
|
||||
import { router as stationRoutes } from './routes/stations.js';
|
||||
import { router as meRoutes } from './routes/me.js';
|
||||
import { router as adminRoutes } from './routes/admin.js';
|
||||
import { router as v1Routes } from './routes/v1.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT) || 4173;
|
||||
|
||||
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
|
||||
ensureBootstrapAdmin({
|
||||
username: process.env.ADMIN_BOOTSTRAP_USER,
|
||||
password: process.env.ADMIN_BOOTSTRAP_PASSWORD
|
||||
});
|
||||
const seedResult = applySeedIfEmpty();
|
||||
console.log('[seed]', seedResult);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '512kb' }));
|
||||
app.use(authMiddleware);
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/stations', stationRoutes);
|
||||
app.use('/api/me', meRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/v1', v1Routes);
|
||||
|
||||
// Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173.
|
||||
const publicDir = resolve(__dirname, 'public');
|
||||
if (existsSync(publicDir)) {
|
||||
app.use(express.static(publicDir));
|
||||
app.get('/admin', (_req, res) => res.sendFile(resolve(publicDir, 'admin/index.html')));
|
||||
app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html')));
|
||||
}
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: String(err.message || err) });
|
||||
});
|
||||
|
||||
const server = createServer(app);
|
||||
attachWs(server);
|
||||
scheduleHealthCheck(process.env.STREAM_CHECK_CRON);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[oradio] api+ws on http://localhost:${PORT}`);
|
||||
});
|
||||
14
server/public/admin/index.html
Normal file
14
server/public/admin/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Radio Admin</title>
|
||||
|
||||
<script type="module" crossorigin src="/assets/admin-CVu6KAFb.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/dom-BZgKDOeX.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/admin-CJZ4D7u-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
1
server/public/assets/admin-CJZ4D7u-.css
Normal file
1
server/public/assets/admin-CJZ4D7u-.css
Normal file
File diff suppressed because one or more lines are too long
1
server/public/assets/admin-CVu6KAFb.js
Normal file
1
server/public/assets/admin-CVu6KAFb.js
Normal file
File diff suppressed because one or more lines are too long
1
server/public/assets/dom-BZgKDOeX.js
Normal file
1
server/public/assets/dom-BZgKDOeX.js
Normal file
@@ -0,0 +1 @@
|
||||
(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))s(e);new MutationObserver(e=>{for(const t of e)if(t.type==="childList")for(const c of t.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&s(c)}).observe(document,{childList:!0,subtree:!0});function o(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?t.credentials="include":e.crossOrigin==="anonymous"?t.credentials="omit":t.credentials="same-origin",t}function s(e){if(e.ep)return;e.ep=!0;const t=o(e);fetch(e.href,t)}})();async function i(r,n,o){const s=await fetch(n,{method:r,credentials:"same-origin",headers:o?{"Content-Type":"application/json"}:{},body:o?JSON.stringify(o):void 0});if(s.status===204)return null;const t=(s.headers.get("content-type")||"").includes("json")?await s.json():await s.text();if(!s.ok)throw Object.assign(new Error((t==null?void 0:t.error)||s.statusText),{status:s.status,data:t});return t}const l={get:r=>i("GET",r),post:(r,n)=>i("POST",r,n),put:(r,n)=>i("PUT",r,n),patch:(r,n)=>i("PATCH",r,n),del:r=>i("DELETE",r)};function a(r,n={},...o){const s=document.createElement(r);for(const[e,t]of Object.entries(n||{}))e==="class"?s.className=t:e==="style"&&typeof t=="object"?Object.assign(s.style,t):e.startsWith("on")&&typeof t=="function"?s.addEventListener(e.slice(2).toLowerCase(),t):e==="html"?s.innerHTML=t:t!==!1&&t!=null&&s.setAttribute(e,t===!0?"":t);for(const e of o.flat())e==null||e===!1||s.appendChild(e instanceof Node?e:document.createTextNode(String(e)));return s}function f(r){for(;r.firstChild;)r.removeChild(r.firstChild)}export{l as a,f as c,a as e};
|
||||
1
server/public/assets/kiosk-CL6_kPws.css
Normal file
1
server/public/assets/kiosk-CL6_kPws.css
Normal file
File diff suppressed because one or more lines are too long
40
server/public/assets/kiosk-DBnbAN5w.js
Normal file
40
server/public/assets/kiosk-DBnbAN5w.js
Normal file
File diff suppressed because one or more lines are too long
14
server/public/index.html
Normal file
14
server/public/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<title>Radio Kiosk</title>
|
||||
|
||||
<script type="module" crossorigin src="/assets/kiosk-DBnbAN5w.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/dom-BZgKDOeX.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/kiosk-CL6_kPws.css">
|
||||
</head>
|
||||
<body class="kiosk">
|
||||
<div id="app"></div>
|
||||
|
||||
73
server/routes/admin.js
Normal file
73
server/routes/admin.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAdmin } from '../auth.js';
|
||||
import { runHealthCheck } from '../streams/checker.js';
|
||||
import { applySeedIfEmpty } from '../sources/seed.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
import { scrapeIcon } from '../sources/iconScraper.js';
|
||||
import { listStations, getStation, updateStation } from '../stations.js';
|
||||
|
||||
export const router = Router();
|
||||
router.use(requireAdmin);
|
||||
|
||||
router.post('/health-check', async (_req, res) => {
|
||||
const n = await runHealthCheck();
|
||||
res.json({ checked: n });
|
||||
});
|
||||
|
||||
router.post('/reseed', (_req, res) => {
|
||||
res.json(applySeedIfEmpty());
|
||||
});
|
||||
|
||||
router.get('/system', (_req, res) => {
|
||||
const db = getDb();
|
||||
res.json({
|
||||
stations: db.prepare('SELECT COUNT(*) AS n FROM stations').get().n,
|
||||
streams: db.prepare('SELECT COUNT(*) AS n FROM streams').get().n,
|
||||
users: db.prepare('SELECT COUNT(*) AS n FROM users').get().n,
|
||||
favorites: db.prepare('SELECT COUNT(*) AS n FROM favorites').get().n,
|
||||
node: process.version,
|
||||
uptime_s: Math.round(process.uptime())
|
||||
});
|
||||
});
|
||||
|
||||
// Scrape an icon for a single station.
|
||||
router.post('/stations/:id/scrape-icon', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const st = getStation(id);
|
||||
if (!st) return res.status(404).json({ error: 'not found' });
|
||||
const url = await scrapeIcon(st);
|
||||
if (!url) return res.status(404).json({ error: 'no icon found' });
|
||||
const updated = updateStation(id, { image_url: url });
|
||||
res.json({ id, image_url: url, station: updated });
|
||||
});
|
||||
|
||||
// Bulk: scrape icons for every station (optionally only those missing one).
|
||||
router.post('/scrape-icons', async (req, res) => {
|
||||
const onlyMissing = req.query.all !== '1';
|
||||
const stations = listStations({ enabled: null }).filter((s) => !onlyMissing || !s.image_url);
|
||||
const results = { total: stations.length, updated: 0, skipped: 0, failed: 0, items: [] };
|
||||
// Limit concurrency to avoid hammering hosts.
|
||||
const concurrency = 4;
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < stations.length) {
|
||||
const s = stations[i++];
|
||||
try {
|
||||
const url = await scrapeIcon(s);
|
||||
if (url) {
|
||||
updateStation(s.id, { image_url: url });
|
||||
results.updated++;
|
||||
results.items.push({ id: s.id, name: s.name, image_url: url });
|
||||
} else {
|
||||
results.failed++;
|
||||
results.items.push({ id: s.id, name: s.name, image_url: null });
|
||||
}
|
||||
} catch (err) {
|
||||
results.failed++;
|
||||
results.items.push({ id: s.id, name: s.name, error: String(err?.message || err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
res.json(results);
|
||||
});
|
||||
71
server/routes/auth.js
Normal file
71
server/routes/auth.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
verifyPassword, createSession, destroySession, setSessionCookie, clearSessionCookie,
|
||||
hashPassword, requireAdmin
|
||||
} from '../auth.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) return res.status(400).json({ error: 'username + password required' });
|
||||
const user = getDb().prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'invalid credentials' });
|
||||
}
|
||||
const { token, expires } = createSession(user.id);
|
||||
setSessionCookie(res, token, expires);
|
||||
res.json({ id: user.id, username: user.username, role: user.role });
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
destroySession(req.session?.token);
|
||||
clearSessionCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/me', (req, res) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'not signed in' });
|
||||
res.json(req.user);
|
||||
});
|
||||
|
||||
// Admin-only user management
|
||||
router.get('/users', requireAdmin, (_req, res) => {
|
||||
const users = getDb().prepare('SELECT id, username, role, created_at FROM users ORDER BY username').all();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
router.post('/users', requireAdmin, (req, res) => {
|
||||
const { username, password, role = 'user' } = req.body || {};
|
||||
if (!username || !password) return res.status(400).json({ error: 'username + password required' });
|
||||
if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'bad role' });
|
||||
try {
|
||||
const info = getDb().prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
||||
.run(username, hashPassword(password), role);
|
||||
getDb().prepare('INSERT INTO profiles (user_id, display_name) VALUES (?, ?)')
|
||||
.run(info.lastInsertRowid, username);
|
||||
res.status(201).json({ id: info.lastInsertRowid, username, role });
|
||||
} catch (err) {
|
||||
if (String(err).includes('UNIQUE')) return res.status(409).json({ error: 'username taken' });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/users/:id', requireAdmin, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const { password, role } = req.body || {};
|
||||
const db = getDb();
|
||||
if (password) db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id);
|
||||
if (role && ['admin', 'user'].includes(role)) {
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/users/:id', requireAdmin, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (id === req.user.id) return res.status(400).json({ error: 'cannot delete self' });
|
||||
getDb().prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
63
server/routes/me.js
Normal file
63
server/routes/me.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Router } from 'express';
|
||||
import { requireUser } from '../auth.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.use(requireUser);
|
||||
|
||||
router.get('/favorites', (req, res) => {
|
||||
const rows = getDb().prepare(`
|
||||
SELECT s.*, f.position
|
||||
FROM favorites f JOIN stations s ON s.id = f.station_id
|
||||
WHERE f.user_id = ? AND s.enabled = 1
|
||||
ORDER BY f.position ASC, f.created_at ASC
|
||||
`).all(req.user.id);
|
||||
res.json(rows.map((r) => ({
|
||||
id: r.id, uuid: r.uuid, name: r.name, slug: r.slug, homepage: r.homepage, country: r.country,
|
||||
genres: r.genres ? JSON.parse(r.genres) : [], image_url: r.image_url, category: r.category, position: r.position
|
||||
})));
|
||||
});
|
||||
|
||||
router.put('/favorites/:stationId', (req, res) => {
|
||||
const stationId = Number(req.params.stationId);
|
||||
const position = Number(req.body?.position ?? 0);
|
||||
getDb().prepare(`
|
||||
INSERT INTO favorites (user_id, station_id, position) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, station_id) DO UPDATE SET position = excluded.position
|
||||
`).run(req.user.id, stationId, position);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/favorites/:stationId', (req, res) => {
|
||||
getDb().prepare('DELETE FROM favorites WHERE user_id = ? AND station_id = ?')
|
||||
.run(req.user.id, Number(req.params.stationId));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/profile', (req, res) => {
|
||||
const row = getDb().prepare('SELECT * FROM profiles WHERE user_id = ?').get(req.user.id);
|
||||
res.json(row || { user_id: req.user.id, display_name: req.user.username, theme: 'dark', default_volume: 0.7 });
|
||||
});
|
||||
|
||||
router.patch('/profile', (req, res) => {
|
||||
const { display_name, theme, default_volume } = req.body || {};
|
||||
getDb().prepare(`
|
||||
INSERT INTO profiles (user_id, display_name, theme, default_volume) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name = COALESCE(excluded.display_name, profiles.display_name),
|
||||
theme = COALESCE(excluded.theme, profiles.theme),
|
||||
default_volume = COALESCE(excluded.default_volume, profiles.default_volume)
|
||||
`).run(req.user.id, display_name ?? null, theme ?? null, default_volume ?? null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/history', (req, res) => {
|
||||
const rows = getDb().prepare(`
|
||||
SELECT h.*, s.name AS station_name, s.slug AS station_slug
|
||||
FROM play_history h JOIN stations s ON s.id = h.station_id
|
||||
WHERE h.user_id = ?
|
||||
ORDER BY h.started_at DESC LIMIT 50
|
||||
`).all(req.user.id);
|
||||
res.json(rows);
|
||||
});
|
||||
150
server/routes/stations.js
Normal file
150
server/routes/stations.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
listStations, getStation, getStreamsForStation,
|
||||
createStation, updateStation, deleteStation, addStream, deleteStream
|
||||
} from '../stations.js';
|
||||
import { resolveStream } from '../streams/resolver.js';
|
||||
import { requireAdmin, requireUser } from '../auth.js';
|
||||
import * as radiobrowser from '../sources/radiobrowser.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const stations = listStations({
|
||||
q: req.query.q || undefined,
|
||||
source: req.query.source || undefined,
|
||||
enabled: req.query.all ? null : true
|
||||
});
|
||||
res.json(stations);
|
||||
});
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const station = getStation(id);
|
||||
if (!station) return res.status(404).json({ error: 'not found' });
|
||||
station.streams = getStreamsForStation(id);
|
||||
res.json(station);
|
||||
});
|
||||
|
||||
router.post('/:id/resolve', requireUser, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const streams = getStreamsForStation(id);
|
||||
if (!streams.length) return res.status(404).json({ error: 'no streams' });
|
||||
const preferred = req.body?.streamId
|
||||
? streams.find((s) => s.id === Number(req.body.streamId))
|
||||
: streams[0];
|
||||
if (!preferred) return res.status(404).json({ error: 'stream not found' });
|
||||
const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
|
||||
res.json({ stream: preferred, resolved });
|
||||
});
|
||||
|
||||
// Same-origin streaming proxy. Adds the CORS headers Icecast/SHOUTcast servers
|
||||
// almost never send, which lets the kiosk wire a Web-Audio AnalyserNode for a
|
||||
// real spectrum. HLS is excluded — the manifest plus every segment would need
|
||||
// rewriting; clients fall back to the direct URL with no analyser there.
|
||||
router.get('/:id/proxy', requireUser, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const streams = getStreamsForStation(id);
|
||||
if (!streams.length) return res.status(404).json({ error: 'no streams' });
|
||||
const preferred = req.query.streamId
|
||||
? streams.find((s) => s.id === Number(req.query.streamId))
|
||||
: streams[0];
|
||||
if (!preferred) return res.status(404).json({ error: 'stream not found' });
|
||||
const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
|
||||
if (resolved.format === 'hls') return res.status(415).json({ error: 'hls not proxied' });
|
||||
|
||||
const controller = new AbortController();
|
||||
req.on('close', () => controller.abort());
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await fetch(resolved.url, {
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'oradio-kiosk/1.0', 'Icy-MetaData': '0' }
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: `upstream: ${err.message || err}` });
|
||||
}
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
return res.status(502).json({ error: `upstream HTTP ${upstream.status}` });
|
||||
}
|
||||
|
||||
const ct = upstream.headers.get('content-type') || guessContentType(resolved.format);
|
||||
res.status(200);
|
||||
res.set('Content-Type', ct);
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
res.set('Access-Control-Expose-Headers', 'Content-Type');
|
||||
|
||||
// Pipe the WHATWG ReadableStream into the Express response.
|
||||
const reader = upstream.body.getReader();
|
||||
const pump = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (!res.write(Buffer.from(value))) {
|
||||
await new Promise((r) => res.once('drain', r));
|
||||
}
|
||||
}
|
||||
} catch { /* client disconnect or upstream abort */ }
|
||||
finally {
|
||||
try { reader.cancel(); } catch {}
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
pump();
|
||||
});
|
||||
|
||||
function guessContentType(format) {
|
||||
switch (format) {
|
||||
case 'mp3': return 'audio/mpeg';
|
||||
case 'aac': return 'audio/aac';
|
||||
case 'ogg': return 'audio/ogg';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
// --- admin mutations ---
|
||||
router.post('/', requireAdmin, (req, res) => {
|
||||
const station = createStation({ ...req.body, source: req.body.source || 'manual' }, req.user.id);
|
||||
res.status(201).json(station);
|
||||
});
|
||||
|
||||
router.patch('/:id', requireAdmin, (req, res) => {
|
||||
const station = updateStation(Number(req.params.id), req.body || {});
|
||||
if (!station) return res.status(404).json({ error: 'not found' });
|
||||
res.json(station);
|
||||
});
|
||||
|
||||
router.delete('/:id', requireAdmin, (req, res) => {
|
||||
if (!deleteStation(Number(req.params.id))) return res.status(404).json({ error: 'not found' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:id/streams', requireAdmin, (req, res) => {
|
||||
const stream = addStream(Number(req.params.id), req.body || {});
|
||||
res.status(201).json(stream);
|
||||
});
|
||||
|
||||
router.delete('/:id/streams/:streamId', requireAdmin, (req, res) => {
|
||||
if (!deleteStream(Number(req.params.streamId))) return res.status(404).json({ error: 'not found' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Radio-Browser passthrough for the admin importer ---
|
||||
router.get('/sources/radiobrowser/search', requireAdmin, async (req, res) => {
|
||||
const results = await radiobrowser.search({
|
||||
name: req.query.q,
|
||||
country: req.query.country,
|
||||
tag: req.query.tag,
|
||||
limit: Number(req.query.limit) || 30
|
||||
});
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
router.post('/sources/radiobrowser/import', requireAdmin, (req, res) => {
|
||||
const station = createStation({ ...req.body, source: 'radiobrowser' }, req.user.id);
|
||||
res.status(201).json(station);
|
||||
});
|
||||
169
server/routes/v1.js
Normal file
169
server/routes/v1.js
Normal file
@@ -0,0 +1,169 @@
|
||||
// Public read-only API mounted at /api/v1.
|
||||
// Stable per-station UUIDs let third-party tools (mpv, smart-home, scripts)
|
||||
// reference stations independently of internal numeric IDs.
|
||||
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
listStations, getStationByUuid, getStreamsForStation, getStreamByUuid
|
||||
} from '../stations.js';
|
||||
import { resolveStream } from '../streams/resolver.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
import { loadCategoriesFile } from '../sources/seed.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
// CORS for public endpoints. Browser-side integrations can hit the API
|
||||
// from any origin; we don't expose any user data here.
|
||||
router.use((_req, res, next) => {
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
res.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||
res.set('Access-Control-Allow-Headers', 'Content-Type');
|
||||
next();
|
||||
});
|
||||
|
||||
// Tiny in-memory token bucket per IP. 120 req/min is plenty for human use
|
||||
// and clearly throttles a runaway script. Resets on process restart.
|
||||
const buckets = new Map();
|
||||
const RATE = 120;
|
||||
const WINDOW_MS = 60_000;
|
||||
router.use((req, res, next) => {
|
||||
const key = req.ip || 'unknown';
|
||||
const now = Date.now();
|
||||
const b = buckets.get(key) || { count: 0, reset: now + WINDOW_MS };
|
||||
if (now > b.reset) { b.count = 0; b.reset = now + WINDOW_MS; }
|
||||
b.count += 1;
|
||||
buckets.set(key, b);
|
||||
res.set('X-RateLimit-Limit', String(RATE));
|
||||
res.set('X-RateLimit-Remaining', String(Math.max(0, RATE - b.count)));
|
||||
if (b.count > RATE) return res.status(429).json({ error: 'rate limited' });
|
||||
next();
|
||||
});
|
||||
|
||||
function publicStation(s) {
|
||||
if (!s) return null;
|
||||
return {
|
||||
uuid: s.uuid,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
homepage: s.homepage,
|
||||
country: s.country,
|
||||
genres: s.genres,
|
||||
description: s.description,
|
||||
image_url: s.image_url,
|
||||
category: s.category,
|
||||
enabled: s.enabled
|
||||
};
|
||||
}
|
||||
|
||||
function publicStream(s) {
|
||||
if (!s) return null;
|
||||
return {
|
||||
uuid: s.uuid,
|
||||
url: s.url,
|
||||
format: s.format,
|
||||
bitrate: s.bitrate,
|
||||
label: s.label,
|
||||
priority: s.priority,
|
||||
last_status: s.last_status,
|
||||
last_checked_at: s.last_checked_at
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/health', (_req, res) => {
|
||||
const stations = getDb().prepare('SELECT COUNT(*) AS n FROM stations WHERE enabled = 1').get().n;
|
||||
res.json({ ok: true, stations });
|
||||
});
|
||||
|
||||
router.get('/categories', (_req, res) => {
|
||||
const rows = getDb().prepare(`
|
||||
SELECT category AS id, COUNT(*) AS count
|
||||
FROM stations
|
||||
WHERE enabled = 1 AND category IS NOT NULL AND category <> ''
|
||||
GROUP BY category
|
||||
`).all();
|
||||
const counts = new Map(rows.map((r) => [r.id, r.count]));
|
||||
const meta = loadCategoriesFile();
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const m of meta) {
|
||||
seen.add(m.id);
|
||||
out.push({ ...m, count: counts.get(m.id) || 0 });
|
||||
}
|
||||
for (const [id, count] of counts) {
|
||||
if (seen.has(id)) continue;
|
||||
out.push({ id, label: id, icon: '', order: 999, count });
|
||||
}
|
||||
out.sort((a, b) => (a.order ?? 999) - (b.order ?? 999) || String(a.id).localeCompare(String(b.id)));
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
router.get('/stations', (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 200, 1000);
|
||||
let items = listStations({
|
||||
q: req.query.q || undefined,
|
||||
category: req.query.category || undefined,
|
||||
enabled: req.query.all ? null : true
|
||||
});
|
||||
if (req.query.country) {
|
||||
const c = String(req.query.country).toUpperCase();
|
||||
items = items.filter((s) => (s.country || '').toUpperCase() === c);
|
||||
}
|
||||
if (req.query.genre) {
|
||||
const g = String(req.query.genre).toLowerCase();
|
||||
items = items.filter((s) => (s.genres || []).some((x) => x.toLowerCase().includes(g)));
|
||||
}
|
||||
res.json({
|
||||
total: items.length,
|
||||
items: items.slice(0, limit).map(publicStation)
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/stations/:uuid', (req, res) => {
|
||||
const s = getStationByUuid(req.params.uuid);
|
||||
if (!s) return res.status(404).json({ error: 'not found' });
|
||||
const out = publicStation(s);
|
||||
out.streams = getStreamsForStation(s.id).map(publicStream);
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
// 302 redirect to the resolved stream URL. Pure convenience for CLI players
|
||||
// (`mpv http://host/api/v1/stations/<uuid>/stream`) and smart-home scripts.
|
||||
router.get('/stations/:uuid/stream', async (req, res) => {
|
||||
const s = getStationByUuid(req.params.uuid);
|
||||
if (!s) return res.status(404).json({ error: 'station not found' });
|
||||
let streams = getStreamsForStation(s.id);
|
||||
if (!streams.length) return res.status(404).json({ error: 'no streams' });
|
||||
|
||||
if (req.query.format) {
|
||||
const fmt = String(req.query.format).toLowerCase();
|
||||
const filtered = streams.filter((x) => x.format === fmt);
|
||||
if (filtered.length) streams = filtered;
|
||||
}
|
||||
// Prefer streams known to be up; fall back to priority order otherwise.
|
||||
const ordered = [...streams].sort((a, b) => {
|
||||
const au = a.last_status === 'up' ? 0 : 1;
|
||||
const bu = b.last_status === 'up' ? 0 : 1;
|
||||
return au - bu || a.priority - b.priority;
|
||||
});
|
||||
const pick = ordered[0];
|
||||
const resolved = await resolveStream({ url: pick.url, format: pick.format });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.redirect(302, resolved.url);
|
||||
});
|
||||
|
||||
router.get('/stations/:uuid/streams/:streamUuid', async (req, res) => {
|
||||
const station = getStationByUuid(req.params.uuid);
|
||||
if (!station) return res.status(404).json({ error: 'station not found' });
|
||||
const stream = getStreamByUuid(req.params.streamUuid);
|
||||
if (!stream || stream.station_id !== station.id) return res.status(404).json({ error: 'stream not found' });
|
||||
if (req.query.redirect === '0') {
|
||||
return res.json(publicStream(stream));
|
||||
}
|
||||
const resolved = await resolveStream({ url: stream.url, format: stream.format });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.redirect(302, resolved.url);
|
||||
});
|
||||
|
||||
// Reject any non-GET method explicitly so the public surface can never be
|
||||
// abused for mutations even if a bug ever wires one in.
|
||||
router.all('*', (_req, res) => res.status(405).json({ error: 'method not allowed' }));
|
||||
73
server/scripts/check-images.js
Normal file
73
server/scripts/check-images.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'dotenv/config';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const db = new Database(process.env.DB_PATH || './data/db/oradio.sqlite');
|
||||
const rows = db.prepare(`SELECT id, name, image_url FROM stations WHERE image_url IS NOT NULL AND image_url != ''`).all();
|
||||
|
||||
const TIMEOUT_MS = 8000;
|
||||
const CONCURRENCY = 12;
|
||||
const apply = process.argv.includes('--apply');
|
||||
|
||||
async function check(url) {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
||||
const headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
};
|
||||
try {
|
||||
// Many CDNs don't support HEAD or return wrong content-type for HEAD; use a ranged GET.
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
signal: ctrl.signal,
|
||||
headers: { ...headers, Range: 'bytes=0-1023' }
|
||||
});
|
||||
if (!res.ok && res.status !== 206) {
|
||||
// Treat 4xx (except 429 rate-limit) as broken; 5xx as transient → keep.
|
||||
if (res.status === 429) return { ok: true, transient: true };
|
||||
if (res.status >= 500) return { ok: true, transient: true };
|
||||
return { ok: false, reason: `HTTP ${res.status}` };
|
||||
}
|
||||
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct && !ct.startsWith('image/') && !ct.includes('octet-stream')) {
|
||||
return { ok: false, reason: `bad content-type ${ct}` };
|
||||
}
|
||||
// Drain a small amount so the body is closed cleanly.
|
||||
try { await res.arrayBuffer(); } catch { }
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const code = err.cause?.code || err.code || err.name;
|
||||
// DNS / connection failures are definitive.
|
||||
if (['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ETIMEDOUT'].includes(code)) {
|
||||
return { ok: false, reason: code };
|
||||
}
|
||||
if (err.name === 'AbortError') return { ok: true, transient: true }; // timeout, keep
|
||||
return { ok: false, reason: code || err.message };
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
const upd = db.prepare('UPDATE stations SET image_url = NULL WHERE id = ?');
|
||||
let bad = 0, ok = 0;
|
||||
|
||||
async function worker(queue) {
|
||||
while (queue.length) {
|
||||
const r = queue.shift();
|
||||
const res = await check(r.image_url);
|
||||
if (res.ok) {
|
||||
ok++;
|
||||
} else {
|
||||
bad++;
|
||||
console.log(`BAD ${r.name} :: ${res.reason} :: ${r.image_url}`);
|
||||
if (apply) upd.run(r.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queue = rows.slice();
|
||||
console.log(`Checking ${queue.length} image_url values (apply=${apply})...`);
|
||||
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker(queue)));
|
||||
console.log(`Done. ok=${ok} bad=${bad}${apply ? ' (cleared)' : ' (dry run; pass --apply to clear)'}`);
|
||||
203
server/scripts/import-allradio-nl.js
Normal file
203
server/scripts/import-allradio-nl.js
Normal file
@@ -0,0 +1,203 @@
|
||||
// One-shot importer: resolves a list of Dutch station names (from allradio.net)
|
||||
// against Radio-Browser, plus adds Vintage Obscura by direct URL,
|
||||
// then writes data/seed/stations-allradio-nl.json.
|
||||
//
|
||||
// Usage: node server/scripts/import-allradio-nl.js
|
||||
//
|
||||
// Re-running is safe: existing entries are matched by RB UUID via the
|
||||
// merge-by-UUID seeder. This script does NOT touch the database directly.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const OUT = path.join(ROOT, 'data', 'seed', 'stations-allradio-nl.json');
|
||||
const RB = 'https://de1.api.radio-browser.info';
|
||||
const UA = 'OnlineRadioExplorer/0.1 (+import-allradio-nl)';
|
||||
|
||||
// Station names taken from https://www.allradio.net/country/3 (pages 1+2),
|
||||
// minus duplicates and minus ones already seeded under stations-extended.json.
|
||||
const NAMES = [
|
||||
// public broadcasters not yet seeded
|
||||
['NPO 3FM Alternative', 'dutch-public'],
|
||||
['NPO 3FM KX', 'dutch-public'],
|
||||
['NPO FunX NL', 'dutch-public'],
|
||||
['NPO FunX Reggae', 'dutch-public'],
|
||||
['NPO 2', 'dutch-public'],
|
||||
['Radio Rijnmond', 'dutch-public'],
|
||||
['Omroep Gelderland', 'dutch-public'],
|
||||
['Omroep West', 'dutch-public'],
|
||||
// commercials
|
||||
['Radio 10', 'dutch-commercial'],
|
||||
['Radio 10 80\'s Hits', 'dutch-commercial'],
|
||||
['Radio 10 60\'s & 70\'s Hits', 'dutch-commercial'],
|
||||
['Radio 538 Nonstop', 'dutch-commercial'],
|
||||
['538 Dance Department', 'dutch-commercial'],
|
||||
['538 TOP 50', 'dutch-commercial'],
|
||||
['Sky Radio Hits', 'dutch-commercial'],
|
||||
['Sky Radio 90\'s Hits', 'dutch-commercial'],
|
||||
['Sky Radio 101 FM', 'dutch-commercial'],
|
||||
['SLAM FM', 'dutch-commercial'],
|
||||
['Veronica Rockradio', 'dutch-commercial'],
|
||||
['Veronica TOP1000 AllerTijden', 'dutch-commercial'],
|
||||
['JAMM FM', 'dutch-commercial'],
|
||||
['RADIONL', 'dutch-commercial'],
|
||||
['Grand Prix Radio', 'dutch-commercial'],
|
||||
['XXL Stenders', 'dutch-commercial'],
|
||||
['Sublime - Live', 'dutch-commercial'],
|
||||
['Sublime - Soul', 'dutch-commercial'],
|
||||
// rock & alt
|
||||
['KINK', 'rock'],
|
||||
['KINK CLASSICS', 'rock'],
|
||||
['Baars classic Rock', 'rock'],
|
||||
['ISKC Rock Radio', 'rock'],
|
||||
['ICE RADIO', 'rock'],
|
||||
// electronic / dance / hard
|
||||
['Jungletrain.net', 'electronic'],
|
||||
['Real Hardstyle Radio', 'electronic'],
|
||||
['Hardstyle Radio NL', 'electronic'],
|
||||
['Hardcore Power', 'electronic'],
|
||||
['Freak31', 'electronic'],
|
||||
['Decibel', 'electronic'],
|
||||
['Decibel EURODANCE', 'electronic'],
|
||||
['Intense Radio', 'electronic'],
|
||||
['Deep Radio', 'electronic'],
|
||||
['Fantasy Radio - Italo Disco Euro Dance HiNRG', 'electronic'],
|
||||
['MixPerfect Radio', 'electronic'],
|
||||
['Dancegroove Radio', 'electronic'],
|
||||
['DANCEableRADIO', 'electronic'],
|
||||
// jazz / lounge / classical
|
||||
['Jazz de Ville - Jazz', 'jazz'],
|
||||
['Jazz de Ville - Chill', 'jazz'],
|
||||
['Hi On Line Jazz Radio', 'jazz'],
|
||||
['Hi On Line Classical Radio', 'classical'],
|
||||
['Hi On Line Lounge Radio', 'ambient'],
|
||||
['Hi On Line World Radio', 'world'],
|
||||
['Hi On Line Latin Radio', 'world'],
|
||||
['Hi On Line Radio - Pop', 'dutch-commercial'],
|
||||
['ClassicFM - Chillout', 'classical'],
|
||||
['Classic NL', 'classical'],
|
||||
// niche / community / piraten
|
||||
['Pinguin Blues', 'jazz'],
|
||||
['Pinguin Ska World', 'reggae'],
|
||||
['Lachende Piraat', 'world'],
|
||||
['Oude Piraten Hits', 'world'],
|
||||
['Radio Caroline 319 Gold', 'world'],
|
||||
['Radio Nostalgia', 'world'],
|
||||
['Slow Radio Gold', 'world'],
|
||||
['Olympia Classics', 'world'],
|
||||
['Peaceful Radio', 'ambient'],
|
||||
['Amsterdam Funk Channel', 'electronic'],
|
||||
['247Spice', 'world'],
|
||||
['SH Radio', 'world'],
|
||||
['Rivierenland Radio', 'world'],
|
||||
['Grolloo Radio', 'rock'],
|
||||
['All Oldies Channel', 'world'],
|
||||
['i-turn radio', 'world'],
|
||||
['NPO 3FM Serious Radio', 'dutch-public']
|
||||
];
|
||||
|
||||
function detectFormat(codec, url) {
|
||||
const c = (codec || '').toLowerCase();
|
||||
if (c.includes('mp3')) return 'mp3';
|
||||
if (c.includes('aac')) return 'aac';
|
||||
if (c.includes('ogg') || c.includes('vorbis') || c.includes('opus')) return 'ogg';
|
||||
if (url?.endsWith('.m3u8')) return 'hls';
|
||||
if (url?.endsWith('.m3u')) return 'm3u';
|
||||
if (url?.endsWith('.pls')) return 'pls';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
return name.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
async function rbSearch(name) {
|
||||
const url = `${RB}/json/stations/search?name=${encodeURIComponent(name)}&countrycode=NL&limit=5&hidebroken=true&order=clickcount&reverse=true`;
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error(`RB ${res.status}`);
|
||||
const list = await res.json();
|
||||
// also try without country filter as fallback (some entries have wrong country)
|
||||
if (!list.length) {
|
||||
const r2 = await fetch(`${RB}/json/stations/search?name=${encodeURIComponent(name)}&limit=5&hidebroken=true&order=clickcount&reverse=true`, { headers: { 'User-Agent': UA } });
|
||||
if (r2.ok) return r2.json();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function pickBest(list, target) {
|
||||
if (!list.length) return null;
|
||||
const t = target.toLowerCase().trim();
|
||||
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === t);
|
||||
return exact || list[0];
|
||||
}
|
||||
|
||||
function toEntry(s, category) {
|
||||
const stream = {
|
||||
url: s.url_resolved || s.url,
|
||||
format: detectFormat(s.codec, s.url_resolved || s.url),
|
||||
bitrate: s.bitrate || null,
|
||||
label: s.codec ? `${s.codec} ${s.bitrate || ''}`.trim() : null,
|
||||
priority: 0
|
||||
};
|
||||
return {
|
||||
uuid: s.stationuuid,
|
||||
slug: `rb-${s.stationuuid.slice(0, 8)}-${slugify(s.name).slice(0, 40)}`,
|
||||
name: s.name,
|
||||
category,
|
||||
country: s.countrycode || 'NL',
|
||||
homepage: s.homepage || null,
|
||||
genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean).slice(0, 5),
|
||||
description: null,
|
||||
image_url: s.favicon || null,
|
||||
source: 'radiobrowser',
|
||||
source_ref: s.stationuuid,
|
||||
streams: [stream]
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const out = [];
|
||||
const seenUuids = new Set();
|
||||
|
||||
// Vintage Obscura — direct, no RB.
|
||||
out.push({
|
||||
slug: 'vintage-obscura',
|
||||
name: 'Vintage Obscura Radio',
|
||||
category: 'underground',
|
||||
country: 'US',
|
||||
homepage: 'https://vintageobscura.net/',
|
||||
genres: ['vintage', 'obscure', 'curated', 'reddit'],
|
||||
description: 'Curated rare music discovered daily by /r/vintageobscura. All tracks <30k YouTube views, pre-2000.',
|
||||
image_url: 'https://vintageobscura.net/img/vintage-obscura-logo.png',
|
||||
streams: [
|
||||
{ url: 'https://radio.vintageobscura.net/stream', format: 'mp3', bitrate: 128, label: 'MP3 128', priority: 0 }
|
||||
]
|
||||
});
|
||||
|
||||
for (const [name, category] of NAMES) {
|
||||
try {
|
||||
const hits = await rbSearch(name);
|
||||
const pick = pickBest(hits, name);
|
||||
if (!pick) { console.warn(' miss:', name); continue; }
|
||||
if (seenUuids.has(pick.stationuuid)) { console.warn(' dup:', name, '->', pick.name); continue; }
|
||||
seenUuids.add(pick.stationuuid);
|
||||
out.push(toEntry(pick, category));
|
||||
console.log(' ok :', name, '->', pick.name, `(${pick.codec || '?'} ${pick.bitrate || ''})`);
|
||||
} catch (err) {
|
||||
console.warn(' err:', name, err.message);
|
||||
}
|
||||
// gentle pacing
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
|
||||
fs.writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
||||
console.log(`\nwrote ${out.length} entries to ${path.relative(ROOT, OUT)}`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
12
server/scripts/report-streams.js
Normal file
12
server/scripts/report-streams.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'dotenv/config';
|
||||
import Database from 'better-sqlite3';
|
||||
const db = new Database(process.env.DB_PATH || './data/db/oradio.sqlite');
|
||||
const rows = db.prepare(`
|
||||
SELECT s.name, st.format, st.url, st.last_status
|
||||
FROM streams st JOIN stations s ON s.id = st.station_id
|
||||
ORDER BY (st.last_status = 'up'), s.name
|
||||
`).all();
|
||||
for (const r of rows) {
|
||||
const tag = r.last_status === 'up' ? 'OK ' : 'BAD';
|
||||
console.log(tag, (r.last_status || '').padEnd(14), r.format.padEnd(5), r.name, '->', r.url);
|
||||
}
|
||||
48
server/scripts/restore-images-from-seed.js
Normal file
48
server/scripts/restore-images-from-seed.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Restore image_url from seed JSON files for any station where it is currently NULL.
|
||||
// Match priority: explicit uuid → uuidFromSlug(slug) → exact name.
|
||||
import 'dotenv/config';
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SEED_DIR = resolve(__dirname, '../../data/seed');
|
||||
|
||||
function uuidFromSlug(slug) {
|
||||
const h = createHash('sha1').update('oradio:' + slug).digest('hex');
|
||||
return [h.slice(0, 8), h.slice(8, 12), '5' + h.slice(13, 16), '8' + h.slice(17, 20), h.slice(20, 32)].join('-');
|
||||
}
|
||||
|
||||
const db = new Database(process.env.DB_PATH || './data/db/oradio.sqlite');
|
||||
const apply = process.argv.includes('--apply');
|
||||
|
||||
const entries = [];
|
||||
for (const f of readdirSync(SEED_DIR).filter((x) => x.startsWith('stations') && x.endsWith('.json'))) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(join(SEED_DIR, f), 'utf8'));
|
||||
if (Array.isArray(data)) entries.push(...data);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const byUuid = new Map();
|
||||
const byName = new Map();
|
||||
for (const e of entries) {
|
||||
if (!e.image_url) continue;
|
||||
const u = e.uuid || (e.slug ? uuidFromSlug(e.slug) : null);
|
||||
if (u) byUuid.set(u, e.image_url);
|
||||
if (e.name) byName.set(e.name.toLowerCase(), e.image_url);
|
||||
}
|
||||
|
||||
const rows = db.prepare(`SELECT id, uuid, name FROM stations WHERE image_url IS NULL OR image_url = ''`).all();
|
||||
const upd = db.prepare('UPDATE stations SET image_url = ? WHERE id = ?');
|
||||
let restored = 0;
|
||||
for (const r of rows) {
|
||||
const url = (r.uuid && byUuid.get(r.uuid)) || byName.get(r.name?.toLowerCase());
|
||||
if (!url) continue;
|
||||
console.log(`restore ${r.name} -> ${url}`);
|
||||
if (apply) upd.run(url, r.id);
|
||||
restored++;
|
||||
}
|
||||
console.log(`Done. restored=${restored}${apply ? '' : ' (dry run; pass --apply to write)'}`);
|
||||
7
server/scripts/seed.js
Normal file
7
server/scripts/seed.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Standalone seed runner: `npm run seed`
|
||||
import 'dotenv/config';
|
||||
import { initDb } from '../db/index.js';
|
||||
import { applySeedIfEmpty } from '../sources/seed.js';
|
||||
|
||||
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
|
||||
console.log(applySeedIfEmpty());
|
||||
141
server/sources/iconScraper.js
Normal file
141
server/sources/iconScraper.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// Best-effort icon resolver for radio stations.
|
||||
// Order:
|
||||
// 1. Radio-Browser favicon by exact-ish name (only if station.source !== 'radiobrowser', else
|
||||
// we already have it).
|
||||
// 2. Scrape <link rel="icon">, <link rel="apple-touch-icon">, <meta property="og:image">
|
||||
// from the homepage HTML.
|
||||
// 3. HEAD-probe /favicon.ico at the homepage origin.
|
||||
// Returns the best absolute URL found, or null.
|
||||
|
||||
const UA = 'OnlineRadioExplorer/0.1 (+icon-scraper)';
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
const MAX_HTML_BYTES = 256 * 1024;
|
||||
const RB_BASE = 'https://de1.api.radio-browser.info';
|
||||
|
||||
function withTimeout(ms) {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), ms);
|
||||
return { signal: ctl.signal, done: () => clearTimeout(t) };
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const t = withTimeout(FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml' },
|
||||
redirect: 'follow',
|
||||
signal: t.signal
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return null;
|
||||
let received = 0;
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.length;
|
||||
chunks.push(value);
|
||||
if (received >= MAX_HTML_BYTES) { try { await reader.cancel(); } catch {} break; }
|
||||
}
|
||||
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
} finally { t.done(); }
|
||||
}
|
||||
|
||||
async function head(url) {
|
||||
const t = withTimeout(FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD', headers: { 'User-Agent': UA }, signal: t.signal, redirect: 'follow' });
|
||||
return res.ok;
|
||||
} catch { return false; } finally { t.done(); }
|
||||
}
|
||||
|
||||
function abs(base, href) {
|
||||
if (!href) return null;
|
||||
try { return new URL(href, base).toString(); } catch { return null; }
|
||||
}
|
||||
|
||||
// Extract candidate icon URLs from raw HTML. Returns array of { href, size } sorted best-first.
|
||||
function parseIconCandidates(html, baseUrl) {
|
||||
const out = [];
|
||||
// <link rel="...icon..." href="..." sizes="...">
|
||||
const linkRe = /<link\b([^>]*?)\/?>/gi;
|
||||
let m;
|
||||
while ((m = linkRe.exec(html))) {
|
||||
const attrs = m[1];
|
||||
const rel = (/\brel\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
|
||||
if (!/icon/i.test(rel)) continue;
|
||||
const href = (/\bhref\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1];
|
||||
if (!href) continue;
|
||||
const sizes = (/\bsizes\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
|
||||
const sz = parseInt((/(\d+)x\d+/.exec(sizes) || [])[1] || '0', 10);
|
||||
const apple = /apple-touch-icon/i.test(rel) ? 64 : 0; // bias: apple-touch-icons usually larger PNGs
|
||||
const u = abs(baseUrl, href);
|
||||
if (u) out.push({ href: u, score: sz + apple });
|
||||
}
|
||||
// <meta property="og:image" content="...">
|
||||
const metaRe = /<meta\b([^>]*?)\/?>/gi;
|
||||
while ((m = metaRe.exec(html))) {
|
||||
const attrs = m[1];
|
||||
const prop = (/\b(?:property|name)\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
|
||||
if (!/^og:image|^twitter:image/i.test(prop)) continue;
|
||||
const content = (/\bcontent\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1];
|
||||
const u = abs(baseUrl, content);
|
||||
if (u) out.push({ href: u, score: 200 }); // og:image preferred
|
||||
}
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
// de-dupe preserving order
|
||||
const seen = new Set();
|
||||
return out.filter((c) => (seen.has(c.href) ? false : (seen.add(c.href), true)));
|
||||
}
|
||||
|
||||
async function fromRadioBrowserByName(name) {
|
||||
if (!name) return null;
|
||||
try {
|
||||
const url = `${RB_BASE}/json/stations/search?name=${encodeURIComponent(name)}&limit=3&hidebroken=true&order=clickcount&reverse=true`;
|
||||
const t = withTimeout(FETCH_TIMEOUT_MS);
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: t.signal });
|
||||
t.done();
|
||||
if (!res.ok) return null;
|
||||
const list = await res.json();
|
||||
const target = name.toLowerCase().trim();
|
||||
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === target);
|
||||
const pick = exact || list[0];
|
||||
if (pick?.favicon) return pick.favicon;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fromHomepage(homepage) {
|
||||
if (!homepage) return null;
|
||||
let base;
|
||||
try { base = new URL(homepage); } catch { return null; }
|
||||
const html = await fetchText(base.toString());
|
||||
if (html) {
|
||||
const cands = parseIconCandidates(html, base.toString());
|
||||
for (const c of cands) {
|
||||
if (await head(c.href)) return c.href;
|
||||
}
|
||||
}
|
||||
// last resort: /favicon.ico
|
||||
const ico = `${base.origin}/favicon.ico`;
|
||||
if (await head(ico)) return ico;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find an icon URL for a station.
|
||||
* @param {{ name?: string, homepage?: string|null, source?: string }} station
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
export async function scrapeIcon(station) {
|
||||
if (!station) return null;
|
||||
// For non-RB stations, RB often still has an entry → cheap win.
|
||||
if (station.source !== 'radiobrowser') {
|
||||
const rb = await fromRadioBrowserByName(station.name);
|
||||
if (rb) return rb;
|
||||
}
|
||||
return fromHomepage(station.homepage);
|
||||
}
|
||||
65
server/sources/radiobrowser.js
Normal file
65
server/sources/radiobrowser.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// Thin wrapper around the Radio-Browser community API.
|
||||
// Docs: https://api.radio-browser.info/
|
||||
|
||||
const SERVERS = [
|
||||
'https://de1.api.radio-browser.info',
|
||||
'https://nl1.api.radio-browser.info',
|
||||
'https://at1.api.radio-browser.info'
|
||||
];
|
||||
|
||||
let activeServer = SERVERS[0];
|
||||
|
||||
async function rb(path, params) {
|
||||
const url = new URL(path, activeServer);
|
||||
if (params) for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null) url.searchParams.set(k, String(v));
|
||||
}
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'OnlineRadioExplorer/0.1' } });
|
||||
if (!res.ok) throw new Error(`Radio-Browser ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function search({ name, country, tag, limit = 30 }) {
|
||||
const list = await rb('/json/stations/search', {
|
||||
name, country, tag, limit, hidebroken: true, order: 'votes', reverse: true
|
||||
});
|
||||
return list.map(toCanonical);
|
||||
}
|
||||
|
||||
export async function byUuid(uuid) {
|
||||
const list = await rb('/json/stations/byuuid', { uuids: uuid });
|
||||
return list[0] ? toCanonical(list[0]) : null;
|
||||
}
|
||||
|
||||
function detectFormat(codec, url) {
|
||||
const c = (codec || '').toLowerCase();
|
||||
if (c.includes('mp3')) return 'mp3';
|
||||
if (c.includes('aac')) return 'aac';
|
||||
if (c.includes('ogg') || c.includes('vorbis') || c.includes('opus')) return 'ogg';
|
||||
if (url?.endsWith('.m3u8')) return 'hls';
|
||||
if (url?.endsWith('.m3u')) return 'm3u';
|
||||
if (url?.endsWith('.pls')) return 'pls';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function toCanonical(s) {
|
||||
return {
|
||||
uuid: s.stationuuid || undefined,
|
||||
name: s.name,
|
||||
slug: `rb-${s.stationuuid}`,
|
||||
homepage: s.homepage || null,
|
||||
country: s.countrycode || s.country || null,
|
||||
genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean),
|
||||
description: null,
|
||||
image_url: s.favicon || null,
|
||||
source: 'radiobrowser',
|
||||
source_ref: s.stationuuid,
|
||||
streams: [{
|
||||
url: s.url_resolved || s.url,
|
||||
format: detectFormat(s.codec, s.url_resolved || s.url),
|
||||
bitrate: s.bitrate || null,
|
||||
label: s.codec ? `${s.codec} ${s.bitrate || ''}`.trim() : null,
|
||||
priority: 0
|
||||
}]
|
||||
};
|
||||
}
|
||||
122
server/sources/seed.js
Normal file
122
server/sources/seed.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { getDb } from '../db/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SEED_DIR = resolve(__dirname, '../../data/seed');
|
||||
|
||||
// Deterministic UUID v5-style derived from slug; stable across DB rebuilds.
|
||||
function uuidFromSlug(slug) {
|
||||
const h = createHash('sha1').update('oradio:' + slug).digest('hex');
|
||||
return [
|
||||
h.slice(0, 8),
|
||||
h.slice(8, 12),
|
||||
'5' + h.slice(13, 16),
|
||||
'8' + h.slice(17, 20),
|
||||
h.slice(20, 32)
|
||||
].join('-');
|
||||
}
|
||||
|
||||
function loadAllSeedFiles() {
|
||||
const files = readdirSync(SEED_DIR)
|
||||
.filter((f) => f.startsWith('stations') && f.endsWith('.json'))
|
||||
.sort();
|
||||
const all = [];
|
||||
for (const f of files) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(join(SEED_DIR, f), 'utf8'));
|
||||
if (Array.isArray(data)) all.push(...data);
|
||||
} catch (err) {
|
||||
console.warn(`[seed] failed to load ${f}:`, err.message);
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
export function loadSeedFile() {
|
||||
return loadAllSeedFiles();
|
||||
}
|
||||
|
||||
export function loadCategoriesFile() {
|
||||
try {
|
||||
const txt = readFileSync(join(SEED_DIR, 'categories.json'), 'utf8');
|
||||
return JSON.parse(txt);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge-by-UUID seeder. Inserts stations and streams whose UUIDs are not yet in
|
||||
* the database. Existing stations are left untouched (admin edits are preserved).
|
||||
*/
|
||||
export function applySeed() {
|
||||
const db = getDb();
|
||||
|
||||
const stationByUuid = db.prepare('SELECT id FROM stations WHERE uuid = ?');
|
||||
const streamByUuid = db.prepare('SELECT id FROM streams WHERE uuid = ?');
|
||||
|
||||
const insertStation = db.prepare(`
|
||||
INSERT INTO stations (uuid, name, slug, homepage, country, genres, description, image_url, category, source, source_ref)
|
||||
VALUES (@uuid, @name, @slug, @homepage, @country, @genres, @description, @image_url, @category, 'seed', @slug)
|
||||
`);
|
||||
const insertStream = db.prepare(`
|
||||
INSERT INTO streams (uuid, station_id, url, format, bitrate, label, priority)
|
||||
VALUES (@uuid, @station_id, @url, @format, @bitrate, @label, @priority)
|
||||
`);
|
||||
|
||||
const entries = loadAllSeedFiles();
|
||||
let inserted = 0;
|
||||
let streamsInserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const tx = db.transaction((list) => {
|
||||
for (const s of list) {
|
||||
const uuid = s.uuid || uuidFromSlug(s.slug);
|
||||
const existing = stationByUuid.get(uuid);
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const info = insertStation.run({
|
||||
uuid,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
homepage: s.homepage ?? null,
|
||||
country: s.country ?? null,
|
||||
genres: JSON.stringify(s.genres ?? []),
|
||||
description: s.description ?? null,
|
||||
image_url: s.image_url ?? null,
|
||||
category: s.category ?? null
|
||||
});
|
||||
const stationId = info.lastInsertRowid;
|
||||
let priority = 0;
|
||||
for (const st of s.streams ?? []) {
|
||||
const streamUuid = st.uuid || randomUUID();
|
||||
if (streamByUuid.get(streamUuid)) continue;
|
||||
insertStream.run({
|
||||
uuid: streamUuid,
|
||||
station_id: stationId,
|
||||
url: st.url,
|
||||
format: st.format ?? 'unknown',
|
||||
bitrate: st.bitrate ?? null,
|
||||
label: st.label ?? null,
|
||||
priority: st.priority ?? priority
|
||||
});
|
||||
streamsInserted++;
|
||||
priority++;
|
||||
}
|
||||
inserted++;
|
||||
}
|
||||
});
|
||||
tx(entries);
|
||||
|
||||
return { inserted, streamsInserted, skipped, total: entries.length };
|
||||
}
|
||||
|
||||
// Back-compat shim: bootstrap and reseed call applySeedIfEmpty(); now always merges.
|
||||
export function applySeedIfEmpty() {
|
||||
return applySeed();
|
||||
}
|
||||
142
server/stations.js
Normal file
142
server/stations.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDb } from './db/index.js';
|
||||
|
||||
function rowToStation(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
uuid: row.uuid,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
homepage: row.homepage,
|
||||
country: row.country,
|
||||
genres: row.genres ? JSON.parse(row.genres) : [],
|
||||
description: row.description,
|
||||
image_url: row.image_url,
|
||||
source: row.source,
|
||||
source_ref: row.source_ref,
|
||||
category: row.category,
|
||||
enabled: !!row.enabled,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function listStations({ q, source, category, enabled = true } = {}) {
|
||||
const db = getDb();
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (enabled !== null) { where.push('enabled = ?'); params.push(enabled ? 1 : 0); }
|
||||
if (source) { where.push('source = ?'); params.push(source); }
|
||||
if (category) { where.push('category = ?'); params.push(category); }
|
||||
if (q) { where.push('(name LIKE ? OR genres LIKE ? OR country LIKE ?)'); params.push(`%${q}%`, `%${q}%`, `%${q}%`); }
|
||||
const sql = `SELECT * FROM stations ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY name COLLATE NOCASE`;
|
||||
return db.prepare(sql).all(...params).map(rowToStation);
|
||||
}
|
||||
|
||||
export function getStation(id) {
|
||||
return rowToStation(getDb().prepare('SELECT * FROM stations WHERE id = ?').get(id));
|
||||
}
|
||||
|
||||
export function getStationByUuid(uuid) {
|
||||
return rowToStation(getDb().prepare('SELECT * FROM stations WHERE uuid = ?').get(uuid));
|
||||
}
|
||||
|
||||
export function getStreamsForStation(stationId) {
|
||||
return getDb().prepare(
|
||||
'SELECT * FROM streams WHERE station_id = ? ORDER BY priority ASC, id ASC'
|
||||
).all(stationId);
|
||||
}
|
||||
|
||||
export function getStreamByUuid(uuid) {
|
||||
return getDb().prepare('SELECT * FROM streams WHERE uuid = ?').get(uuid);
|
||||
}
|
||||
|
||||
export function slugify(name) {
|
||||
return name.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80) || `station-${Date.now()}`;
|
||||
}
|
||||
|
||||
export function uniqueSlug(base) {
|
||||
const db = getDb();
|
||||
let slug = base, n = 1;
|
||||
while (db.prepare('SELECT 1 FROM stations WHERE slug = ?').get(slug)) {
|
||||
n += 1;
|
||||
slug = `${base}-${n}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export function createStation(input, userId) {
|
||||
const db = getDb();
|
||||
const slug = input.slug || uniqueSlug(slugify(input.name));
|
||||
const uuid = input.uuid || randomUUID();
|
||||
const info = db.prepare(`
|
||||
INSERT INTO stations (uuid, name, slug, homepage, country, genres, description, image_url, source, source_ref, category, created_by)
|
||||
VALUES (@uuid, @name, @slug, @homepage, @country, @genres, @description, @image_url, @source, @source_ref, @category, @created_by)
|
||||
`).run({
|
||||
uuid,
|
||||
name: input.name,
|
||||
slug,
|
||||
homepage: input.homepage ?? null,
|
||||
country: input.country ?? null,
|
||||
genres: JSON.stringify(input.genres ?? []),
|
||||
description: input.description ?? null,
|
||||
image_url: input.image_url ?? null,
|
||||
source: input.source ?? 'manual',
|
||||
source_ref: input.source_ref ?? null,
|
||||
category: input.category ?? null,
|
||||
created_by: userId ?? null
|
||||
});
|
||||
const id = info.lastInsertRowid;
|
||||
for (const s of input.streams ?? []) {
|
||||
db.prepare(`INSERT INTO streams (uuid, station_id, url, format, bitrate, label, priority)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(s.uuid || randomUUID(), id, s.url, s.format ?? 'unknown', s.bitrate ?? null, s.label ?? null, s.priority ?? 0);
|
||||
}
|
||||
return getStation(id);
|
||||
}
|
||||
|
||||
export function updateStation(id, patch) {
|
||||
const db = getDb();
|
||||
const cur = getStation(id);
|
||||
if (!cur) return null;
|
||||
const next = { ...cur, ...patch };
|
||||
db.prepare(`
|
||||
UPDATE stations
|
||||
SET name=@name, homepage=@homepage, country=@country, genres=@genres,
|
||||
description=@description, image_url=@image_url, category=@category,
|
||||
enabled=@enabled, updated_at=datetime('now')
|
||||
WHERE id=@id
|
||||
`).run({
|
||||
id,
|
||||
name: next.name,
|
||||
homepage: next.homepage ?? null,
|
||||
country: next.country ?? null,
|
||||
genres: JSON.stringify(next.genres ?? []),
|
||||
description: next.description ?? null,
|
||||
image_url: next.image_url ?? null,
|
||||
category: next.category ?? null,
|
||||
enabled: next.enabled ? 1 : 0
|
||||
});
|
||||
return getStation(id);
|
||||
}
|
||||
|
||||
export function deleteStation(id) {
|
||||
return getDb().prepare('DELETE FROM stations WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
export function addStream(stationId, s) {
|
||||
const uuid = s.uuid || randomUUID();
|
||||
const info = getDb().prepare(`
|
||||
INSERT INTO streams (uuid, station_id, url, format, bitrate, label, priority)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(uuid, stationId, s.url, s.format ?? 'unknown', s.bitrate ?? null, s.label ?? null, s.priority ?? 0);
|
||||
return getDb().prepare('SELECT * FROM streams WHERE id = ?').get(info.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function deleteStream(streamId) {
|
||||
return getDb().prepare('DELETE FROM streams WHERE id = ?').run(streamId).changes > 0;
|
||||
}
|
||||
25
server/streams/checker.js
Normal file
25
server/streams/checker.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import cron from 'node-cron';
|
||||
import { getDb } from '../db/index.js';
|
||||
import { probeStream } from './probe.js';
|
||||
|
||||
const probe = probeStream;
|
||||
|
||||
export async function runHealthCheck() {
|
||||
const db = getDb();
|
||||
const streams = db.prepare('SELECT id, url FROM streams').all();
|
||||
const update = db.prepare(
|
||||
"UPDATE streams SET last_status = ?, last_checked_at = datetime('now') WHERE id = ?"
|
||||
);
|
||||
for (const s of streams) {
|
||||
const status = await probe(s.url);
|
||||
update.run(status, s.id);
|
||||
}
|
||||
return streams.length;
|
||||
}
|
||||
|
||||
export function scheduleHealthCheck(expr) {
|
||||
if (!expr) return null;
|
||||
return cron.schedule(expr, () => {
|
||||
runHealthCheck().catch((err) => console.error('[health]', err));
|
||||
});
|
||||
}
|
||||
68
server/streams/probe.js
Normal file
68
server/streams/probe.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// Low-level stream probe.
|
||||
// Icecast/SHOUTcast servers commonly answer with `ICY 200 OK` instead of
|
||||
// `HTTP/1.1 200 OK`, which Node's built-in fetch refuses to parse. We open
|
||||
// a raw TCP/TLS socket, send a minimal HTTP/1.0 GET, and inspect the first
|
||||
// status line ourselves.
|
||||
|
||||
import net from 'node:net';
|
||||
import tls from 'node:tls';
|
||||
|
||||
const TIMEOUT = 8000;
|
||||
const UA = 'Mozilla/5.0 OnlineRadioExplorer/0.1';
|
||||
|
||||
export function probeStream(rawUrl) {
|
||||
return new Promise((resolve) => {
|
||||
let url;
|
||||
try { url = new URL(rawUrl); } catch { return resolve('err-badurl'); }
|
||||
|
||||
const isTls = url.protocol === 'https:';
|
||||
const port = Number(url.port) || (isTls ? 443 : 80);
|
||||
const path = (url.pathname || '/') + (url.search || '');
|
||||
const host = url.hostname;
|
||||
|
||||
const opts = { host, port, servername: host };
|
||||
const connect = isTls ? tls.connect : net.connect;
|
||||
const sock = connect(opts);
|
||||
|
||||
let settled = false;
|
||||
const finish = (status) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { sock.destroy(); } catch {}
|
||||
resolve(status);
|
||||
};
|
||||
|
||||
sock.setTimeout(TIMEOUT);
|
||||
sock.on('timeout', () => finish('err-timeout'));
|
||||
sock.on('error', () => finish('err-fetch'));
|
||||
|
||||
sock.on('connect', () => {
|
||||
const req =
|
||||
`GET ${path} HTTP/1.0\r\n` +
|
||||
`Host: ${host}\r\n` +
|
||||
`User-Agent: ${UA}\r\n` +
|
||||
`Icy-MetaData: 1\r\n` +
|
||||
`Accept: */*\r\n` +
|
||||
`Connection: close\r\n\r\n`;
|
||||
sock.write(req);
|
||||
});
|
||||
|
||||
let buf = '';
|
||||
sock.on('data', (chunk) => {
|
||||
buf += chunk.toString('latin1');
|
||||
const eol = buf.indexOf('\n');
|
||||
if (eol < 0) return;
|
||||
const statusLine = buf.slice(0, eol).trim();
|
||||
// Accept: HTTP/1.x 2xx, ICY 2xx, SOURCE 2xx
|
||||
const m = statusLine.match(/^(?:HTTP\/\d\.\d|ICY|SOURCE)\s+(\d{3})/i);
|
||||
if (!m) return finish(`bad-${statusLine.slice(0, 16)}`);
|
||||
const code = Number(m[1]);
|
||||
if (code >= 200 && code < 400) finish('up');
|
||||
else finish(`http-${code}`);
|
||||
});
|
||||
|
||||
sock.on('end', () => {
|
||||
if (!settled) finish(buf ? 'err-empty' : 'err-fetch');
|
||||
});
|
||||
});
|
||||
}
|
||||
40
server/streams/resolver.js
Normal file
40
server/streams/resolver.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// Resolve playlist files (.pls / .m3u) to a direct stream URL.
|
||||
// HLS (.m3u8) is left as-is so hls.js can fetch it.
|
||||
|
||||
export function detectFormatFromUrl(url) {
|
||||
const u = url.toLowerCase().split('?')[0];
|
||||
if (u.endsWith('.m3u8')) return 'hls';
|
||||
if (u.endsWith('.m3u')) return 'm3u';
|
||||
if (u.endsWith('.pls')) return 'pls';
|
||||
if (u.endsWith('.aac')) return 'aac';
|
||||
if (u.endsWith('.mp3')) return 'mp3';
|
||||
if (u.endsWith('.ogg') || u.endsWith('.opus')) return 'ogg';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function parsePls(text) {
|
||||
const m = text.match(/^File\d+\s*=\s*(.+)$/im);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
function parseM3u(text) {
|
||||
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
return lines.find((l) => !l.startsWith('#')) || null;
|
||||
}
|
||||
|
||||
export async function resolveStream({ url, format }) {
|
||||
const fmt = format && format !== 'unknown' ? format : detectFormatFromUrl(url);
|
||||
if (fmt === 'pls' || fmt === 'm3u') {
|
||||
try {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
const direct = fmt === 'pls' ? parsePls(text) : parseM3u(text);
|
||||
if (!direct) throw new Error('No direct URL found in playlist');
|
||||
return { url: direct, format: detectFormatFromUrl(direct) };
|
||||
} catch (err) {
|
||||
return { url, format: fmt, error: String(err.message || err) };
|
||||
}
|
||||
}
|
||||
return { url, format: fmt };
|
||||
}
|
||||
56
server/ws.js
Normal file
56
server/ws.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { getUserBySession, readSessionToken } from './auth.js';
|
||||
|
||||
// per-user channel hub: any client of user U receives messages targeted to U.
|
||||
const channels = new Map(); // userId -> Set<ws>
|
||||
|
||||
export function attachWs(server) {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
if (!req.url.startsWith('/ws')) return socket.destroy();
|
||||
const token = readSessionToken(req);
|
||||
const user = getUserBySession(token);
|
||||
if (!user) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.user = user;
|
||||
addClient(user.id, ws);
|
||||
ws.on('close', () => removeClient(user.id, ws));
|
||||
ws.on('message', (raw) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||||
// Re-broadcast every message to all connections of the same user.
|
||||
// (e.g. phone sends `{type:"command", action:"play", stationId:7}` → kiosk receives)
|
||||
broadcastToUser(user.id, msg, ws);
|
||||
});
|
||||
ws.send(JSON.stringify({ type: 'hello', user: { id: user.id, username: user.username, role: user.role } }));
|
||||
});
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
function addClient(userId, ws) {
|
||||
if (!channels.has(userId)) channels.set(userId, new Set());
|
||||
channels.get(userId).add(ws);
|
||||
}
|
||||
function removeClient(userId, ws) {
|
||||
const set = channels.get(userId);
|
||||
if (!set) return;
|
||||
set.delete(ws);
|
||||
if (!set.size) channels.delete(userId);
|
||||
}
|
||||
|
||||
export function broadcastToUser(userId, msg, except) {
|
||||
const set = channels.get(userId);
|
||||
if (!set) return;
|
||||
const payload = JSON.stringify(msg);
|
||||
for (const ws of set) {
|
||||
if (ws === except) continue;
|
||||
if (ws.readyState === ws.OPEN) ws.send(payload);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user