// 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'; 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(); }); // 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, 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 }; } 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))); } 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' }); 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//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' }));