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:
189
server/scripts/import-underground.js
Normal file
189
server/scripts/import-underground.js
Normal 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); });
|
||||
Reference in New Issue
Block a user