Files
radio-explorer/server/routes/v1.js
Marco Mooren e0a60f7b64 Add player functionality with HLS support and API integration
- Implemented a new Player class in player.js to handle audio playback, including HLS support using hls.js.
- Created a shared API module in api.js for making HTTP requests with proper error handling.
- Added DOM utility functions in dom.js for creating and clearing elements.
- Introduced WebSocket connection handling in ws.js for real-time updates.
- Developed a comprehensive CSS stylesheet for styling the application, including a high-contrast theme.
2026-05-10 14:43:00 +02:00

170 lines
5.8 KiB
JavaScript

// Public read-only API mounted at /api/v1.
// Stable per-station UUIDs let third-party tools (mpv, smart-home, scripts)
// reference stations independently of internal numeric IDs.
import { Router } from 'express';
import {
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';
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();
});
// Tiny in-memory token bucket per IP. 120 req/min is plenty for human use
// and clearly throttles a runaway script. Resets on process restart.
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();
});
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
};
}
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
};
}
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 });
});
router.get('/categories', (_req, res) => {
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);
});
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)
});
});
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);
});
// 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' });
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);
});
// Reject any non-GET method explicitly so the public surface can never be
// abused for mutations even if a bug ever wires one in.
router.all('*', (_req, res) => res.status(405).json({ error: 'method not allowed' }));