added lite version
This commit is contained in:
@@ -9,6 +9,14 @@ import { dirname, resolve } from 'node:path';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
// AbortErrors from undici/node-fetch can escape async route handlers when a
|
||||||
|
// client disconnects mid-stream (station switch). They are benign — the socket
|
||||||
|
// is already gone. Log everything else so genuine bugs are still visible.
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
if (reason?.name === 'AbortError') return;
|
||||||
|
console.error('[electron] unhandledRejection:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
// Pick data location BEFORE importing the server so its module-level path
|
// Pick data location BEFORE importing the server so its module-level path
|
||||||
|
|||||||
BIN
node_modules.zip
Normal file
BIN
node_modules.zip
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
1
server/public/assets/master-x_buNpFo.js
Normal file
1
server/public/assets/master-x_buNpFo.js
Normal file
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Radio Master</title>
|
<title>Radio Master</title>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/master-x_buNpFo.js"></script>
|
<script type="module" crossorigin src="/assets/master-x_buNpFo.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/debug-B-FoNBZ5.js">
|
<link rel="modulepreload" crossorigin href="/assets/debug-B-FoNBZ5.js">
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ router.post('/:id/play/end', requireUser, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/resolve', requireUser, async (req, res) => {
|
router.post('/:id/resolve', requireUser, async (req, res) => {
|
||||||
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const streams = getStreamsForStation(id);
|
const streams = getStreamsForStation(id);
|
||||||
if (!streams.length) return res.status(404).json({ error: 'no streams' });
|
if (!streams.length) return res.status(404).json({ error: 'no streams' });
|
||||||
@@ -99,6 +100,10 @@ router.post('/:id/resolve', requireUser, async (req, res) => {
|
|||||||
if (!preferred) return res.status(404).json({ error: 'stream not found' });
|
if (!preferred) return res.status(404).json({ error: 'stream not found' });
|
||||||
const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
|
const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
|
||||||
res.json({ stream: preferred, resolved });
|
res.json({ stream: preferred, resolved });
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'AbortError' || res.headersSent) return;
|
||||||
|
res.status(500).json({ error: 'resolve failed' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Same-origin streaming proxy. Adds the CORS headers Icecast/SHOUTcast servers
|
// Same-origin streaming proxy. Adds the CORS headers Icecast/SHOUTcast servers
|
||||||
@@ -116,8 +121,14 @@ router.get('/:id/proxy', requireUser, async (req, res) => {
|
|||||||
const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
|
const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
|
||||||
if (resolved.format === 'hls') return res.status(415).json({ error: 'hls not proxied' });
|
if (resolved.format === 'hls') return res.status(415).json({ error: 'hls not proxied' });
|
||||||
|
|
||||||
|
// Use an AbortController only for the fetch-connection phase. Once the
|
||||||
|
// upstream connection is established the listener is removed and streaming
|
||||||
|
// teardown switches to reader.cancel(). Keeping the controller active after
|
||||||
|
// fetch() resolves causes undici to emit an unhandled AbortError on the
|
||||||
|
// response-body stream when the client disconnects.
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
req.on('close', () => { try { controller.abort(); } catch { } });
|
const abortFetch = () => { try { controller.abort(); } catch { } };
|
||||||
|
req.once('close', abortFetch);
|
||||||
|
|
||||||
let upstream;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
@@ -127,10 +138,14 @@ router.get('/:id/proxy', requireUser, async (req, res) => {
|
|||||||
headers: { 'User-Agent': 'oradio-kiosk/1.0', 'Icy-MetaData': '0' }
|
headers: { 'User-Agent': 'oradio-kiosk/1.0', 'Icy-MetaData': '0' }
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') { res.end(); return; }
|
req.off('close', abortFetch);
|
||||||
|
if (err?.name === 'AbortError') { res.end(); return; }
|
||||||
if (!res.headersSent) res.status(502).json({ error: `upstream: ${err.message || err}` });
|
if (!res.headersSent) res.status(502).json({ error: `upstream: ${err.message || err}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Fetch resolved — detach the abort listener so controller.abort() is
|
||||||
|
// never called on an already-resolved fetch.
|
||||||
|
req.off('close', abortFetch);
|
||||||
if (!upstream.ok || !upstream.body) {
|
if (!upstream.ok || !upstream.body) {
|
||||||
return res.status(502).json({ error: `upstream HTTP ${upstream.status}` });
|
return res.status(502).json({ error: `upstream HTTP ${upstream.status}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,6 +338,17 @@ function handleCommand(msg) {
|
|||||||
if (id === state.np.stationId && (state.np.playing || state.np.loading)) return;
|
if (id === state.np.stationId && (state.np.playing || state.np.loading)) return;
|
||||||
if (id === _pendingStationId) return;
|
if (id === _pendingStationId) return;
|
||||||
_pendingStationId = id;
|
_pendingStationId = id;
|
||||||
|
// Stop old audio immediately — mirror the UI path where the station
|
||||||
|
// object is already available and player.stop() fires synchronously
|
||||||
|
// inside player.play(). Without this, the old stream keeps playing
|
||||||
|
// for the full round-trip of the metadata fetch below.
|
||||||
|
endCurrentSession();
|
||||||
|
try { player.stop(); } catch { /* ignore */ }
|
||||||
|
state.np.playing = false;
|
||||||
|
state.np.loading = true;
|
||||||
|
state.np.station = null;
|
||||||
|
state.np.stationId = id;
|
||||||
|
render();
|
||||||
const gen = ++_cmdGen;
|
const gen = ++_cmdGen;
|
||||||
api.get(`/api/stations/${id}`).then((st) => {
|
api.get(`/api/stations/${id}`).then((st) => {
|
||||||
// Bail if a newer command superseded this one while the
|
// Bail if a newer command superseded this one while the
|
||||||
|
|||||||
Reference in New Issue
Block a user