2026-04-26 09:38:11 -07:00
|
|
|
const express = require('express');
|
|
|
|
|
const http = require('http');
|
|
|
|
|
const { WebSocketServer } = require('ws');
|
|
|
|
|
const { initialState, mergeState } = require('./state');
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start the now-playing server.
|
|
|
|
|
*
|
|
|
|
|
* @param {Object} opts
|
|
|
|
|
* @param {number} opts.port Port to listen on (e.g. 9095).
|
|
|
|
|
* @param {string} [opts.overlayDir] Absolute path to the overlay/ directory to serve as static files. Optional (omit for tests).
|
2026-04-26 10:12:14 -07:00
|
|
|
* @returns {Promise<{ setState: (update: object) => object, stop: () => Promise<void>, port: number }>}
|
2026-04-26 09:38:11 -07:00
|
|
|
*/
|
2026-04-26 10:08:15 -07:00
|
|
|
async function startServer({ port, overlayDir }) {
|
2026-04-26 09:38:11 -07:00
|
|
|
let currentState = { ...initialState };
|
|
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
const server = http.createServer(app);
|
|
|
|
|
const wss = new WebSocketServer({ server });
|
|
|
|
|
|
|
|
|
|
// CORS: overlay/OBS browser sources may be loaded from arbitrary origins.
|
|
|
|
|
app.use((req, res, next) => {
|
|
|
|
|
res.header('Access-Control-Allow-Origin', '*');
|
|
|
|
|
res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
|
|
|
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
|
|
|
next();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
|
|
|
|
|
|
|
|
|
app.get('/api/now-playing', (req, res) => res.json(currentState));
|
|
|
|
|
|
|
|
|
|
// Image proxy so overlay clients can fetch album art that may have CORS / referrer restrictions.
|
|
|
|
|
app.get('/api/image-proxy', async (req, res) => {
|
|
|
|
|
const url = req.query.url;
|
|
|
|
|
if (!url) return res.status(400).send('Missing url parameter');
|
2026-04-26 09:41:44 -07:00
|
|
|
|
|
|
|
|
let parsed;
|
|
|
|
|
try { parsed = new URL(url); }
|
|
|
|
|
catch { return res.status(400).send('Invalid url'); }
|
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
|
|
|
return res.status(400).send('Only http/https URLs are allowed');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 09:38:11 -07:00
|
|
|
try {
|
2026-04-26 09:41:44 -07:00
|
|
|
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
2026-04-26 09:38:11 -07:00
|
|
|
if (!response.ok) return res.status(response.status).send('Upstream error');
|
|
|
|
|
res.set('Content-Type', response.headers.get('content-type') || 'image/jpeg');
|
|
|
|
|
res.set('Cache-Control', 'public, max-age=3600');
|
|
|
|
|
const buffer = await response.arrayBuffer();
|
|
|
|
|
res.send(Buffer.from(buffer));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(502).send('Failed to fetch image');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (overlayDir) {
|
|
|
|
|
app.use(express.static(overlayDir));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wss.on('connection', (ws) => {
|
2026-04-26 09:41:44 -07:00
|
|
|
ws.on('error', () => {});
|
2026-04-26 09:38:11 -07:00
|
|
|
ws.send(JSON.stringify(currentState));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function setState(update) {
|
|
|
|
|
currentState = mergeState(currentState, update || {});
|
|
|
|
|
const message = JSON.stringify(currentState);
|
|
|
|
|
wss.clients.forEach((client) => {
|
2026-04-26 09:41:44 -07:00
|
|
|
if (client.readyState === 1) client.send(message); // 1 === WebSocket.OPEN
|
2026-04-26 09:38:11 -07:00
|
|
|
});
|
|
|
|
|
return currentState;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stop() {
|
|
|
|
|
return new Promise((resolve) => {
|
2026-04-26 09:41:44 -07:00
|
|
|
wss.close(() => {
|
|
|
|
|
server.closeAllConnections();
|
|
|
|
|
server.close(() => resolve());
|
|
|
|
|
});
|
2026-04-26 09:38:11 -07:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 10:08:15 -07:00
|
|
|
// Try the preferred port first, then up to 4 sequential fallbacks on EADDRINUSE.
|
|
|
|
|
const MAX_PORT_ATTEMPTS = 5;
|
|
|
|
|
let actualPort = null;
|
|
|
|
|
let lastErr = null;
|
|
|
|
|
|
|
|
|
|
// The WebSocketServer re-emits server 'error' events on itself.
|
|
|
|
|
// Add a no-op sink so Node doesn't crash on unhandled 'error' during port scanning.
|
|
|
|
|
const wssErrorSink = () => {};
|
|
|
|
|
wss.on('error', wssErrorSink);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < MAX_PORT_ATTEMPTS; i++) {
|
|
|
|
|
const tryPort = port + i;
|
|
|
|
|
try {
|
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
|
const onError = (err) => {
|
|
|
|
|
server.removeListener('listening', onListening);
|
|
|
|
|
reject(err);
|
|
|
|
|
};
|
|
|
|
|
const onListening = () => {
|
|
|
|
|
server.removeListener('error', onError);
|
|
|
|
|
resolve();
|
|
|
|
|
};
|
|
|
|
|
server.once('error', onError);
|
|
|
|
|
server.once('listening', onListening);
|
|
|
|
|
server.listen(tryPort, '127.0.0.1');
|
|
|
|
|
});
|
|
|
|
|
actualPort = tryPort;
|
|
|
|
|
break;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
lastErr = err;
|
|
|
|
|
if (err.code !== 'EADDRINUSE') throw err;
|
|
|
|
|
// EADDRINUSE — fall through and try the next port.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wss.removeListener('error', wssErrorSink);
|
|
|
|
|
|
|
|
|
|
if (actualPort === null) {
|
|
|
|
|
throw lastErr || new Error(`No free port in range ${port}..${port + MAX_PORT_ATTEMPTS - 1}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[wtm] server listening on http://127.0.0.1:${actualPort}`);
|
|
|
|
|
return { setState, stop, port: actualPort };
|
2026-04-26 09:38:11 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { startServer };
|