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:
73
server/routes/admin.js
Normal file
73
server/routes/admin.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAdmin } from '../auth.js';
|
||||
import { runHealthCheck } from '../streams/checker.js';
|
||||
import { applySeedIfEmpty } from '../sources/seed.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
import { scrapeIcon } from '../sources/iconScraper.js';
|
||||
import { listStations, getStation, updateStation } from '../stations.js';
|
||||
|
||||
export const router = Router();
|
||||
router.use(requireAdmin);
|
||||
|
||||
router.post('/health-check', async (_req, res) => {
|
||||
const n = await runHealthCheck();
|
||||
res.json({ checked: n });
|
||||
});
|
||||
|
||||
router.post('/reseed', (_req, res) => {
|
||||
res.json(applySeedIfEmpty());
|
||||
});
|
||||
|
||||
router.get('/system', (_req, res) => {
|
||||
const db = getDb();
|
||||
res.json({
|
||||
stations: db.prepare('SELECT COUNT(*) AS n FROM stations').get().n,
|
||||
streams: db.prepare('SELECT COUNT(*) AS n FROM streams').get().n,
|
||||
users: db.prepare('SELECT COUNT(*) AS n FROM users').get().n,
|
||||
favorites: db.prepare('SELECT COUNT(*) AS n FROM favorites').get().n,
|
||||
node: process.version,
|
||||
uptime_s: Math.round(process.uptime())
|
||||
});
|
||||
});
|
||||
|
||||
// Scrape an icon for a single station.
|
||||
router.post('/stations/:id/scrape-icon', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const st = getStation(id);
|
||||
if (!st) return res.status(404).json({ error: 'not found' });
|
||||
const url = await scrapeIcon(st);
|
||||
if (!url) return res.status(404).json({ error: 'no icon found' });
|
||||
const updated = updateStation(id, { image_url: url });
|
||||
res.json({ id, image_url: url, station: updated });
|
||||
});
|
||||
|
||||
// Bulk: scrape icons for every station (optionally only those missing one).
|
||||
router.post('/scrape-icons', async (req, res) => {
|
||||
const onlyMissing = req.query.all !== '1';
|
||||
const stations = listStations({ enabled: null }).filter((s) => !onlyMissing || !s.image_url);
|
||||
const results = { total: stations.length, updated: 0, skipped: 0, failed: 0, items: [] };
|
||||
// Limit concurrency to avoid hammering hosts.
|
||||
const concurrency = 4;
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < stations.length) {
|
||||
const s = stations[i++];
|
||||
try {
|
||||
const url = await scrapeIcon(s);
|
||||
if (url) {
|
||||
updateStation(s.id, { image_url: url });
|
||||
results.updated++;
|
||||
results.items.push({ id: s.id, name: s.name, image_url: url });
|
||||
} else {
|
||||
results.failed++;
|
||||
results.items.push({ id: s.id, name: s.name, image_url: null });
|
||||
}
|
||||
} catch (err) {
|
||||
results.failed++;
|
||||
results.items.push({ id: s.id, name: s.name, error: String(err?.message || err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
res.json(results);
|
||||
});
|
||||
71
server/routes/auth.js
Normal file
71
server/routes/auth.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
verifyPassword, createSession, destroySession, setSessionCookie, clearSessionCookie,
|
||||
hashPassword, requireAdmin
|
||||
} from '../auth.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) return res.status(400).json({ error: 'username + password required' });
|
||||
const user = getDb().prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'invalid credentials' });
|
||||
}
|
||||
const { token, expires } = createSession(user.id);
|
||||
setSessionCookie(res, token, expires);
|
||||
res.json({ id: user.id, username: user.username, role: user.role });
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
destroySession(req.session?.token);
|
||||
clearSessionCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/me', (req, res) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'not signed in' });
|
||||
res.json(req.user);
|
||||
});
|
||||
|
||||
// Admin-only user management
|
||||
router.get('/users', requireAdmin, (_req, res) => {
|
||||
const users = getDb().prepare('SELECT id, username, role, created_at FROM users ORDER BY username').all();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
router.post('/users', requireAdmin, (req, res) => {
|
||||
const { username, password, role = 'user' } = req.body || {};
|
||||
if (!username || !password) return res.status(400).json({ error: 'username + password required' });
|
||||
if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'bad role' });
|
||||
try {
|
||||
const info = getDb().prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
||||
.run(username, hashPassword(password), role);
|
||||
getDb().prepare('INSERT INTO profiles (user_id, display_name) VALUES (?, ?)')
|
||||
.run(info.lastInsertRowid, username);
|
||||
res.status(201).json({ id: info.lastInsertRowid, username, role });
|
||||
} catch (err) {
|
||||
if (String(err).includes('UNIQUE')) return res.status(409).json({ error: 'username taken' });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/users/:id', requireAdmin, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const { password, role } = req.body || {};
|
||||
const db = getDb();
|
||||
if (password) db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id);
|
||||
if (role && ['admin', 'user'].includes(role)) {
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/users/:id', requireAdmin, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (id === req.user.id) return res.status(400).json({ error: 'cannot delete self' });
|
||||
getDb().prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
63
server/routes/me.js
Normal file
63
server/routes/me.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Router } from 'express';
|
||||
import { requireUser } from '../auth.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.use(requireUser);
|
||||
|
||||
router.get('/favorites', (req, res) => {
|
||||
const rows = getDb().prepare(`
|
||||
SELECT s.*, f.position
|
||||
FROM favorites f JOIN stations s ON s.id = f.station_id
|
||||
WHERE f.user_id = ? AND s.enabled = 1
|
||||
ORDER BY f.position ASC, f.created_at ASC
|
||||
`).all(req.user.id);
|
||||
res.json(rows.map((r) => ({
|
||||
id: r.id, uuid: r.uuid, name: r.name, slug: r.slug, homepage: r.homepage, country: r.country,
|
||||
genres: r.genres ? JSON.parse(r.genres) : [], image_url: r.image_url, category: r.category, position: r.position
|
||||
})));
|
||||
});
|
||||
|
||||
router.put('/favorites/:stationId', (req, res) => {
|
||||
const stationId = Number(req.params.stationId);
|
||||
const position = Number(req.body?.position ?? 0);
|
||||
getDb().prepare(`
|
||||
INSERT INTO favorites (user_id, station_id, position) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, station_id) DO UPDATE SET position = excluded.position
|
||||
`).run(req.user.id, stationId, position);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/favorites/:stationId', (req, res) => {
|
||||
getDb().prepare('DELETE FROM favorites WHERE user_id = ? AND station_id = ?')
|
||||
.run(req.user.id, Number(req.params.stationId));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/profile', (req, res) => {
|
||||
const row = getDb().prepare('SELECT * FROM profiles WHERE user_id = ?').get(req.user.id);
|
||||
res.json(row || { user_id: req.user.id, display_name: req.user.username, theme: 'dark', default_volume: 0.7 });
|
||||
});
|
||||
|
||||
router.patch('/profile', (req, res) => {
|
||||
const { display_name, theme, default_volume } = req.body || {};
|
||||
getDb().prepare(`
|
||||
INSERT INTO profiles (user_id, display_name, theme, default_volume) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name = COALESCE(excluded.display_name, profiles.display_name),
|
||||
theme = COALESCE(excluded.theme, profiles.theme),
|
||||
default_volume = COALESCE(excluded.default_volume, profiles.default_volume)
|
||||
`).run(req.user.id, display_name ?? null, theme ?? null, default_volume ?? null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/history', (req, res) => {
|
||||
const rows = getDb().prepare(`
|
||||
SELECT h.*, s.name AS station_name, s.slug AS station_slug
|
||||
FROM play_history h JOIN stations s ON s.id = h.station_id
|
||||
WHERE h.user_id = ?
|
||||
ORDER BY h.started_at DESC LIMIT 50
|
||||
`).all(req.user.id);
|
||||
res.json(rows);
|
||||
});
|
||||
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);
|
||||
});
|
||||
169
server/routes/v1.js
Normal file
169
server/routes/v1.js
Normal file
@@ -0,0 +1,169 @@
|
||||
// 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' }));
|
||||
Reference in New Issue
Block a user