import { Router } from 'express'; import { listStations, getStation, getStreamsForStation, createStation, updateStation, deleteStation, addStream, deleteStream } from '../stations.js'; import { resolveStream } from '../streams/resolver.js'; import { requireAdmin, requireUser } from '../auth.js'; import * as radiobrowser from '../sources/radiobrowser.js'; import { castVote, getStationStats, getStatsMap, recordPlay, endPlaySession, sortByMode } from '../stats.js'; import { broadcastGlobal } from '../ws.js'; export const router = Router(); router.get('/', (req, res) => { const stations = listStations({ q: req.query.q || undefined, source: req.query.source || undefined, enabled: req.query.all ? null : true }); const statsMap = getStatsMap(req.user?.id || null); for (const s of stations) { const st = statsMap.get(s.id) || { up: 0, down: 0, plays: 0, myVote: 0, score: 0 }; s.up = st.up; s.down = st.down; s.plays = st.plays; s.my_vote = st.myVote; s.score = st.score; } sortByMode(stations, req.query.sort, statsMap); res.json(stations); }); router.get('/:id', (req, res) => { const id = Number(req.params.id); const station = getStation(id); if (!station) return res.status(404).json({ error: 'not found' }); station.streams = getStreamsForStation(id); Object.assign(station, getStationStats(id, req.user?.id || null)); res.json(station); }); // --- voting --- router.get('/:id/votes', (req, res) => { const id = Number(req.params.id); if (!getStation(id)) return res.status(404).json({ error: 'not found' }); res.json(getStationStats(id, req.user?.id || null)); }); router.post('/:id/vote', requireUser, (req, res) => { const id = Number(req.params.id); if (!getStation(id)) return res.status(404).json({ error: 'not found' }); const raw = req.body?.value; const value = raw === 1 || raw === '1' || raw === 'up' ? 1 : raw === -1 || raw === '-1' || raw === 'down' ? -1 : raw === 0 || raw === '0' || raw === null || raw === 'clear' ? 0 : NaN; if (Number.isNaN(value)) return res.status(400).json({ error: 'value must be 1, -1 or 0' }); const stats = castVote(req.user.id, id, value); // Tell every open client so other panels' vote counts update live. broadcastGlobal({ type: 'vote', stationId: id, stats: { up: stats.up, down: stats.down, score: stats.score }, by: req.user.username }); res.json(stats); }); // Lightweight play-count ping (called when the kiosk actually starts a station). // Opens a listening session in play_history; the returned `sessionId` should be // echoed back to POST /api/stations/:id/play/end so we can credit total listen // time toward the leaderboard score. router.post('/:id/play', requireUser, (req, res) => { const id = Number(req.params.id); if (!getStation(id)) return res.status(404).json({ error: 'not found' }); const streamId = Number.isFinite(Number(req.body?.streamId)) ? Number(req.body.streamId) : null; const sessionId = recordPlay(id, req.user.id, streamId); const stats = getStationStats(id, req.user.id); broadcastGlobal({ type: 'plays', stationId: id, plays: stats.plays }); res.json({ ...stats, sessionId }); }); // Close a session opened by POST /:id/play. Idempotent — calling twice or with // an unknown id silently no-ops. Accepts an optional `duration_ms` so a client // that knows the real listened time (e.g. minus buffering stalls) can be honest. router.post('/:id/play/end', requireUser, (req, res) => { const id = Number(req.params.id); if (!getStation(id)) return res.status(404).json({ error: 'not found' }); const sessionId = Number(req.body?.sessionId); if (!Number.isFinite(sessionId)) return res.status(400).json({ error: 'sessionId required' }); const rawMs = req.body?.duration_ms; const ms = rawMs == null ? null : Number(rawMs); const closed = endPlaySession(sessionId, req.user.id, ms); if (closed == null) return res.json({ ok: false }); const stats = getStationStats(closed, req.user.id); broadcastGlobal({ type: 'plays', stationId: closed, plays: stats.plays }); res.json({ ok: true, ...stats }); }); router.post('/:id/resolve', requireUser, async (req, res) => { const id = Number(req.params.id); const streams = getStreamsForStation(id); if (!streams.length) return res.status(404).json({ error: 'no streams' }); const preferred = req.body?.streamId ? streams.find((s) => s.id === Number(req.body.streamId)) : streams[0]; if (!preferred) return res.status(404).json({ error: 'stream not found' }); const resolved = await resolveStream({ url: preferred.url, format: preferred.format }); res.json({ stream: preferred, resolved }); }); // Same-origin streaming proxy. Adds the CORS headers Icecast/SHOUTcast servers // almost never send, which lets the kiosk wire a Web-Audio AnalyserNode for a // real spectrum. HLS is excluded — the manifest plus every segment would need // rewriting; clients fall back to the direct URL with no analyser there. router.get('/:id/proxy', requireUser, async (req, res) => { const id = Number(req.params.id); const streams = getStreamsForStation(id); if (!streams.length) return res.status(404).json({ error: 'no streams' }); const preferred = req.query.streamId ? streams.find((s) => s.id === Number(req.query.streamId)) : streams[0]; if (!preferred) return res.status(404).json({ error: 'stream not found' }); const resolved = await resolveStream({ url: preferred.url, format: preferred.format }); if (resolved.format === 'hls') return res.status(415).json({ error: 'hls not proxied' }); const controller = new AbortController(); req.on('close', () => controller.abort()); let upstream; try { upstream = await fetch(resolved.url, { redirect: 'follow', signal: controller.signal, headers: { 'User-Agent': 'oradio-kiosk/1.0', 'Icy-MetaData': '0' } }); } catch (err) { return res.status(502).json({ error: `upstream: ${err.message || err}` }); } if (!upstream.ok || !upstream.body) { return res.status(502).json({ error: `upstream HTTP ${upstream.status}` }); } const ct = upstream.headers.get('content-type') || guessContentType(resolved.format); res.status(200); res.set('Content-Type', ct); res.set('Cache-Control', 'no-store'); res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Expose-Headers', 'Content-Type'); // Pipe the WHATWG ReadableStream into the Express response. const reader = upstream.body.getReader(); const pump = async () => { try { while (true) { const { value, done } = await reader.read(); if (done) break; if (!res.write(Buffer.from(value))) { await new Promise((r) => res.once('drain', r)); } } } catch { /* client disconnect or upstream abort */ } finally { try { reader.cancel(); } catch { } res.end(); } }; pump(); }); function guessContentType(format) { switch (format) { case 'mp3': return 'audio/mpeg'; case 'aac': return 'audio/aac'; case 'ogg': return 'audio/ogg'; default: return 'application/octet-stream'; } } // --- admin mutations --- router.post('/', requireAdmin, (req, res) => { const station = createStation({ ...req.body, source: req.body.source || 'manual' }, req.user.id); res.status(201).json(station); }); router.patch('/:id', requireAdmin, (req, res) => { const station = updateStation(Number(req.params.id), req.body || {}); if (!station) return res.status(404).json({ error: 'not found' }); res.json(station); }); router.delete('/:id', requireAdmin, (req, res) => { if (!deleteStation(Number(req.params.id))) return res.status(404).json({ error: 'not found' }); res.json({ ok: true }); }); router.post('/:id/streams', requireAdmin, (req, res) => { const stream = addStream(Number(req.params.id), req.body || {}); res.status(201).json(stream); }); router.delete('/:id/streams/:streamId', requireAdmin, (req, res) => { if (!deleteStream(Number(req.params.streamId))) return res.status(404).json({ error: 'not found' }); res.json({ ok: true }); }); // --- Radio-Browser passthrough for the admin importer --- router.get('/sources/radiobrowser/search', requireAdmin, async (req, res) => { const results = await radiobrowser.search({ name: req.query.q, country: req.query.country, tag: req.query.tag, limit: Number(req.query.limit) || 30 }); res.json(results); }); router.post('/sources/radiobrowser/import', requireAdmin, (req, res) => { const station = createStation({ ...req.body, source: 'radiobrowser' }, req.user.id); res.status(201).json(station); });