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 }>} */ 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()); }); }); } return new Promise((resolve, reject) => { server.listen(port, '127.0.0.1', () => { console.log(`[wtm] server listening on http://127.0.0.1:${port}`); resolve({ setState, stop }); }); server.on('error', reject); }); } module.exports = { startServer };