const express = require('express'); const fs = require('fs'); const http = require('http'); const crypto = require('crypto'); const path = require('path'); const { WebSocketServer, WebSocket } = require('ws'); const common = require('./common'); async function buildScreenPlaylist(pool, slug) { const [screenRows] = await pool.query('SELECT * FROM screens WHERE slug = ?', [slug]); if (!screenRows.length) { return { screen: null, playlist: null, slides: [] }; } const screen = screenRows[0]; if (!screen.playlist_id) { return { screen, playlist: null, slides: [] }; } const [playlistRows] = await pool.query('SELECT * FROM playlists WHERE id = ?', [screen.playlist_id]); const playlist = playlistRows[0] || null; const [slideRows] = await pool.query(` SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at, ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height FROM playlist_slides ps JOIN slides sl ON sl.id = ps.slide_id LEFT JOIN slide_templates st ON st.id = sl.template_id LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id WHERE ps.playlist_id = ? ORDER BY ps.position ASC, ps.id ASC `, [screen.playlist_id]); const templateIds = slideRows .filter(function (slide) { return slide.template_id; }) .map(function (slide) { return slide.template_id; }); const templatesById = {}; if (templateIds.length) { const [templateRows] = await pool.query(` SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height FROM slide_templates st LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id WHERE st.id IN (?) `, [templateIds]); const [regionRows] = await pool.query('SELECT * FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]); templateRows.forEach(function (template) { template.regions = regionRows.filter(function (region) { return region.template_id === template.id; }); templatesById[template.id] = template; }); } const slides = slideRows.map(function (slide) { return { id: slide.id, title: slide.title, body: slide.body, duration_seconds: slide.duration_seconds, schedule_mode: slide.schedule_mode, schedule_start_datetime: slide.schedule_start_datetime, schedule_end_datetime: slide.schedule_end_datetime, schedule_start_time: slide.schedule_start_time, schedule_end_time: slide.schedule_end_time, schedule_days_json: slide.schedule_days_json, media_url: slide.media_path, media_type: slide.media_type, kind: common.mediaKind(slide.media_type, slide.media_path), template_id: slide.template_id, template: slide.template_id ? templatesById[slide.template_id] || null : null, content: common.parseJsonSafe(slide.content_json) || {} }; }); return { screen, playlist, slides }; } async function start() { const app = express(); const pool = common.createPool(); const PORT = Number(process.env.PLAYER_PORT || 3001); const ASSET_DIR = path.join(__dirname, 'player', 'public'); const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads'); const connectionsBySlug = new Map(); const dashboardListenersBySlug = new Map(); const server = http.createServer(app); const wss = new WebSocketServer({ noServer: true }); app.use(express.json()); app.use('/assets', express.static(ASSET_DIR)); app.use('/uploads', express.static(UPLOAD_DIR)); function getConnectionBucket(slug) { const key = String(slug || '').trim(); if (!key) { return null; } if (!connectionsBySlug.has(key)) { connectionsBySlug.set(key, new Map()); } return connectionsBySlug.get(key); } function removeConnection(slug, connectionId) { const bucket = connectionsBySlug.get(slug); if (!bucket) { return; } bucket.delete(connectionId); if (!bucket.size) { connectionsBySlug.delete(slug); } } function getDashboardListenerBucket(slug) { const key = String(slug || '').trim(); if (!key) { return null; } if (!dashboardListenersBySlug.has(key)) { dashboardListenersBySlug.set(key, new Set()); } return dashboardListenersBySlug.get(key); } function removeDashboardListener(slug, socket) { const key = String(slug || '').trim(); const bucket = dashboardListenersBySlug.get(key); if (!bucket) { return; } bucket.delete(socket); if (!bucket.size) { dashboardListenersBySlug.delete(key); } } function buildClientLabel(connection) { const clientId = String(connection.clientId || '').trim(); const userAgent = String(connection.userAgent || '').trim(); const clientIp = String(connection.clientIp || '').trim(); const viewport = connection.viewport && typeof connection.viewport === 'object' ? connection.viewport : null; const labelParts = []; if (userAgent) { labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent); } if (clientId) { labelParts.push(`id ${clientId.slice(-6)}`); } if (clientIp) { labelParts.push(clientIp); } if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) { labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`); } if (!labelParts.length) { return connection.remoteAddress || 'connected client'; } return labelParts.join(' • '); } function snapshotConnections(slug) { const bucket = connectionsBySlug.get(String(slug || '').trim()); if (!bucket) { return []; } return Array.from(bucket.values()).map(function (connection) { return { id: connection.id, clientId: connection.clientId || null, label: connection.label, userAgent: connection.userAgent || null, viewport: connection.viewport || null, page: connection.page || null, currentSlide: connection.currentSlide || null, paused: Boolean(connection.paused), blackout: Boolean(connection.blackout), currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null, currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null, clientIp: connection.clientIp || null, remoteAddress: connection.remoteAddress || null, connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null, lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null }; }); } function broadcastConnectionSnapshot(slug) { const key = String(slug || '').trim(); const bucket = dashboardListenersBySlug.get(key); if (!bucket || !bucket.size) { return; } const payload = JSON.stringify({ type: 'snapshot', slug: key, connections: snapshotConnections(slug), sentAt: new Date().toISOString() }); bucket.forEach(function (socket) { if (socket.readyState === WebSocket.OPEN) { socket.send(payload); } }); } function sendCommandToConnection(slug, connectionId, commandOrPayload) { const bucket = connectionsBySlug.get(String(slug || '').trim()); if (!bucket || !bucket.size) { return 0; } const target = bucket.get(String(connectionId || '').trim()); if (!target || target.socket.readyState !== WebSocket.OPEN) { return 0; } const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null ? Object.assign({}, commandOrPayload) : { command: commandOrPayload }; payload.type = 'command'; payload.targetConnectionId = target.id; payload.sentAt = new Date().toISOString(); target.socket.send(JSON.stringify(payload)); return 1; } function broadcastCommand(slug, commandOrPayload) { const bucket = connectionsBySlug.get(String(slug || '').trim()); if (!bucket || !bucket.size) { return 0; } let sent = 0; bucket.forEach(function (connection) { if (connection.socket.readyState !== WebSocket.OPEN) { return; } const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null ? Object.assign({}, commandOrPayload) : { command: commandOrPayload }; payload.type = 'command'; payload.sentAt = new Date().toISOString(); connection.socket.send(JSON.stringify(payload)); sent += 1; }); return sent; } app.get('/', function (_req, res) { res.send('Pulse Signage player service'); }); app.get('/screen/:slug', function (req, res) { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Pragma', 'no-cache'); buildScreenPlaylist(pool, req.params.slug).then(function (data) { res.send(common.renderPlayerPage(req.params.slug, data)); }).catch(function (error) { console.error(error); res.status(500).send('Internal server error'); }); }); app.get('/api/screens/:slug/playlist', async function (req, res, next) { try { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); const data = await buildScreenPlaylist(pool, req.params.slug); if (!data.screen) { return res.status(404).json({ error: 'Screen not found' }); } res.json(data); } catch (error) { next(error); } }); app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) { try { const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]); if (!screenRows.length) { return res.status(404).json({ error: 'Screen not found' }); } const connections = snapshotConnections(req.params.slug); res.json({ screen: screenRows[0], screenSlug: req.params.slug, count: connections.length, connections: connections }); } catch (error) { next(error); } }); app.post('/api/screens/:slug/commands', async function (req, res, next) { try { const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase(); const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim(); const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') ? req.body.blackout : req.query.blackout; if (!command) { return res.status(400).json({ error: 'Command is required' }); } if (['refresh', 'reload', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) { return res.status(400).json({ error: 'Unsupported command' }); } const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]); if (!screenRows.length) { return res.status(404).json({ error: 'Screen not found' }); } const commandPayload = command === 'blackout' && blackoutValue !== undefined ? { command: command, blackout: blackoutValue } : command; const sent = connectionId ? sendCommandToConnection(req.params.slug, connectionId, commandPayload) : broadcastCommand(req.params.slug, commandPayload); res.json({ screen: screenRows[0], screenSlug: req.params.slug, command: command, connectionId: connectionId || null, sent: sent }); } catch (error) { next(error); } }); app.use(function (error, _req, res, _next) { console.error(error); res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error'); }); await common.ensureSchema(pool); fs.mkdirSync(UPLOAD_DIR, { recursive: true }); server.on('upgrade', function (request, socket, head) { let pathname = ''; try { pathname = new URL(request.url, 'http://localhost').pathname; } catch (_error) { socket.destroy(); return; } const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/); const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/); if (!dashboardMatch && !playerMatch) { socket.destroy(); return; } const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]); wss.handleUpgrade(request, socket, head, function (ws) { wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player'); }); }); wss.on('connection', function (socket, request, slug, role) { if (role === 'dashboard') { const listenerBucket = getDashboardListenerBucket(slug); if (!listenerBucket) { socket.close(); return; } listenerBucket.add(socket); socket.send(JSON.stringify({ type: 'snapshot', slug: String(slug || '').trim(), connections: snapshotConnections(slug), sentAt: new Date().toISOString() })); socket.on('close', function () { removeDashboardListener(slug, socket); }); socket.on('error', function () { removeDashboardListener(slug, socket); }); return; } const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null; const forwardedFor = String(request.headers['x-forwarded-for'] || '').split(',')[0].trim(); const connectionId = crypto.randomUUID(); const connection = { id: connectionId, slug: slug, socket: socket, clientId: null, userAgent: null, viewport: null, page: null, paused: false, blackout: false, clientIp: forwardedFor || remoteAddress, remoteAddress: remoteAddress, label: forwardedFor || remoteAddress || 'connected client', connectedAt: new Date(), lastSeenAt: new Date() }; const bucket = getConnectionBucket(slug); if (!bucket) { socket.close(); return; } bucket.set(connectionId, connection); socket.on('message', function (rawMessage) { connection.lastSeenAt = new Date(); let payload = null; try { payload = JSON.parse(String(rawMessage || '')); } catch (_error) { return; } if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) { return; } connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId; connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent; connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport; connection.page = payload.page ? String(payload.page).trim() : connection.page; connection.paused = Boolean(payload.paused); connection.blackout = Boolean(payload.blackout); connection.clientIp = payload.clientIp ? String(payload.clientIp).trim() : connection.clientIp; connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? { id: payload.currentSlide.id || null, title: payload.currentSlide.title || '', kind: payload.currentSlide.kind || '', playlistSignature: payload.currentSlide.playlistSignature || '' } : connection.currentSlide; connection.label = buildClientLabel(connection); connection.lastSeenAt = new Date(); broadcastConnectionSnapshot(slug); }); socket.on('close', function () { removeConnection(slug, connectionId); broadcastConnectionSnapshot(slug); }); socket.on('error', function () { removeConnection(slug, connectionId); broadcastConnectionSnapshot(slug); }); }); server.listen(PORT, function () { console.log(`Pulse Signage app listening on port ${PORT}`); }); } module.exports = { start }; if (require.main === module) { start().catch(function (error) { console.error(error); process.exit(1); }); }