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:
@@ -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); });
|
||||
|
||||
Reference in New Issue
Block a user