This PR breaks the large web and player bootstrap files into smaller modules with clearer ownership.

Web changes:

Split shared helpers, bootstrap logic, route groups, and upload-sync behavior out of web.js.
Kept web.js focused on wiring and server startup.
Fixed screen playlist reassignment so changing a screen’s playlist now triggers a refresh.
Fixed single-slide playlist refresh behavior so updates do not get stuck behind the current slide.
Player changes:

Split websocket/runtime handling into runtime.js.
Split playlist assembly and revision hashing into playlist.js.
Split onboarding and player HTTP routes into dedicated modules.
Split render utilities and template loading into render-helpers.js.
Kept player.js mostly as startup/orchestration.
Validation:

Rebuilt both services with Docker Compose.
Smoke-checked web and player routes after the refactor.
Verified get_errors was clean on the touched modules.
This commit is contained in:
2026-07-20 23:58:27 +01:00
parent 480ccdbe9c
commit 2ea8d389fa
321 changed files with 12687 additions and 7080 deletions
+26 -579
View File
@@ -1,151 +1,12 @@
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');
// Playlist assembly and revision helpers.
async function buildScreenPlaylist(pool, slug) {
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by 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: [],
revision: getPlaylistRevision(screen, null, [], [], [])
};
}
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by 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 = {};
let templateRows = [];
let regionRows = [];
if (templateIds.length) {
[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]);
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by 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_path),
template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null,
content: common.parseJsonSafe(slide.content_json) || {}
};
});
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows);
return { screen, playlist, slides, revision };
}
function updatePlaylistRevisionHash(hash, value) {
hash.update(String(value === null || value === undefined ? '' : value));
hash.update('\0');
}
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows) {
const hash = crypto.createHash('sha1');
updatePlaylistRevisionHash(hash, screen && screen.id);
updatePlaylistRevisionHash(hash, screen && screen.playlist_id);
updatePlaylistRevisionHash(hash, screen && screen.modified_at);
updatePlaylistRevisionHash(hash, playlist && playlist.id);
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
updatePlaylistRevisionHash(hash, slide.id);
updatePlaylistRevisionHash(hash, slide.title);
updatePlaylistRevisionHash(hash, slide.body);
updatePlaylistRevisionHash(hash, slide.template_id);
updatePlaylistRevisionHash(hash, slide.content_json);
updatePlaylistRevisionHash(hash, slide.media_path);
updatePlaylistRevisionHash(hash, slide.media_type);
updatePlaylistRevisionHash(hash, slide.modified_at);
updatePlaylistRevisionHash(hash, slide.position);
updatePlaylistRevisionHash(hash, slide.duration_seconds);
updatePlaylistRevisionHash(hash, slide.schedule_mode);
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
updatePlaylistRevisionHash(hash, slide.schedule_end_time);
updatePlaylistRevisionHash(hash, slide.schedule_days_json);
});
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
updatePlaylistRevisionHash(hash, template.id);
updatePlaylistRevisionHash(hash, template.name);
updatePlaylistRevisionHash(hash, template.canvas_size_id);
updatePlaylistRevisionHash(hash, template.canvas_size_width);
updatePlaylistRevisionHash(hash, template.canvas_size_height);
updatePlaylistRevisionHash(hash, template.background_image_path);
updatePlaylistRevisionHash(hash, template.modified_at);
});
(Array.isArray(regionRows) ? regionRows : []).forEach(function (region) {
updatePlaylistRevisionHash(hash, region.id);
updatePlaylistRevisionHash(hash, region.template_id);
updatePlaylistRevisionHash(hash, region.region_key);
updatePlaylistRevisionHash(hash, region.region_type);
updatePlaylistRevisionHash(hash, region.label);
updatePlaylistRevisionHash(hash, region.font_family);
updatePlaylistRevisionHash(hash, region.x);
updatePlaylistRevisionHash(hash, region.y);
updatePlaylistRevisionHash(hash, region.width);
updatePlaylistRevisionHash(hash, region.height);
updatePlaylistRevisionHash(hash, region.z_index);
updatePlaylistRevisionHash(hash, region.modified_at);
});
return hash.digest('hex');
}
const { createPlayerRuntime } = require('./player/runtime');
const { createPlayerPlaylistService } = require('./player/playlist');
const { normalizeDeviceId, registerPlayerOnboardingRoutes } = require('./player/onboarding');
const { registerPlayerRoutes } = require('./player/routes');
// Player runtime, upload API, and websocket wiring.
@@ -155,327 +16,30 @@ async function start() {
const PORT = Number(process.env.PLAYER_PORT || 3001);
const ASSET_DIR = path.join(__dirname, 'player', 'public');
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
const connectionsBySlug = new Map();
const dashboardListenersBySlug = new Map();
const playerRuntime = createPlayerRuntime({
pool: pool,
normalizeDeviceId: normalizeDeviceId
});
const playerPlaylistService = createPlayerPlaylistService({
pool: pool,
common: common
});
const server = http.createServer(app);
const wss = new WebSocketServer({ noServer: true });
playerRuntime.installWebsocket(server);
app.use(express.json());
// Static assets and mirrored uploads are served from the player container.
app.use('/assets', express.static(ASSET_DIR));
app.use('/uploads', express.static(UPLOAD_DIR));
app.get('/api/uploads/config', function (_req, res) {
res.json({
uploadDir: UPLOAD_DIR
});
registerPlayerOnboardingRoutes(app, {
pool: pool,
common: common,
playerRuntime: playerRuntime,
QRCode: require('qrcode')
});
app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
try {
const filename = path.basename(String(req.params.filename || '').trim());
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = path.join(UPLOAD_DIR, filename);
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
await fs.promises.mkdir(UPLOAD_DIR, { recursive: true });
await fs.promises.writeFile(filePath, body);
res.json({ ok: true, filename: filename });
} catch (error) {
next(error);
}
});
app.delete('/api/uploads/:filename', async function (req, res, next) {
try {
const filename = path.basename(String(req.params.filename || '').trim());
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = path.join(UPLOAD_DIR, filename);
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (!error || error.code !== 'ENOENT') {
throw error;
}
}
res.json({ ok: true, filename: filename });
} catch (error) {
next(error);
}
});
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');
});
// Screen playback endpoints render the active playlist for a slug.
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' });
}
const etag = '"' + String(data.revision || '') + '"';
res.set('ETag', etag);
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
return String(value || '').trim();
}).includes(etag)) {
return res.status(304).end();
}
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', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) {
return res.status(400).json({ error: 'Unsupported command' });
}
const isRedirectCommand = command === 'redirect';
let screenRows = [];
if (!isRedirectCommand) {
[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 = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: command;
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
commandPayload.blackout = blackoutValue;
}
const sent = connectionId
? sendCommandToConnection(req.params.slug, connectionId, commandPayload)
: broadcastCommand(req.params.slug, commandPayload);
res.json({
screen: screenRows[0] || null,
screenSlug: req.params.slug,
command: command,
connectionId: connectionId || null,
sent: sent
});
} catch (error) {
next(error);
}
registerPlayerRoutes(app, {
pool: pool,
common: common,
uploadDir: UPLOAD_DIR,
assetDir: ASSET_DIR,
playerRuntime: playerRuntime,
playerPlaylistService: playerPlaylistService
});
app.use(function (error, _req, res, _next) {
@@ -485,124 +49,6 @@ async function start() {
await common.ensureSchema(pool);
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// Websocket upgrades split dashboard snapshots from player client sessions.
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}`);
@@ -617,3 +63,4 @@ if (require.main === module) {
process.exit(1);
});
}