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.
This commit is contained in:
150
server/routes/stations.js
Normal file
150
server/routes/stations.js
Normal file
@@ -0,0 +1,150 @@
|
||||
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';
|
||||
|
||||
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
|
||||
});
|
||||
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);
|
||||
res.json(station);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user