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:
@@ -4,21 +4,22 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
listStations, getStationByUuid, getStreamsForStation, getStreamByUuid
|
||||
listStations, getStationByUuid, getStreamsForStation, getStreamByUuid
|
||||
} from '../stations.js';
|
||||
import { resolveStream } from '../streams/resolver.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
import { loadCategoriesFile } from '../sources/seed.js';
|
||||
import { getStationStats, getStatsMap, sortByMode } from '../stats.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
// CORS for public endpoints. Browser-side integrations can hit the API
|
||||
// from any origin; we don't expose any user data here.
|
||||
router.use((_req, res, next) => {
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
res.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||
res.set('Access-Control-Allow-Headers', 'Content-Type');
|
||||
next();
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
res.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||
res.set('Access-Control-Allow-Headers', 'Content-Type');
|
||||
next();
|
||||
});
|
||||
|
||||
// Tiny in-memory token bucket per IP. 120 req/min is plenty for human use
|
||||
@@ -27,141 +28,193 @@ const buckets = new Map();
|
||||
const RATE = 120;
|
||||
const WINDOW_MS = 60_000;
|
||||
router.use((req, res, next) => {
|
||||
const key = req.ip || 'unknown';
|
||||
const now = Date.now();
|
||||
const b = buckets.get(key) || { count: 0, reset: now + WINDOW_MS };
|
||||
if (now > b.reset) { b.count = 0; b.reset = now + WINDOW_MS; }
|
||||
b.count += 1;
|
||||
buckets.set(key, b);
|
||||
res.set('X-RateLimit-Limit', String(RATE));
|
||||
res.set('X-RateLimit-Remaining', String(Math.max(0, RATE - b.count)));
|
||||
if (b.count > RATE) return res.status(429).json({ error: 'rate limited' });
|
||||
next();
|
||||
const key = req.ip || 'unknown';
|
||||
const now = Date.now();
|
||||
const b = buckets.get(key) || { count: 0, reset: now + WINDOW_MS };
|
||||
if (now > b.reset) { b.count = 0; b.reset = now + WINDOW_MS; }
|
||||
b.count += 1;
|
||||
buckets.set(key, b);
|
||||
res.set('X-RateLimit-Limit', String(RATE));
|
||||
res.set('X-RateLimit-Remaining', String(Math.max(0, RATE - b.count)));
|
||||
if (b.count > RATE) return res.status(429).json({ error: 'rate limited' });
|
||||
next();
|
||||
});
|
||||
|
||||
function publicStation(s) {
|
||||
if (!s) return null;
|
||||
return {
|
||||
uuid: s.uuid,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
homepage: s.homepage,
|
||||
country: s.country,
|
||||
genres: s.genres,
|
||||
description: s.description,
|
||||
image_url: s.image_url,
|
||||
category: s.category,
|
||||
enabled: s.enabled
|
||||
};
|
||||
if (!s) return null;
|
||||
return {
|
||||
uuid: s.uuid,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
homepage: s.homepage,
|
||||
country: s.country,
|
||||
genres: s.genres,
|
||||
description: s.description,
|
||||
image_url: s.image_url,
|
||||
category: s.category,
|
||||
enabled: s.enabled,
|
||||
up: s.up ?? 0,
|
||||
down: s.down ?? 0,
|
||||
plays: s.plays ?? 0,
|
||||
score: s.score ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
function publicStream(s) {
|
||||
if (!s) return null;
|
||||
return {
|
||||
uuid: s.uuid,
|
||||
url: s.url,
|
||||
format: s.format,
|
||||
bitrate: s.bitrate,
|
||||
label: s.label,
|
||||
priority: s.priority,
|
||||
last_status: s.last_status,
|
||||
last_checked_at: s.last_checked_at
|
||||
};
|
||||
if (!s) return null;
|
||||
return {
|
||||
uuid: s.uuid,
|
||||
url: s.url,
|
||||
format: s.format,
|
||||
bitrate: s.bitrate,
|
||||
label: s.label,
|
||||
priority: s.priority,
|
||||
last_status: s.last_status,
|
||||
last_checked_at: s.last_checked_at
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/health', (_req, res) => {
|
||||
const stations = getDb().prepare('SELECT COUNT(*) AS n FROM stations WHERE enabled = 1').get().n;
|
||||
res.json({ ok: true, stations });
|
||||
const stations = getDb().prepare('SELECT COUNT(*) AS n FROM stations WHERE enabled = 1').get().n;
|
||||
res.json({ ok: true, stations });
|
||||
});
|
||||
|
||||
router.get('/categories', (_req, res) => {
|
||||
const rows = getDb().prepare(`
|
||||
const rows = getDb().prepare(`
|
||||
SELECT category AS id, COUNT(*) AS count
|
||||
FROM stations
|
||||
WHERE enabled = 1 AND category IS NOT NULL AND category <> ''
|
||||
GROUP BY category
|
||||
`).all();
|
||||
const counts = new Map(rows.map((r) => [r.id, r.count]));
|
||||
const meta = loadCategoriesFile();
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const m of meta) {
|
||||
seen.add(m.id);
|
||||
out.push({ ...m, count: counts.get(m.id) || 0 });
|
||||
}
|
||||
for (const [id, count] of counts) {
|
||||
if (seen.has(id)) continue;
|
||||
out.push({ id, label: id, icon: '', order: 999, count });
|
||||
}
|
||||
out.sort((a, b) => (a.order ?? 999) - (b.order ?? 999) || String(a.id).localeCompare(String(b.id)));
|
||||
res.json(out);
|
||||
const counts = new Map(rows.map((r) => [r.id, r.count]));
|
||||
const meta = loadCategoriesFile();
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const m of meta) {
|
||||
seen.add(m.id);
|
||||
out.push({ ...m, count: counts.get(m.id) || 0 });
|
||||
}
|
||||
for (const [id, count] of counts) {
|
||||
if (seen.has(id)) continue;
|
||||
out.push({ id, label: id, icon: '', order: 999, count });
|
||||
}
|
||||
out.sort((a, b) => (a.order ?? 999) - (b.order ?? 999) || String(a.id).localeCompare(String(b.id)));
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
router.get('/stations', (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 200, 1000);
|
||||
let items = listStations({
|
||||
q: req.query.q || undefined,
|
||||
category: req.query.category || undefined,
|
||||
enabled: req.query.all ? null : true
|
||||
});
|
||||
if (req.query.country) {
|
||||
const c = String(req.query.country).toUpperCase();
|
||||
items = items.filter((s) => (s.country || '').toUpperCase() === c);
|
||||
}
|
||||
if (req.query.genre) {
|
||||
const g = String(req.query.genre).toLowerCase();
|
||||
items = items.filter((s) => (s.genres || []).some((x) => x.toLowerCase().includes(g)));
|
||||
}
|
||||
res.json({
|
||||
total: items.length,
|
||||
items: items.slice(0, limit).map(publicStation)
|
||||
});
|
||||
const limit = Math.min(Number(req.query.limit) || 200, 1000);
|
||||
let items = listStations({
|
||||
q: req.query.q || undefined,
|
||||
category: req.query.category || undefined,
|
||||
enabled: req.query.all ? null : true
|
||||
});
|
||||
if (req.query.country) {
|
||||
const c = String(req.query.country).toUpperCase();
|
||||
items = items.filter((s) => (s.country || '').toUpperCase() === c);
|
||||
}
|
||||
if (req.query.genre) {
|
||||
const g = String(req.query.genre).toLowerCase();
|
||||
items = items.filter((s) => (s.genres || []).some((x) => x.toLowerCase().includes(g)));
|
||||
}
|
||||
const statsMap = getStatsMap(null);
|
||||
for (const s of items) {
|
||||
const st = statsMap.get(s.id) || { up: 0, down: 0, plays: 0, score: 0 };
|
||||
s.up = st.up; s.down = st.down; s.plays = st.plays; s.score = st.score;
|
||||
}
|
||||
sortByMode(items, req.query.sort, statsMap);
|
||||
res.json({
|
||||
total: items.length,
|
||||
items: items.slice(0, limit).map(publicStation)
|
||||
});
|
||||
});
|
||||
|
||||
// Pick a random enabled station. Optional filters narrow the pool.
|
||||
// `redirect=stream` issues a 302 to the resolved stream URL — handy for
|
||||
// `mpv http://host/api/v1/stations/random?redirect=stream`.
|
||||
router.get('/stations/random', async (req, res) => {
|
||||
let items = listStations({
|
||||
category: req.query.category || undefined,
|
||||
enabled: true
|
||||
});
|
||||
if (req.query.country) {
|
||||
const c = String(req.query.country).toUpperCase();
|
||||
items = items.filter((s) => (s.country || '').toUpperCase() === c);
|
||||
}
|
||||
if (req.query.genre) {
|
||||
const g = String(req.query.genre).toLowerCase();
|
||||
items = items.filter((s) => (s.genres || []).some((x) => x.toLowerCase().includes(g)));
|
||||
}
|
||||
if (!items.length) return res.status(404).json({ error: 'no stations match' });
|
||||
const pick = items[Math.floor(Math.random() * items.length)];
|
||||
Object.assign(pick, getStationStats(pick.id, null));
|
||||
|
||||
if (req.query.redirect === 'stream') {
|
||||
const streams = getStreamsForStation(pick.id);
|
||||
if (!streams.length) return res.status(404).json({ error: 'no streams' });
|
||||
const ordered = [...streams].sort((a, b) => {
|
||||
const au = a.last_status === 'up' ? 0 : 1;
|
||||
const bu = b.last_status === 'up' ? 0 : 1;
|
||||
return au - bu || a.priority - b.priority;
|
||||
});
|
||||
const resolved = await resolveStream({ url: ordered[0].url, format: ordered[0].format });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('X-Station-Uuid', pick.uuid);
|
||||
res.set('X-Station-Name', encodeURIComponent(pick.name));
|
||||
return res.redirect(302, resolved.url);
|
||||
}
|
||||
|
||||
const out = publicStation(pick);
|
||||
out.streams = getStreamsForStation(pick.id).map(publicStream);
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
router.get('/stations/:uuid', (req, res) => {
|
||||
const s = getStationByUuid(req.params.uuid);
|
||||
if (!s) return res.status(404).json({ error: 'not found' });
|
||||
const out = publicStation(s);
|
||||
out.streams = getStreamsForStation(s.id).map(publicStream);
|
||||
res.json(out);
|
||||
const s = getStationByUuid(req.params.uuid);
|
||||
if (!s) return res.status(404).json({ error: 'not found' });
|
||||
Object.assign(s, getStationStats(s.id, null));
|
||||
const out = publicStation(s);
|
||||
out.streams = getStreamsForStation(s.id).map(publicStream);
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
// 302 redirect to the resolved stream URL. Pure convenience for CLI players
|
||||
// (`mpv http://host/api/v1/stations/<uuid>/stream`) and smart-home scripts.
|
||||
router.get('/stations/:uuid/stream', async (req, res) => {
|
||||
const s = getStationByUuid(req.params.uuid);
|
||||
if (!s) return res.status(404).json({ error: 'station not found' });
|
||||
let streams = getStreamsForStation(s.id);
|
||||
if (!streams.length) return res.status(404).json({ error: 'no streams' });
|
||||
const s = getStationByUuid(req.params.uuid);
|
||||
if (!s) return res.status(404).json({ error: 'station not found' });
|
||||
let streams = getStreamsForStation(s.id);
|
||||
if (!streams.length) return res.status(404).json({ error: 'no streams' });
|
||||
|
||||
if (req.query.format) {
|
||||
const fmt = String(req.query.format).toLowerCase();
|
||||
const filtered = streams.filter((x) => x.format === fmt);
|
||||
if (filtered.length) streams = filtered;
|
||||
}
|
||||
// Prefer streams known to be up; fall back to priority order otherwise.
|
||||
const ordered = [...streams].sort((a, b) => {
|
||||
const au = a.last_status === 'up' ? 0 : 1;
|
||||
const bu = b.last_status === 'up' ? 0 : 1;
|
||||
return au - bu || a.priority - b.priority;
|
||||
});
|
||||
const pick = ordered[0];
|
||||
const resolved = await resolveStream({ url: pick.url, format: pick.format });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.redirect(302, resolved.url);
|
||||
if (req.query.format) {
|
||||
const fmt = String(req.query.format).toLowerCase();
|
||||
const filtered = streams.filter((x) => x.format === fmt);
|
||||
if (filtered.length) streams = filtered;
|
||||
}
|
||||
// Prefer streams known to be up; fall back to priority order otherwise.
|
||||
const ordered = [...streams].sort((a, b) => {
|
||||
const au = a.last_status === 'up' ? 0 : 1;
|
||||
const bu = b.last_status === 'up' ? 0 : 1;
|
||||
return au - bu || a.priority - b.priority;
|
||||
});
|
||||
const pick = ordered[0];
|
||||
const resolved = await resolveStream({ url: pick.url, format: pick.format });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.redirect(302, resolved.url);
|
||||
});
|
||||
|
||||
router.get('/stations/:uuid/streams/:streamUuid', async (req, res) => {
|
||||
const station = getStationByUuid(req.params.uuid);
|
||||
if (!station) return res.status(404).json({ error: 'station not found' });
|
||||
const stream = getStreamByUuid(req.params.streamUuid);
|
||||
if (!stream || stream.station_id !== station.id) return res.status(404).json({ error: 'stream not found' });
|
||||
if (req.query.redirect === '0') {
|
||||
return res.json(publicStream(stream));
|
||||
}
|
||||
const resolved = await resolveStream({ url: stream.url, format: stream.format });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.redirect(302, resolved.url);
|
||||
const station = getStationByUuid(req.params.uuid);
|
||||
if (!station) return res.status(404).json({ error: 'station not found' });
|
||||
const stream = getStreamByUuid(req.params.streamUuid);
|
||||
if (!stream || stream.station_id !== station.id) return res.status(404).json({ error: 'stream not found' });
|
||||
if (req.query.redirect === '0') {
|
||||
return res.json(publicStream(stream));
|
||||
}
|
||||
const resolved = await resolveStream({ url: stream.url, format: stream.format });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.redirect(302, resolved.url);
|
||||
});
|
||||
|
||||
// Reject any non-GET method explicitly so the public surface can never be
|
||||
|
||||
Reference in New Issue
Block a user