Add player functionality with HLS support and API integration

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

View File

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

View File

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

View File

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

View File

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

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

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