488 lines
17 KiB
JavaScript
488 lines
17 KiB
JavaScript
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);
|
|
});
|
|
}
|