220 lines
9.3 KiB
JavaScript
220 lines
9.3 KiB
JavaScript
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', () => { try { controller.abort(); } catch { } });
|
|
|
|
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) {
|
|
if (err.name === 'AbortError') { res.end(); return; }
|
|
if (!res.headersSent) res.status(502).json({ error: `upstream: ${err.message || err}` });
|
|
return;
|
|
}
|
|
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.
|
|
// We cancel the reader directly on client-close — equivalent to aborting
|
|
// the fetch but without the AbortController rejection that escapes the
|
|
// async route in older Node/Electron versions.
|
|
const reader = upstream.body.getReader();
|
|
req.on('close', () => { reader.cancel().catch(() => { }); });
|
|
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 error */ }
|
|
finally {
|
|
try { reader.cancel(); } catch { }
|
|
try { res.end(); } catch { }
|
|
}
|
|
};
|
|
pump().catch(() => { });
|
|
});
|
|
|
|
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);
|
|
});
|