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

@@ -5,21 +5,21 @@ 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;
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));
});
if (!expr) return null;
return cron.schedule(expr, () => {
runHealthCheck().catch((err) => console.error('[health]', err));
});
}

View File

@@ -11,58 +11,58 @@ 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'); }
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 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);
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);
};
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.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);
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');
});
});
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');
});
});
}

View File

@@ -2,39 +2,39 @@
// 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';
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;
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;
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) };
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 };
return { url, format: fmt };
}