Add API documentation and underground station importer

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

View File

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

View File

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