// Player application bootstrap, media routes, websocket runtime, and onboarding wiring. const express = require('express'); const fs = require('fs'); const http = require('http'); const path = require('path'); const { WebSocket } = require('ws'); const common = require('./common'); const { createPlayerRuntime } = require('./player/runtime'); const { createPlayerPlaylistService } = require('./player/playlist'); const { createRtmpStreamService } = require('./player/modules/rtmp-streams'); const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding'); const { createOnboardingStore } = require('./player/onboarding/store'); const { registerPlayerRoutes } = require('./player/routes'); const { getPlayerRuntimeScripts } = require('./player/render-helpers'); const { ensureFontLibrary } = require('#src/web/lib/media/font-library'); const { createRequestAuthHeaders } = require('#src/request-auth'); const { withClientNameReservation } = require('#src/data/client-name-check'); const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/data/player-registry'); // Player runtime, media API, and websocket wiring. async function start() { const app = express(); const pool = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '') ? null : common.createPool(); const PORT = Number(process.env.PLAYER_PORT || 8081); const PLAYER_PUBLIC_URL = String(process.env.PLAYER_PUBLIC_URL || '').trim().replace(/\/$/, ''); const BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, ''); const WEB_INTERNAL_URL = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, ''); const isRemotePlayer = Boolean(BRIDGE_PUBLIC_URL); const PLAYER_INTERNAL_URL = String(isRemotePlayer ? BRIDGE_PUBLIC_URL : (process.env.PLAYER_INTERNAL_URL || '')).trim().replace(/\/$/, ''); const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier(); const PLAYER_AGENT_RECONNECT_DELAY_MS = Number(process.env.PLAYER_AGENT_RECONNECT_DELAY_MS || 5000); const ASSET_DIR = path.join(__dirname, 'player', 'public'); const MEDIA_DIR = path.join(__dirname, '..', 'media'); const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json'); const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000); const RECONNECT_SYNC_STALE_MS = 60 * 1000; const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE); let thinClientSocket = null; let lastDisconnectAt = 0; let playerPublicBaseUrl = PLAYER_PUBLIC_URL || null; let refreshThinClientRegistration = null; let activePairingCode = ''; let activePairingCodes = []; let activePairingSessions = []; const playerRuntime = createPlayerRuntime({ pool: pool, normalizeDeviceId: normalizeDeviceId, notifySnapshot: function (snapshot) { if (!thinClientSocket || thinClientSocket.readyState !== WebSocket.OPEN) { return; } try { thinClientSocket.send(JSON.stringify({ type: 'snapshot', deviceId: PLAYER_DEVICE_ID, playerPublicBaseUrl: getPlayerPublicBaseUrl(), slug: snapshot && snapshot.slug ? String(snapshot.slug).trim() : '', connections: Array.isArray(snapshot && snapshot.connections) ? snapshot.connections : [] })); } catch (_error) { } }, persistClientName: async function (deviceId, clientName) { if (!pool || !deviceId || !clientName) { return; } await withClientNameReservation(pool, clientName, async function () { await pool.query( `UPDATE d_onboarding_devices SET client_name = ?, modified_at = CURRENT_TIMESTAMP WHERE device_id = ?`, [clientName, deviceId] ); }); }, touchClientLastSeen: async function (deviceId) { await common.touchOnboardingDeviceLastSeen(pool, deviceId); } }); const playerPlaylistService = isRemotePlayer ? null : createPlayerPlaylistService({ pool: pool, common: common, mediaDir: MEDIA_DIR, snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists') }); const rtmpStreamService = createRtmpStreamService({ mediaDir: MEDIA_DIR }); const server = http.createServer(app); playerRuntime.installWebsocket(server); app.use(express.json()); let hasLoggedPlayerStartup = false; function normalizePlayerPublicBaseUrl(value) { const normalized = String(value || '').trim().replace(/\/$/, ''); if (!normalized) { return null; } try { return new URL(normalized).origin.replace(/\/$/, ''); } catch (_error) { return normalized; } } function setPlayerPublicBaseUrl(value) { const nextBaseUrl = normalizePlayerPublicBaseUrl(value); if (!nextBaseUrl || nextBaseUrl === playerPublicBaseUrl) { return; } playerPublicBaseUrl = nextBaseUrl; if (typeof refreshThinClientRegistration === 'function') { refreshThinClientRegistration(); } } function getPlayerPublicBaseUrl() { return playerPublicBaseUrl; } function logPlayerStartup(connectionState) { if (hasLoggedPlayerStartup) { return; } hasLoggedPlayerStartup = true; console.info('[player] startup', { mode: isRemotePlayer ? 'bridge client' : 'local', connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false, publicBaseUrl: getPlayerPublicBaseUrl(), bridgeBaseUrl: PLAYER_INTERNAL_URL || null, bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null }); } fs.mkdirSync(MEDIA_DIR, { recursive: true }); function resolveLocalMediaFilePath(fileName) { const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, ''); if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return null; } const resolvedMediaDir = path.resolve(MEDIA_DIR); const resolvedFilePath = path.resolve(MEDIA_DIR, relativePath); if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) { return null; } return resolvedFilePath; } async function triggerWebMediaSync() { const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL; if (!isRemotePlayer || !syncBaseUrl) { return false; } const requestBody = { playerIdentifier: PLAYER_DEVICE_ID, playerPublicBaseUrl: getPlayerPublicBaseUrl(), playerInternalBaseUrl: PLAYER_INTERNAL_URL }; try { const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: '/api/internal/sync/player-media', body: requestBody }); const response = await fetch(`${syncBaseUrl}/api/internal/sync/player-media`, { method: 'POST', headers: Object.assign({ Accept: 'application/json', 'Content-Type': 'application/json' }, authHeaders), body: JSON.stringify(requestBody) }); return Boolean(response && response.ok); } catch (_error) { console.warn('[player] Startup media sync failed'); return false; } } async function triggerWebFontSync() { const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL; if (!isRemotePlayer || !syncBaseUrl) { return false; } const requestBody = { playerIdentifier: PLAYER_DEVICE_ID, playerPublicBaseUrl: getPlayerPublicBaseUrl(), playerInternalBaseUrl: PLAYER_INTERNAL_URL }; try { const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: '/api/internal/sync/player-font', body: requestBody }); const response = await fetch(`${syncBaseUrl}/api/internal/sync/player-font`, { method: 'POST', headers: Object.assign({ Accept: 'application/json', 'Content-Type': 'application/json' }, authHeaders), body: JSON.stringify(requestBody) }); return Boolean(response && response.ok); } catch (_error) { console.warn('[player] Startup font sync failed'); return false; } } let webMediaSyncCompleted = false; let webFontSyncCompleted = false; let webFontSyncTriggered = false; function shouldTriggerReconnectSync() { if (!lastDisconnectAt) { return true; } return Date.now() - lastDisconnectAt >= RECONNECT_SYNC_STALE_MS; } async function handleThinClientCommand(socket, rawMessage) { let payload = null; try { payload = JSON.parse(String(rawMessage || '')); } catch (_error) { return; } if (!payload || typeof payload !== 'object' || Array.isArray(payload) || String(payload.type || '').trim() !== 'command') { return; } const requestId = String(payload.requestId || '').trim() || null; const command = String(payload.command || '').trim().toLowerCase(); const response = { type: 'command-response', requestId: requestId, ok: false }; try { if (command === 'media-put') { const relativePath = String(payload.relativePath || payload.filename || '').trim(); const filePath = resolveLocalMediaFilePath(relativePath); const bodyBase64 = String(payload.bodyBase64 || '').trim(); if (!filePath || !bodyBase64) { response.error = 'Invalid media payload.'; } else { const bodyBuffer = Buffer.from(bodyBase64, 'base64'); await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); await fs.promises.writeFile(filePath, bodyBuffer); response.ok = true; } } else if (command === 'media-delete') { const relativePath = String(payload.relativePath || payload.filename || '').trim(); const filePath = resolveLocalMediaFilePath(relativePath); if (!filePath) { response.error = 'Invalid media path.'; } else { try { await fs.promises.unlink(filePath); } catch (error) { if (!error || error.code !== 'ENOENT') { throw error; } } response.ok = true; } } else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname', 'announcement-refresh'].indexOf(command) !== -1) { const screenSlug = String(payload.screenSlug || payload.slug || '').trim(); if (!screenSlug) { response.error = 'Screen slug is required.'; } else if (payload.connectionId) { const sent = await playerRuntime.sendCommandToConnection(screenSlug, String(payload.connectionId || '').trim(), payload); response.ok = sent > 0; if (!response.ok) { response.error = 'Player is not connected.'; } } else { const sent = await playerRuntime.broadcastCommand(screenSlug, payload); response.ok = sent > 0; if (!response.ok) { response.error = 'Player is not connected.'; } } } else { response.error = 'Unsupported command.'; } } catch (error) { response.error = error && error.message ? error.message : 'Command failed.'; } if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(response)); } } app.use(function (error, _req, res, _next) { console.error(error); res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error'); }); await ensureFontLibrary(MEDIA_DIR); registerPlayerOnboardingRoutes(app, { pool: pool, common: common, playerRuntime: playerRuntime, onboardingStore: onboardingStore, playerPublicBaseUrl: getPlayerPublicBaseUrl(), playerInternalBaseUrl: PLAYER_INTERNAL_URL, bridgeBaseUrl: BRIDGE_PUBLIC_URL, playerDeviceId: PLAYER_DEVICE_ID, onPairingCode: function (code, codes) { activePairingCode = String(code || '').trim().toUpperCase(); activePairingSessions = Array.isArray(codes) ? codes.map(function (entry) { return { deviceId: String(entry && entry.deviceId || '').trim(), clientId: String(entry && entry.clientId || '').trim(), code: String(entry && entry.code || '').trim().toUpperCase() }; }).filter(function (entry) { return entry.deviceId && entry.code; }) : []; activePairingCodes = activePairingSessions.map(function (entry) { return entry.code; }); if (typeof refreshThinClientRegistration === 'function') { refreshThinClientRegistration(); } } }); app.get('/assets/player-script/:name.js', function (req, res) { const script = getPlayerRuntimeScripts().find(function (entry) { return entry[0] === req.params.name; }); if (!script) { return res.sendStatus(404); } res.set('Cache-Control', 'no-cache'); return res.type('application/javascript').send(script[1]); }); registerPlayerRoutes(app, { pool: pool, common: common, mediaDir: MEDIA_DIR, assetDir: ASSET_DIR, playerRuntime: playerRuntime, playerPlaylistService: playerPlaylistService, rtmpStreamService: rtmpStreamService, snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists'), playerInternalBaseUrl: PLAYER_INTERNAL_URL, bridgeBaseUrl: BRIDGE_PUBLIC_URL, playerDeviceId: PLAYER_DEVICE_ID, onPlayerPublicBaseUrl: setPlayerPublicBaseUrl }); function createThinClientWebSocketUrl() { if (!BRIDGE_PUBLIC_URL) { return null; } return BRIDGE_PUBLIC_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players'; } function startThinClientRegistration() { const thinClientUrl = createThinClientWebSocketUrl(); if (!thinClientUrl) { return null; } let socket = null; let reconnectTimer = null; let heartbeatTimer = null; function clearTimers() { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } } function connect() { clearTimers(); const timestamp = String(Date.now()); const authHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/ws/players', timestamp: timestamp }); let webMediaSyncTriggered = false; socket = new WebSocket(thinClientUrl, { headers: Object.assign({ 'x-pulse-request-timestamp': timestamp }, authHeaders) }); thinClientSocket = socket; function sendHeartbeat() { if (!socket || socket.readyState !== WebSocket.OPEN) { return; } socket.send(JSON.stringify({ type: 'heartbeat', deviceId: PLAYER_DEVICE_ID, publicBaseUrl: getPlayerPublicBaseUrl(), internalBaseUrl: PLAYER_INTERNAL_URL, pairingCode: activePairingCode, pairingCodes: activePairingCodes, pairingSessions: activePairingSessions, connections: playerRuntime.snapshotAllConnections() })); } refreshThinClientRegistration = sendHeartbeat; function triggerMediaSyncIfNeeded() { if (webMediaSyncTriggered || webMediaSyncCompleted) { return; } webMediaSyncTriggered = true; triggerWebMediaSync().then(function (success) { webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted; if (!success) { webMediaSyncTriggered = false; } }).catch(function () { webMediaSyncTriggered = false; }); } function triggerFontSyncIfNeeded() { if (webFontSyncTriggered || webFontSyncCompleted) { return; } webFontSyncTriggered = true; triggerWebFontSync().then(function (success) { webFontSyncCompleted = Boolean(success) || webFontSyncCompleted; if (!success) { webFontSyncTriggered = false; } }).catch(function () { webFontSyncTriggered = false; webFontSyncCompleted = false; }); } function sendSnapshot(slug) { if (!socket || socket.readyState !== WebSocket.OPEN) { return; } try { socket.send(JSON.stringify({ type: 'snapshot', deviceId: PLAYER_DEVICE_ID, playerPublicBaseUrl: getPlayerPublicBaseUrl(), slug: String(slug || '').trim(), connections: playerRuntime.snapshotConnections(slug) })); } catch (_error) { } } socket.on('open', function () { logPlayerStartup({ connected: true }); socket.send(JSON.stringify({ type: 'register', deviceId: PLAYER_DEVICE_ID, publicBaseUrl: getPlayerPublicBaseUrl(), internalBaseUrl: PLAYER_INTERNAL_URL, pairingCode: activePairingCode, pairingCodes: activePairingCodes, pairingSessions: activePairingSessions })); playerRuntime.snapshotSlugs().forEach(function (slug) { sendSnapshot(slug); }); heartbeatTimer = setInterval(function () { sendHeartbeat(); }, DB_SYNC_INTERVAL_MS); }); socket.on('message', function (rawMessage) { let parsedMessage = null; try { parsedMessage = JSON.parse(String(rawMessage || '{}')); } catch (_error) { parsedMessage = null; } if (parsedMessage && String(parsedMessage.type || '').trim() === 'registered') { sendHeartbeat(); return; } if (parsedMessage && String(parsedMessage.type || '').trim() === 'heartbeat-ack') { if (shouldTriggerReconnectSync()) { triggerMediaSyncIfNeeded(); triggerFontSyncIfNeeded(); } return; } handleThinClientCommand(socket, rawMessage).catch(function (error) { try { socket.send(JSON.stringify({ type: 'command-response', requestId: null, ok: false, error: error && error.message ? error.message : 'Command failed.' })); } catch (_sendError) { // ignore send errors } }); }); socket.on('close', function () { lastDisconnectAt = Date.now(); webFontSyncTriggered = false; webFontSyncCompleted = false; webMediaSyncCompleted = false; clearTimers(); thinClientSocket = null; refreshThinClientRegistration = null; reconnectTimer = setTimeout(connect, PLAYER_AGENT_RECONNECT_DELAY_MS); }); socket.on('error', function () { try { socket.close(); } catch (_error) { // ignore reconnect noise } }); } connect(); return function stop() { clearTimers(); if (socket) { try { socket.close(); } catch (_error) { // ignore close errors } socket = null; } }; } if (!isRemotePlayer) { logPlayerStartup({ connected: false }); } const stopThinClientRegistration = startThinClientRegistration(); server.listen(PORT, function () { console.log(`Pulse Signage app listening on port ${PORT}`); }); async function syncDatabaseState() { if (isRemotePlayer) { return; } try { await recordPlayerHeartbeat(pool, { deviceId: PLAYER_DEVICE_ID, publicBaseUrl: getPlayerPublicBaseUrl(), internalBaseUrl: PLAYER_INTERNAL_URL }).catch(function (error) { console.error(error); }); await onboardingStore.flushBindings(function (entry) { return commitDeviceBinding( pool, entry.deviceId, entry.clientName, entry.screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime.snapshotAllConnections() ); }); } catch (error) { console.error(error); } } await syncDatabaseState(); if (PLAYER_DEVICE_ID && !isRemotePlayer) { const { upsertPlayerRegistration } = require('./player/onboarding'); await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) { console.error(error); }); } setInterval(function () { if (isRemotePlayer) { return; } syncDatabaseState().catch(function (error) { console.error(error); }); }, DB_SYNC_INTERVAL_MS); process.on('exit', function () { if (typeof stopThinClientRegistration === 'function') { stopThinClientRegistration(); } }); } module.exports = { start }; if (require.main === module) { start().catch(function (error) { console.error(error); process.exit(1); }); }