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). * @returns {Promise<{ setState: (update: object) => object, stop: () => Promise }>} */ async function startServer({ port, overlayDir }) { 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'); 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'); } try { const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); 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) => { ws.on('error', () => {}); ws.send(JSON.stringify(currentState)); }); function setState(update) { currentState = mergeState(currentState, update || {}); const message = JSON.stringify(currentState); wss.clients.forEach((client) => { if (client.readyState === 1) client.send(message); // 1 === WebSocket.OPEN }); return currentState; } function stop() { return new Promise((resolve) => { wss.close(() => { server.closeAllConnections(); server.close(() => resolve()); }); }); } // 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 }; } module.exports = { startServer };