fixed API and stopping delay
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
||||
import { getDb } from './db/index.js';
|
||||
|
||||
const SESSION_DAYS = 30;
|
||||
@@ -66,7 +66,82 @@ function appendSetCookieRaw(res, value) {
|
||||
else res.setHeader('Set-Cookie', [prev, value]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API key auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hashApiKey(key) {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
export function createApiKey(userId, label = '') {
|
||||
const key = 'oradio_' + randomBytes(32).toString('hex');
|
||||
getDb().prepare('INSERT INTO api_keys (key_hash, label, user_id) VALUES (?, ?, ?)')
|
||||
.run(hashApiKey(key), label, userId);
|
||||
return key; // shown exactly once — never stored in plain text
|
||||
}
|
||||
|
||||
export function getUserByApiKey(key) {
|
||||
if (!key || typeof key !== 'string') return null;
|
||||
// Constant-time-safe: derive the hash first, then look it up by hash.
|
||||
const hash = hashApiKey(key);
|
||||
const row = getDb().prepare(`
|
||||
SELECT u.id, u.username, u.role, u.is_main, u.avatar_color, u.avatar_emoji,
|
||||
ak.id AS ak_id
|
||||
FROM api_keys ak JOIN users u ON u.id = ak.user_id
|
||||
WHERE ak.key_hash = ?
|
||||
`).get(hash);
|
||||
if (!row) return null;
|
||||
// Bump last_used_at asynchronously so auth stays fast
|
||||
setImmediate(() => {
|
||||
getDb().prepare('UPDATE api_keys SET last_used_at = datetime(\'now\') WHERE id = ?')
|
||||
.run(row.ak_id);
|
||||
});
|
||||
const { ak_id: _drop, ...user } = row;
|
||||
return user;
|
||||
}
|
||||
|
||||
export function listApiKeys() {
|
||||
return getDb().prepare(
|
||||
'SELECT ak.id, ak.label, ak.created_at, ak.last_used_at, u.username ' +
|
||||
'FROM api_keys ak JOIN users u ON u.id = ak.user_id ORDER BY ak.id'
|
||||
).all();
|
||||
}
|
||||
|
||||
export function revokeApiKey(id) {
|
||||
getDb().prepare('DELETE FROM api_keys WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// Registers a raw key from the environment (e.g. ORADIO_API_KEY) so it can
|
||||
// be used immediately without going through the admin UI. Idempotent.
|
||||
export function ensureBootstrapApiKey(rawKey) {
|
||||
if (!rawKey) return;
|
||||
const db = getDb();
|
||||
const hash = hashApiKey(rawKey);
|
||||
if (db.prepare('SELECT 1 FROM api_keys WHERE key_hash = ?').get(hash)) return;
|
||||
const main = db.prepare('SELECT id FROM users WHERE is_main = 1').get()
|
||||
|| db.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get();
|
||||
if (!main) return;
|
||||
db.prepare('INSERT INTO api_keys (key_hash, label, user_id) VALUES (?, ?, ?)')
|
||||
.run(hash, 'env-bootstrap', main.id);
|
||||
console.log('[auth] bootstrap API key registered from ORADIO_API_KEY');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function authMiddleware(req, _res, next) {
|
||||
// 1. API key: X-Api-Key header or Authorization: Bearer <key>
|
||||
const rawAuth = req.headers.authorization || '';
|
||||
const apiKey = req.headers['x-api-key']
|
||||
|| (rawAuth.startsWith('Bearer ') ? rawAuth.slice(7) : null);
|
||||
if (apiKey) {
|
||||
req.session = {};
|
||||
req.user = getUserByApiKey(apiKey);
|
||||
return next();
|
||||
}
|
||||
// 2. Session cookie (browser UI)
|
||||
const token = readSessionToken(req);
|
||||
req.session = { token };
|
||||
req.user = getUserBySession(token);
|
||||
|
||||
22
server/companion.js
Normal file
22
server/companion.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// Bitfocus Companion integration.
|
||||
// Keeps the `currentRadio` custom variable on the Companion instance
|
||||
// in sync with the currently playing station.
|
||||
//
|
||||
// Set COMPANION_URL in your .env to override the default host/port.
|
||||
// e.g. COMPANION_URL=http://10.99.0.110:8000
|
||||
|
||||
const BASE = process.env.COMPANION_URL || 'http://10.99.0.110:8000';
|
||||
const VARIABLE_PATH = '/api/custom-variable/currentRadio/value';
|
||||
|
||||
/**
|
||||
* Push a new value to Companion's `currentRadio` custom variable.
|
||||
* @param {number|null} stationId Pass a numeric station id when playing;
|
||||
* pass null/undefined to clear (stop/pause).
|
||||
*/
|
||||
export function notifyCompanion(stationId) {
|
||||
const value = stationId != null ? String(stationId) : '';
|
||||
const url = `${BASE}${VARIABLE_PATH}?value=${encodeURIComponent(value)}`;
|
||||
fetch(url, { method: 'POST' }).catch((err) => {
|
||||
console.warn('[companion] notify failed:', err.message);
|
||||
});
|
||||
}
|
||||
@@ -158,3 +158,15 @@ CREATE TABLE IF NOT EXISTS room_state (
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- API keys for programmatic / script / StreamDeck access.
|
||||
-- The actual key is shown only once at creation; only a SHA-256 hash is stored.
|
||||
-- Send via: X-Api-Key: <key> or Authorization: Bearer <key>
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT
|
||||
);
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Radio Admin</title>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/admin-DN0aiXMa.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="stylesheet" crossorigin href="/assets/admin-C-qnWY0z.css">
|
||||
</head>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
server/public/assets/kiosk-Cc8AKKkv.js
Normal file
1
server/public/assets/kiosk-Cc8AKKkv.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
server/public/assets/master-Bb7HGec-.js
Normal file
1
server/public/assets/master-Bb7HGec-.js
Normal file
File diff suppressed because one or more lines are too long
1
server/public/assets/playGate-BFH7IScH.js
Normal file
1
server/public/assets/playGate-BFH7IScH.js
Normal file
@@ -0,0 +1 @@
|
||||
function x(l,t={}){let e,i=0,r=!1;function m(){const h=location.protocol==="https:"?"wss":"ws",o=new URLSearchParams;t.room&&o.set("room",t.room),t.kind&&o.set("kind",t.kind);const c=o.toString();e=new WebSocket(`${h}://${location.host}/ws${c?"?"+c:""}`),e.addEventListener("open",()=>{var n;i=0,(n=t.onOpen)==null||n.call(t)}),e.addEventListener("message",n=>{try{l(JSON.parse(n.data))}catch{}}),e.addEventListener("close",()=>{var n;(n=t.onClose)==null||n.call(t),!r&&(i=Math.min(i+1,6),setTimeout(m,500*2**i))}),e.addEventListener("error",()=>e.close())}return m(),{send(h){return(e==null?void 0:e.readyState)!==WebSocket.OPEN?!1:(e.send(JSON.stringify(h)),!0)},close(){r=!0,e==null||e.close()},get readyState(){return e==null?void 0:e.readyState}}}const w=1e3,N=5e3,C=5,I=8,v=16;function S(l){if(!l.length)return 0;const t=l.slice().sort((i,r)=>i-r),e=t.length>>1;return t.length%2?t[e]:(t[e-1]+t[e])/2}class M{constructor(){this.offset=0,this.rtt=1/0,this.offsetStd=1/0,this.samples=[],this.synced=!1,this._pending=new Map,this._listeners=new Set,this._timeoutId=null,this._ws=null}attachWs(t){this._ws=t,this.reset();let e=0;const i=()=>{if(e++>=5){this._scheduleNext();return}this._sendPing(),setTimeout(i,150)};i()}detach(){this._timeoutId&&clearTimeout(this._timeoutId),this._timeoutId=null,this._pending.clear(),this._ws=null}reset(){this.samples=[],this.synced=!1,this.offsetStd=1/0,this._pending.clear(),this._timeoutId&&(clearTimeout(this._timeoutId),this._timeoutId=null)}now(){return Date.now()+this.offset}isStable(){return this.synced&&this.samples.length>=I&&this.offsetStd<=C}onUpdate(t){return this._listeners.add(t),()=>this._listeners.delete(t)}handlePong(t){if(this._pending.get(t.t1)==null)return;this._pending.delete(t.t1);const i=Date.now(),r=i-t.t1,m=t.t2-(t.t1+i)/2;this.samples.push({offset:m,rtt:r}),this.samples.length>v&&this.samples.shift();const h=S(this.samples.map(s=>s.rtt)),o=Math.max(h*2,h+10),c=this.samples.filter(s=>s.rtt<=o),n=c.length?c.map(s=>s.offset):this.samples.map(s=>s.offset),_=S(n),p=n.reduce((s,d)=>s+d,0)/n.length,f=n.reduce((s,d)=>s+(d-p)**2,0)/n.length;this.offsetStd=Math.sqrt(f),this.offset=_,this.rtt=r,this.synced=!0;for(const s of this._listeners)s({offset:this.offset,rtt:this.rtt,offsetStd:this.offsetStd,samples:this.samples.length,accepted:c.length,stable:this.isStable()})}_sendPing(){if(!this._ws)return;const t=Date.now();this._pending.set(t,t);for(const e of this._pending.keys())t-e>5e3&&this._pending.delete(e);this._ws.send({type:"clock-ping",t1:t})}_scheduleNext(){this._timeoutId&&clearTimeout(this._timeoutId);const t=this.isStable()?N:w;this._timeoutId=setTimeout(()=>{this._sendPing(),this._scheduleNext()},t)}}const E="oradio.autoplayDismissed";function b(){var l;try{return!!(typeof window<"u"&&((l=window.oradioNative)!=null&&l.isElectron))}catch{return!1}}function L(){if(!b())return!1;try{return localStorage.getItem(E)==="1"}catch{return!1}}let u=null;function T({stationName:l="Radio",subtitle:t="",onStart:e,onCancel:i}={}){if(u)return u.promise;let r,m;const h=new Promise((a,g)=>{r=a,m=g}),o=document.createElement("div");o.className="play-gate-backdrop",o.setAttribute("role","dialog"),o.setAttribute("aria-modal","true"),o.setAttribute("aria-label","Tap to start audio");const c=document.createElement("div");c.className="play-gate-card";const n=document.createElement("h2");n.className="play-gate-title",n.textContent="Tap to start audio",c.appendChild(n);const _=document.createElement("div");if(_.className="play-gate-station",_.textContent=l,c.appendChild(_),t){const a=document.createElement("div");a.className="play-gate-sub",a.textContent=t,c.appendChild(a)}const p=document.createElement("div");p.className="play-gate-row";const f=document.createElement("button");f.className="play-gate-start",f.textContent="▶ Start";const s=document.createElement("button");s.className="play-gate-cancel",s.textContent="Cancel",p.appendChild(f),p.appendChild(s),c.appendChild(p);let d=null;if(b()){const a=document.createElement("label");a.className="play-gate-dismiss",d=document.createElement("input"),d.type="checkbox",d.id="play-gate-dismiss-cb",a.appendChild(d);const g=document.createElement("span");g.textContent=" Don't show again on this device",a.appendChild(g),c.appendChild(a)}o.appendChild(c),document.body.appendChild(o),queueMicrotask(()=>f.focus());function y(){var a;(a=u==null?void 0:u.backdrop)!=null&&a.parentNode&&u.backdrop.parentNode.removeChild(u.backdrop),u=null}function k(){if(d&&d.checked)try{localStorage.setItem(E,"1")}catch{}}return f.addEventListener("click",()=>{k(),y();try{e&&e()}catch{}r()}),s.addEventListener("click",()=>{y();try{i&&i()}catch{}m(new Error("autoplay-cancelled"))}),o.addEventListener("keydown",a=>{a.key==="Escape"&&s.click()}),u={backdrop:o,promise:h},h}export{M as R,L as a,x as c,T as s};
|
||||
@@ -1 +0,0 @@
|
||||
function x(l,t={}){let e,i=0,r=!1;function m(){const h=location.protocol==="https:"?"wss":"ws",o=new URLSearchParams;t.room&&o.set("room",t.room),t.kind&&o.set("kind",t.kind);const c=o.toString();e=new WebSocket(`${h}://${location.host}/ws${c?"?"+c:""}`),e.addEventListener("open",()=>{var n;i=0,(n=t.onOpen)==null||n.call(t)}),e.addEventListener("message",n=>{try{l(JSON.parse(n.data))}catch{}}),e.addEventListener("close",()=>{var n;(n=t.onClose)==null||n.call(t),!r&&(i=Math.min(i+1,6),setTimeout(m,500*2**i))}),e.addEventListener("error",()=>e.close())}return m(),{send(h){(e==null?void 0:e.readyState)===WebSocket.OPEN&&e.send(JSON.stringify(h))},close(){r=!0,e==null||e.close()},get readyState(){return e==null?void 0:e.readyState}}}const w=1e3,N=5e3,C=5,I=8,v=16;function S(l){if(!l.length)return 0;const t=l.slice().sort((i,r)=>i-r),e=t.length>>1;return t.length%2?t[e]:(t[e-1]+t[e])/2}class M{constructor(){this.offset=0,this.rtt=1/0,this.offsetStd=1/0,this.samples=[],this.synced=!1,this._pending=new Map,this._listeners=new Set,this._timeoutId=null,this._ws=null}attachWs(t){this._ws=t,this.reset();let e=0;const i=()=>{if(e++>=5){this._scheduleNext();return}this._sendPing(),setTimeout(i,150)};i()}detach(){this._timeoutId&&clearTimeout(this._timeoutId),this._timeoutId=null,this._pending.clear(),this._ws=null}reset(){this.samples=[],this.synced=!1,this.offsetStd=1/0,this._pending.clear(),this._timeoutId&&(clearTimeout(this._timeoutId),this._timeoutId=null)}now(){return Date.now()+this.offset}isStable(){return this.synced&&this.samples.length>=I&&this.offsetStd<=C}onUpdate(t){return this._listeners.add(t),()=>this._listeners.delete(t)}handlePong(t){if(this._pending.get(t.t1)==null)return;this._pending.delete(t.t1);const i=Date.now(),r=i-t.t1,m=t.t2-(t.t1+i)/2;this.samples.push({offset:m,rtt:r}),this.samples.length>v&&this.samples.shift();const h=S(this.samples.map(s=>s.rtt)),o=Math.max(h*2,h+10),c=this.samples.filter(s=>s.rtt<=o),n=c.length?c.map(s=>s.offset):this.samples.map(s=>s.offset),_=S(n),p=n.reduce((s,d)=>s+d,0)/n.length,f=n.reduce((s,d)=>s+(d-p)**2,0)/n.length;this.offsetStd=Math.sqrt(f),this.offset=_,this.rtt=r,this.synced=!0;for(const s of this._listeners)s({offset:this.offset,rtt:this.rtt,offsetStd:this.offsetStd,samples:this.samples.length,accepted:c.length,stable:this.isStable()})}_sendPing(){if(!this._ws)return;const t=Date.now();this._pending.set(t,t);for(const e of this._pending.keys())t-e>5e3&&this._pending.delete(e);this._ws.send({type:"clock-ping",t1:t})}_scheduleNext(){this._timeoutId&&clearTimeout(this._timeoutId);const t=this.isStable()?N:w;this._timeoutId=setTimeout(()=>{this._sendPing(),this._scheduleNext()},t)}}const E="oradio.autoplayDismissed";function b(){var l;try{return!!(typeof window<"u"&&((l=window.oradioNative)!=null&&l.isElectron))}catch{return!1}}function L(){if(!b())return!1;try{return localStorage.getItem(E)==="1"}catch{return!1}}let u=null;function T({stationName:l="Radio",subtitle:t="",onStart:e,onCancel:i}={}){if(u)return u.promise;let r,m;const h=new Promise((a,g)=>{r=a,m=g}),o=document.createElement("div");o.className="play-gate-backdrop",o.setAttribute("role","dialog"),o.setAttribute("aria-modal","true"),o.setAttribute("aria-label","Tap to start audio");const c=document.createElement("div");c.className="play-gate-card";const n=document.createElement("h2");n.className="play-gate-title",n.textContent="Tap to start audio",c.appendChild(n);const _=document.createElement("div");if(_.className="play-gate-station",_.textContent=l,c.appendChild(_),t){const a=document.createElement("div");a.className="play-gate-sub",a.textContent=t,c.appendChild(a)}const p=document.createElement("div");p.className="play-gate-row";const f=document.createElement("button");f.className="play-gate-start",f.textContent="▶ Start";const s=document.createElement("button");s.className="play-gate-cancel",s.textContent="Cancel",p.appendChild(f),p.appendChild(s),c.appendChild(p);let d=null;if(b()){const a=document.createElement("label");a.className="play-gate-dismiss",d=document.createElement("input"),d.type="checkbox",d.id="play-gate-dismiss-cb",a.appendChild(d);const g=document.createElement("span");g.textContent=" Don't show again on this device",a.appendChild(g),c.appendChild(a)}o.appendChild(c),document.body.appendChild(o),queueMicrotask(()=>f.focus());function y(){var a;(a=u==null?void 0:u.backdrop)!=null&&a.parentNode&&u.backdrop.parentNode.removeChild(u.backdrop),u=null}function k(){if(d&&d.checked)try{localStorage.setItem(E,"1")}catch{}}return f.addEventListener("click",()=>{k(),y();try{e&&e()}catch{}r()}),s.addEventListener("click",()=>{y();try{i&&i()}catch{}m(new Error("autoplay-cancelled"))}),o.addEventListener("keydown",a=>{a.key==="Escape"&&s.click()}),u={backdrop:o,promise:h},h}export{M as R,L as a,x as c,T as s};
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<title>Radio Kiosk</title>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/kiosk-Cc8AKKkv.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/debug-DBzSAgZo.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/playGate-BFH7IScH.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/kiosk-DuoYH-tL.css">
|
||||
</head>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Radio Master</title>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/master-Bb7HGec-.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/debug-DBzSAgZo.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/playGate-BFH7IScH.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/master-B8Vyo4--.css">
|
||||
</head>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import express from 'express';
|
||||
import { requireAdmin } from '../auth.js';
|
||||
import { requireAdmin, createApiKey, listApiKeys, revokeApiKey } from '../auth.js';
|
||||
import { runHealthCheck } from '../streams/checker.js';
|
||||
import { probeStream } from '../streams/probe.js';
|
||||
import { applySeedIfEmpty } from '../sources/seed.js';
|
||||
@@ -19,6 +19,25 @@ import { broadcastGlobal } from '../ws.js';
|
||||
export const router = Router();
|
||||
router.use(requireAdmin);
|
||||
|
||||
// --- API key management ---
|
||||
|
||||
router.get('/api-keys', (_req, res) => {
|
||||
res.json(listApiKeys());
|
||||
});
|
||||
|
||||
router.post('/api-keys', (req, res) => {
|
||||
const label = String(req.body?.label || '').trim();
|
||||
const userId = Number(req.body?.userId) || req.user.id;
|
||||
const key = createApiKey(userId, label);
|
||||
res.status(201).json({ key }); // plaintext key shown exactly once
|
||||
});
|
||||
|
||||
router.delete('/api-keys/:id', (req, res) => {
|
||||
revokeApiKey(Number(req.params.id));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
// Raw body parser used only by the image upload route. The global JSON
|
||||
// parser is mounted before us so we have to opt-out for `image/*`.
|
||||
const rawImageBody = express.raw({ type: ['image/*', 'application/octet-stream'], limit: '5mb' });
|
||||
|
||||
@@ -117,7 +117,7 @@ router.get('/:id/proxy', requireUser, async (req, res) => {
|
||||
if (resolved.format === 'hls') return res.status(415).json({ error: 'hls not proxied' });
|
||||
|
||||
const controller = new AbortController();
|
||||
req.on('close', () => controller.abort());
|
||||
req.on('close', () => { try { controller.abort(); } catch { } });
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
@@ -127,7 +127,9 @@ router.get('/:id/proxy', requireUser, async (req, res) => {
|
||||
headers: { 'User-Agent': 'oradio-kiosk/1.0', 'Icy-MetaData': '0' }
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: `upstream: ${err.message || 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}` });
|
||||
@@ -141,7 +143,11 @@ router.get('/:id/proxy', requireUser, async (req, res) => {
|
||||
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) {
|
||||
@@ -151,13 +157,13 @@ router.get('/:id/proxy', requireUser, async (req, res) => {
|
||||
await new Promise((r) => res.once('drain', r));
|
||||
}
|
||||
}
|
||||
} catch { /* client disconnect or upstream abort */ }
|
||||
} catch { /* client disconnect or upstream error */ }
|
||||
finally {
|
||||
try { reader.cancel(); } catch { }
|
||||
res.end();
|
||||
try { res.end(); } catch { }
|
||||
}
|
||||
};
|
||||
pump();
|
||||
pump().catch(() => {});
|
||||
});
|
||||
|
||||
function guessContentType(format) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { initDb } from './db/index.js';
|
||||
import { authMiddleware, ensureBootstrapAdmin, ensureMainUser } from './auth.js';
|
||||
import { authMiddleware, ensureBootstrapAdmin, ensureMainUser, ensureBootstrapApiKey } from './auth.js';
|
||||
import { applySeedIfEmpty } from './sources/seed.js';
|
||||
import { scheduleHealthCheck } from './streams/checker.js';
|
||||
import { attachWs } from './ws.js';
|
||||
@@ -39,6 +39,7 @@ export async function startServer(opts = {}) {
|
||||
password: process.env.ADMIN_BOOTSTRAP_PASSWORD
|
||||
});
|
||||
ensureMainUser(process.env.MAIN_USER || 'morphix');
|
||||
ensureBootstrapApiKey(process.env.ORADIO_API_KEY);
|
||||
const seedResult = applySeedIfEmpty();
|
||||
console.log('[seed]', seedResult);
|
||||
ensureImageDirs();
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getRoomState, setRoomState
|
||||
} from './rooms.js';
|
||||
import { getStation } from './stations.js';
|
||||
import { notifyCompanion } from './companion.js';
|
||||
|
||||
const DEBUG = !!process.env.ORADIO_DEBUG_SYNC;
|
||||
function dlog(...args) { if (DEBUG) console.log('[ws]', ...args); }
|
||||
@@ -124,6 +125,7 @@ export function dispatchRoomCommand(room, msg, { except = null } = {}) {
|
||||
|
||||
if (msg.action === 'play' && Number.isFinite(msg.stationId)) {
|
||||
const newStation = Number(msg.stationId);
|
||||
notifyCompanion(newStation);
|
||||
const sameAndPlaying = cur.playing && cur.station_id === newStation && cur.started_at;
|
||||
const patch = { station_id: newStation, playing: true };
|
||||
if (!displayPresent) {
|
||||
@@ -134,8 +136,10 @@ export function dispatchRoomCommand(room, msg, { except = null } = {}) {
|
||||
setRoomState(room.id, patch);
|
||||
} else if (msg.action === 'pause') {
|
||||
setRoomState(room.id, { playing: false });
|
||||
notifyCompanion(null);
|
||||
} else if (msg.action === 'stop') {
|
||||
setRoomState(room.id, { playing: false, started_at: null });
|
||||
notifyCompanion(null);
|
||||
} else if (msg.action === 'volume' && typeof msg.value === 'number') {
|
||||
setRoomState(room.id, { volume: Math.max(0, Math.min(1, msg.value)) });
|
||||
} else {
|
||||
@@ -291,6 +295,10 @@ function handleClientMessage(ws, msg) {
|
||||
const next = setRoomState(ws.room.id, patch);
|
||||
// Station change invalidates any cached master position.
|
||||
if (prev.station_id !== next.station_id) lastSyncPos.delete(slug);
|
||||
// Notify Companion whenever the effective now-playing state changes.
|
||||
if (prev.station_id !== next.station_id || prev.playing !== next.playing) {
|
||||
notifyCompanion(next.playing && next.station_id ? next.station_id : null);
|
||||
}
|
||||
const station = next.station_id ? getStation(next.station_id) : null;
|
||||
broadcastToRoom(slug, { type: 'state', ...next, station, server_now: Date.now() });
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user