const express = require('express'); const crypto = require('crypto'); const fs = require('fs'); const http = require('http'); const path = require('path'); const { WebSocketServer, WebSocket } = require('ws'); const common = require('../common'); const { verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth'); const { normalizeDeviceId, upsertPlayerRegistration, recordPlayerHeartbeat } = require('#src/data/player-registry'); const { createPlayerPlaylistService } = require('../player/playlist'); const { buildThumbnailPreviewData } = require('../player/thumbnail-preview'); const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerPublicBaseUrl } = require('../player/onboarding'); const { createStyledQrCodeSvg } = require('../data/qr-code'); const { verifyPageAuthToken } = require('#src/request-auth'); function createThinClientConfig() { return { port: Number(process.env.THIN_CLIENT_PORT || 8090), mediaDir: String(process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media')).trim() }; } function logBridge(message, details) { if (details === undefined) { console.info(`[player-bridge] ${message}`); return; } console.info(`[player-bridge] ${message}`, details); } function normalizeRemoteAddress(value) { const address = String(value || '').trim(); if (!address) { return ''; } return address.toLowerCase().startsWith('::ffff:') ? address.slice(7) : address; } function formatPlayerConnectionLabel(deviceId, remoteAddress) { const normalizedDeviceId = String(deviceId || '').trim() || 'unknown-player'; return normalizedDeviceId; } function normalizeProxyBaseUrl(value) { const normalized = String(value || '').trim().replace(/\/$/, ''); if (!normalized) { return ''; } try { const url = new URL(normalized); if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') { url.hostname = 'host.docker.internal'; } return url.toString().replace(/\/$/, ''); } catch (_error) { return normalized; } } function isLocalLikeBaseUrl(value) { let host = ''; try { host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase(); } catch (_error) { return false; } return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === 'host.docker.internal' || host === 'player' || host === 'player-dev' || host === 'player-local' || host === 'player-bridge-dev' || host === 'web' || host === 'player-bridge' || host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.docker.internal'); } function resolveSnapshotUpstreamBaseUrl(player) { const internalBaseUrl = normalizeProxyBaseUrl(player && player.internal_base_url); if (internalBaseUrl && isLocalLikeBaseUrl(internalBaseUrl)) { return internalBaseUrl; } return normalizeProxyBaseUrl(player && player.public_base_url) || null; } function resolvePlayerSocketForDeviceId(playerSockets, deviceId) { const normalizedDeviceId = normalizeDeviceId(deviceId); if (!normalizedDeviceId || !playerSockets || typeof playerSockets.get !== 'function') { return null; } const socket = playerSockets.get(normalizedDeviceId); if (!socket || socket.readyState !== WebSocket.OPEN) { return null; } return socket; } function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) { const key = String(slug || '').trim(); if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') { return []; } const deviceIds = screenPlayerDeviceIds.get(key); const targets = Array.isArray(deviceIds) ? Array.from(new Set(deviceIds.map(function (value) { return normalizeDeviceId(value); }).filter(Boolean))).map(function (deviceId) { const socket = playerSockets.get(deviceId); if (!socket || socket.readyState !== WebSocket.OPEN) { return null; } return { deviceId: deviceId, socket: socket }; }).filter(Boolean) : []; if (targets.length) { return targets; } const fallbackTargets = Array.from(playerSockets.values()).filter(function (socket) { return socket && socket.readyState === WebSocket.OPEN; }).map(function (socket) { return { deviceId: String(socket.playerDeviceId || '').trim(), socket: socket }; }).filter(function (target) { return Boolean(target.deviceId); }); return fallbackTargets.length === 1 ? fallbackTargets : []; } function resolveWebBaseUrl(req) { const configuredWebBaseUrl = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, ''); if (configuredWebBaseUrl) { return configuredWebBaseUrl; } const forwardedHost = String(req && req.headers && req.headers['x-forwarded-host'] || '').trim().split(',')[0]; const host = forwardedHost || String(req && req.headers && req.headers.host || '').trim(); if (!host) { return null; } const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0]; const protocol = forwardedProto || (req && req.socket && req.socket.encrypted ? 'https' : 'http'); let url = null; try { url = new URL(`${protocol}://${host}`); } catch (_error) { return null; } if (url.port === '8090') { url.port = '8080'; } else if (!url.port) { const forwardedPort = String(req && req.headers && req.headers['x-forwarded-port'] || '').trim().split(',')[0]; if (forwardedPort) { url.port = forwardedPort === '8090' ? '8080' : forwardedPort; } else if (protocol === 'http') { url.port = '8080'; } } return url.toString().replace(/\/$/, ''); } async function start() { const app = express(); const server = http.createServer(app); const pool = common.createPool(); const config = createThinClientConfig(); const playerPlaylistService = createPlayerPlaylistService({ pool: pool, common: common, snapshotDir: path.join(config.mediaDir, 'player-cache', 'screen-playlists') }); app.use(express.json()); const playersWs = new WebSocketServer({ noServer: true }); const screenSnapshotsWs = new WebSocketServer({ noServer: true }); const playerSockets = new Map(); const screenSnapshotCache = new Map(); const screenSnapshotSourcesBySlug = new Map(); const screenSnapshotSubscribersBySlug = new Map(); const screenPlayerDeviceIds = new Map(); const pendingPlayerCommands = new Map(); function getScreenSnapshotSourceBucket(slug) { const key = String(slug || '').trim(); if (!key) { return null; } if (!screenSnapshotSourcesBySlug.has(key)) { screenSnapshotSourcesBySlug.set(key, new Map()); } return screenSnapshotSourcesBySlug.get(key); } function getScreenSnapshotSubscriberBucket(slug) { const key = String(slug || '').trim(); if (!key) { return null; } if (!screenSnapshotSubscribersBySlug.has(key)) { screenSnapshotSubscribersBySlug.set(key, new Set()); } return screenSnapshotSubscribersBySlug.get(key); } function buildMergedScreenSnapshot(slug) { const key = String(slug || '').trim(); const sourceBucket = screenSnapshotSourcesBySlug.get(key); const connections = []; const deviceIds = []; if (sourceBucket && typeof sourceBucket.forEach === 'function') { sourceBucket.forEach(function (payload) { if (payload && Array.isArray(payload.connections)) { connections.push.apply(connections, payload.connections); } }); sourceBucket.forEach(function (_payload, sourceKey) { deviceIds.push(sourceKey); }); } return { slug: key, count: connections.length, connections: connections, deviceIds: deviceIds }; } function broadcastScreenSnapshot(slug) { const key = String(slug || '').trim(); const snapshot = buildMergedScreenSnapshot(key); storeScreenSnapshot(key, snapshot.connections, snapshot.deviceIds); const bucket = screenSnapshotSubscribersBySlug.get(key); if (!bucket || !bucket.size) { return; } const payload = JSON.stringify({ type: 'snapshot', slug: key, connections: snapshot.connections, sentAt: new Date().toISOString() }); bucket.forEach(function (socket) { if (socket && socket.readyState === WebSocket.OPEN) { socket.send(payload); } }); } function setScreenSnapshotSource(slug, sourceKey, connections) { const key = String(slug || '').trim(); const normalizedSourceKey = String(sourceKey || '').trim(); if (!key || !normalizedSourceKey) { return; } const bucket = getScreenSnapshotSourceBucket(key); if (!bucket) { return; } bucket.set(normalizedSourceKey, { slug: key, connections: Array.isArray(connections) ? connections : [] }); broadcastScreenSnapshot(key); } function clearScreenSnapshotSource(slug, sourceKey) { const key = String(slug || '').trim(); const normalizedSourceKey = String(sourceKey || '').trim(); const bucket = screenSnapshotSourcesBySlug.get(key); if (!bucket || !normalizedSourceKey || !bucket.has(normalizedSourceKey)) { return; } bucket.delete(normalizedSourceKey); if (!bucket.size) { screenSnapshotSourcesBySlug.delete(key); } broadcastScreenSnapshot(key); } function clearPlayerSnapshotSources(sourceKey) { const normalizedSourceKey = String(sourceKey || '').trim(); if (!normalizedSourceKey) { return; } Array.from(screenSnapshotSourcesBySlug.keys()).forEach(function (slug) { clearScreenSnapshotSource(slug, normalizedSourceKey); }); } async function fetchPlayerSnapshotRegistrations() { if (typeof common.fetchPlayerRegistrations === 'function') { return common.fetchPlayerRegistrations(pool); } const [rows] = await pool.query( `SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, modified_at FROM d_players ORDER BY modified_at DESC, identifier ASC` ); return rows; } function getConnectedPlayerSocket() { for (const socket of playerSockets.values()) { if (socket && socket.readyState === WebSocket.OPEN) { return socket; } } return null; } function getConnectedPlayerCount() { return Array.from(playerSockets.values()).filter(function (socket) { return socket && socket.readyState === WebSocket.OPEN; }).length; } function getConnectedPlayerDeviceId() { const socket = getConnectedPlayerSocket(); return socket && socket.playerDeviceId ? String(socket.playerDeviceId).trim() : ''; } function removeConnectedPlayerSocket(socket) { if (!socket || !socket.playerDeviceId) { return false; } const current = playerSockets.get(socket.playerDeviceId); if (current !== socket) { return false; } playerSockets.delete(socket.playerDeviceId); return true; } function logPlayerDisconnect(socket) { if (!socket || !socket.playerDeviceId) { return; } logBridge(`Player ${formatPlayerConnectionLabel(socket.playerDeviceId)} has disconnected`); } function resolveMediaPath(fileName) { const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, ''); if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return null; } return relativePath; } function sendPlayerCommand(commandPayload, deviceId) { const socket = resolvePlayerSocketForDeviceId(playerSockets, deviceId); if (!socket) { return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' }); } return sendPlayerCommandToSocket(socket, commandPayload); } function sendPlayerCommandToSocket(socket, commandPayload) { if (!socket || socket.readyState !== WebSocket.OPEN) { return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' }); } const requestId = crypto.randomUUID(); const payload = Object.assign({ type: 'command', requestId: requestId }, commandPayload || {}); return new Promise(function (resolve) { const timeout = setTimeout(function () { pendingPlayerCommands.delete(requestId); resolve({ ok: false, status: 504, error: 'Player command timed out.' }); }, 10000); pendingPlayerCommands.set(requestId, { resolve: function (message) { clearTimeout(timeout); pendingPlayerCommands.delete(requestId); resolve(message); } }); try { socket.send(JSON.stringify(payload)); } catch (error) { clearTimeout(timeout); pendingPlayerCommands.delete(requestId); resolve({ ok: false, status: 502, error: error && error.message ? error.message : 'Unable to send player command.' }); } }); } function getScreenPlayerDeviceIds(slug) { const key = String(slug || '').trim(); const deviceIds = screenPlayerDeviceIds.get(key); return Array.isArray(deviceIds) ? deviceIds.slice() : []; } function storeScreenSnapshot(slug, connections, deviceIds) { const key = String(slug || '').trim(); const normalizedConnections = Array.isArray(connections) ? connections : []; const normalizedDeviceIds = Array.isArray(deviceIds) ? deviceIds.map(function (value) { return normalizeDeviceId(value); }).filter(Boolean) : []; screenSnapshotCache.set(key, { slug: key, count: normalizedConnections.length, connections: normalizedConnections }); screenPlayerDeviceIds.set(key, Array.from(new Set(normalizedDeviceIds))); } app.get('/health', function (_req, res) { res.json({ ok: true, service: 'player-bridge' }); }); function requireRequestAuth(req, res, next) { if (!verifyRequestAuth(req)) { return res.status(401).json({ error: 'Request authentication required.' }); } next(); } app.get('/api/players', requireRequestAuth, async function (_req, res, next) { try { const [rows] = await pool.query( `SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, created_at, modified_at FROM d_players ORDER BY modified_at DESC, identifier ASC` ); res.json({ players: rows }); } catch (error) { next(error); } }); app.get('/api/players/connected-count', requireRequestAuth, function (_req, res) { res.json({ connectedPlayersCount: getConnectedPlayerCount() }); }); app.get('/api/screens/:slug/connections', requireRequestAuth, async function (req, res, next) { try { const slug = String(req.params.slug || '').trim(); if (!slug) { return res.status(400).json({ error: 'Screen slug is required.' }); } const snapshot = screenSnapshotCache.get(slug) || { slug: slug, count: 0, connections: [] }; res.json({ screenSlug: slug, count: Number(snapshot.count || 0), connections: Array.isArray(snapshot.connections) ? snapshot.connections : [] }); } catch (error) { next(error); } }); function requirePageAuth(allowedScopes) { return function (req, res, next) { const token = String(req.headers['x-pulse-page-auth'] || '').trim(); const payload = verifyPageAuthToken(token); if (!payload) { return res.status(401).json({ error: 'Page authentication required.' }); } const scopes = Array.isArray(allowedScopes) ? allowedScopes : []; if (scopes.length && scopes.indexOf(String(payload.scope || '').trim()) === -1) { return res.status(403).json({ error: 'Page authentication scope is not allowed for this route.' }); } req.playerPageAuth = payload; next(); }; } app.get('/api/media/config', requireRequestAuth, function (_req, res) { res.json({ mediaDir: config.mediaDir, uploadDir: path.join(config.mediaDir, 'uploads') }); }); app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) { try { const relativePath = resolveMediaPath(req.params.filename); if (!relativePath) { return res.status(400).json({ error: 'Filename is required' }); } const deviceId = normalizeDeviceId(req.query.deviceId || req.query.playerIdentifier || req.headers['x-pulse-player-device-id']); if (!deviceId) { return res.status(400).json({ error: 'Device ID is required.' }); } const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); const response = await sendPlayerCommand({ command: 'media-put', relativePath: relativePath, bodyBase64: bodyBuffer.toString('base64') }, deviceId); res.status(response.status || (response.ok ? 200 : 502)).json(response); } catch (error) { logBridge('Player media upload failed', { relativePath: req.params && req.params.filename ? String(req.params.filename).trim() : '', error: error && error.message ? error.message : String(error) }); next(error); } }); app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) { try { const relativePath = resolveMediaPath(req.params.filename); if (!relativePath) { return res.status(400).json({ error: 'Filename is required' }); } const deviceId = normalizeDeviceId(req.query.deviceId || req.query.playerIdentifier || req.headers['x-pulse-player-device-id']); if (!deviceId) { return res.status(400).json({ error: 'Device ID is required.' }); } const response = await sendPlayerCommand({ command: 'media-delete', relativePath: relativePath }, deviceId); res.status(response.status || (response.ok ? 200 : 502)).json(response); } catch (error) { logBridge('Player media delete failed', { relativePath: req.params && req.params.filename ? String(req.params.filename).trim() : '', error: error && error.message ? error.message : String(error) }); next(error); } }); app.post('/api/screens/:slug/commands', requireRequestAuth, async function (req, res, next) { try { const slug = String(req.params.slug || '').trim(); 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 (!slug) { return res.status(400).json({ error: 'Screen slug is required.' }); } if (!command) { return res.status(400).json({ error: 'Command is required.' }); } const targetPlayers = resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds); if (!targetPlayers.length) { return res.status(404).json({ error: 'Player is not connected.' }); } const payload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? Object.assign({}, req.body, { command: command, screenSlug: slug }) : { command: command, screenSlug: slug }; if (command === 'blackout' && blackoutValue !== undefined) { payload.blackout = blackoutValue; } const results = await Promise.all(targetPlayers.map(async function (target) { const requestBody = Object.assign({}, payload, connectionId ? { connectionId: connectionId } : {}); const response = await sendPlayerCommandToSocket(target.socket, requestBody); return Object.assign({ ok: Boolean(response && response.ok), status: response && response.status ? response.status : (response && response.ok ? 200 : 502), playerIdentifier: String(target.deviceId || '').trim() }, response && typeof response === 'object' ? response : {}); })); res.json({ ok: true, screenSlug: slug, command: command, connectionId: connectionId || null, sent: results.filter(function (result) { return result && result.ok; }).length, results: results }); } catch (error) { next(error); } }); app.get('/api/onboarding/status', async function (req, res, next) { try { const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId(); const status = await getOnboardingStatus(pool, deviceId); res.json({ deviceId: normalizeDeviceId(deviceId), onboarded: Boolean(status && status.screen_id), clientName: status ? status.client_name : null, screenId: status ? status.screen_id : null, screenSlug: status ? status.screen_slug : null, screenName: status ? status.screen_name : null, playerUrl: status && status.screen_slug ? `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null }); } catch (error) { next(error); } }); app.get('/api/onboarding/screens', requirePageAuth(['onboarding', 'player']), async function (_req, res, next) { try { const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC'); res.json({ screens: rows }); } catch (error) { next(error); } }); app.get('/api/onboarding/qr', async function (req, res, next) { try { const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId(); if (!deviceId) { return res.status(400).json({ error: 'Device ID is required' }); } const onboardingUrl = `${getPlayerPublicBaseUrl(req)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 }); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); res.set('Cache-Control', 'no-store'); res.send(svg); } catch (error) { next(error); } }); app.post('/api/onboarding', requirePageAuth(['onboarding', 'player']), express.json(), async function (req, res, next) { try { const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || getConnectedPlayerDeviceId(); const clientName = String((req.body && req.body.clientName) || '').trim(); const screenSlug = String((req.body && req.body.screenSlug) || '').trim(); if (!deviceId) { return res.status(503).json({ error: 'Player is not connected.' }); } if (!clientName) { return res.status(400).json({ error: 'Client name is required' }); } if (!screenSlug) { return res.status(400).json({ error: 'Screen is required' }); } const status = await commitDeviceBinding(pool, deviceId, clientName, screenSlug, null, []); await bindPlayerToScreen(pool, deviceId, screenSlug); res.json({ deviceId: deviceId, clientName: status ? status.client_name : clientName, screenId: status && status.screen_id ? status.screen_id : null, screenSlug: status ? status.screen_slug : screenSlug, screenName: status && status.screen_name ? status.screen_name : null, playerUrl: status && status.screen_slug ? `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`, queued: Boolean(status && status.queued) }); } catch (error) { const statusCode = Number(error && error.statusCode || error && error.status || 500); res.status(Number.isFinite(statusCode) && statusCode >= 400 ? statusCode : 500).json({ error: error && error.message ? error.message : 'Onboarding failed.' }); } }); app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) { try { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); const data = await playerPlaylistService.buildScreenPlaylist(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/announcement', requirePageAuth(['player']), async function (req, res, next) { try { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); const announcement = typeof common.fetchActiveAnnouncement === 'function' ? await common.fetchActiveAnnouncement(pool, req.params.slug) : null; const revision = announcement ? [announcement.id, announcement.modified_at || '', announcement.expires_at || '', announcement.enabled ? '1' : '0'].join(':') : 'none'; const etag = '"' + String(revision || 'none') + '"'; 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({ announcement: announcement, revision: revision }); } catch (error) { next(error); } }); app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) { try { const slide = await common.fetchSlideById(pool, Number(req.params.id)); if (!slide) { return res.status(404).send('Slide not found'); } const data = buildThumbnailPreviewData(slide); if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') { const rssData = await common.fetchRssFeedsData(pool); data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) { const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id); return Object.assign({}, feed, { items: items.map(function (item) { return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item; }) }); })); } if (typeof common.fetchApiSourcesData === 'function') { const apiData = await common.fetchApiSourcesData(pool); data.apiSources = (apiData.apiSources || []).map(function (source) { return Object.assign({}, source, { responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null }); }); } if (typeof common.fetchTimetablesData === 'function') { const timetableData = await common.fetchTimetablesData(pool); data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : []; } res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data)); } catch (error) { next(error); } }); app.post('/api/players/:deviceId/commands', requireRequestAuth, async function (req, res, next) { try { const deviceId = normalizeDeviceId(req.params.deviceId); const socket = playerSockets.get(deviceId); const payload = req.body && typeof req.body === 'object' ? req.body : {}; if (!socket || socket.readyState !== WebSocket.OPEN) { return res.status(404).json({ ok: false, error: 'Player is not connected.' }); } socket.send(JSON.stringify(payload)); res.json({ ok: true, deviceId: deviceId, sent: true }); } catch (error) { next(error); } }); async function handlePlayerMessage(socket, rawMessage) { let payload = null; try { payload = JSON.parse(String(rawMessage || '{}')); } catch (_error) { socket.send(JSON.stringify({ type: 'error', error: 'Invalid JSON payload.' })); return; } if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { socket.send(JSON.stringify({ type: 'error', error: 'Invalid player payload.' })); return; } if (String(payload.type || '').trim() === 'command-response') { const requestId = String(payload.requestId || '').trim(); if (requestId && pendingPlayerCommands.has(requestId)) { pendingPlayerCommands.get(requestId).resolve(payload); } return; } const messageType = String(payload.type || '').trim().toLowerCase(); const deviceId = normalizeDeviceId(payload.deviceId || payload.playerIdentifier || socket.playerDeviceId || ''); if (!deviceId) { socket.send(JSON.stringify({ type: 'error', error: 'Device ID is required.' })); return; } socket.playerDeviceId = deviceId; if (messageType === 'snapshot') { const slug = String(payload.slug || '').trim(); if (!slug) { return; } setScreenSnapshotSource(slug, deviceId, Array.isArray(payload.connections) ? payload.connections : []); return; } if (messageType === 'register') { const player = await upsertPlayerRegistration(pool, { deviceId: deviceId, publicBaseUrl: payload.publicBaseUrl, internalBaseUrl: payload.internalBaseUrl }); playerSockets.set(deviceId, socket); logBridge(`Player ${formatPlayerConnectionLabel(deviceId)} has connected`); socket.send(JSON.stringify({ type: 'registered', ok: true, player: player })); return; } if (messageType === 'heartbeat') { const player = await recordPlayerHeartbeat(pool, { deviceId: deviceId, publicBaseUrl: payload.publicBaseUrl, internalBaseUrl: payload.internalBaseUrl }); socket.send(JSON.stringify({ type: 'heartbeat-ack', ok: true, player: player })); return; } socket.send(JSON.stringify({ type: 'error', error: 'Unsupported player message type.' })); } server.on('upgrade', function (request, socket, head) { let pathname = ''; try { pathname = new URL(request.url, 'http://localhost').pathname; } catch (_error) { socket.destroy(); return; } if (pathname !== '/ws/players') { const screenMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/); if (!screenMatch) { socket.destroy(); return; } if (!verifyRequestAuth(request)) { socket.destroy(); return; } screenSnapshotsWs.handleUpgrade(request, socket, head, function (ws) { screenSnapshotsWs.emit('connection', ws, request, decodeURIComponent(screenMatch[1]), 'screen-snapshots'); }); return; } if (!verifyRequestAuth(request)) { logBridge('Player denied with wrong shared secret'); socket.destroy(); return; } playersWs.handleUpgrade(request, socket, head, function (ws) { playersWs.emit('connection', ws, request); }); }); playersWs.on('connection', function (socket) { socket.send(JSON.stringify({ type: 'ready', channel: 'player-bridge' })); socket.on('message', function (message) { handlePlayerMessage(socket, message).catch(function (error) { console.error(error); socket.send(JSON.stringify({ type: 'error', error: 'Player bridge request failed.' })); }); }); socket.on('close', function () { clearPlayerSnapshotSources(socket.playerDeviceId); if (removeConnectedPlayerSocket(socket)) { logPlayerDisconnect(socket); } }); socket.on('error', function () { clearPlayerSnapshotSources(socket.playerDeviceId); if (removeConnectedPlayerSocket(socket)) { logPlayerDisconnect(socket); } }); }); screenSnapshotsWs.on('connection', function (socket, request, slug) { const normalizedSlug = String(slug || '').trim(); const upstreamSockets = new Map(); const upstreamDeviceIds = new Set(); let refreshTimer = null; let closed = false; const subscriberBucket = getScreenSnapshotSubscriberBucket(normalizedSlug); if (!subscriberBucket) { socket.close(); return; } subscriberBucket.add(socket); function closeUpstreamSockets() { upstreamSockets.forEach(function (upstreamSocket) { try { upstreamSocket.close(); } catch (_error) { } }); upstreamSockets.clear(); } async function refreshUpstreams() { if (closed || socket.readyState !== WebSocket.OPEN || !normalizedSlug) { return; } let players = []; try { players = await fetchPlayerSnapshotRegistrations(); } catch (_error) { return; } const seenKeys = new Set(); players.forEach(function (player) { const sourceKey = String(player && player.identifier || player && player.id || '').trim(); const connectedSocket = sourceKey ? playerSockets.get(sourceKey) : null; if (connectedSocket && connectedSocket.readyState === WebSocket.OPEN) { seenKeys.add(sourceKey); upstreamDeviceIds.add(sourceKey); return; } const baseUrl = resolveSnapshotUpstreamBaseUrl(player); if (!baseUrl) { return; } seenKeys.add(sourceKey); upstreamDeviceIds.add(sourceKey); if (upstreamSockets.has(sourceKey)) { return; } const upstreamUrl = new URL(baseUrl.replace(/^http/, 'ws')); upstreamUrl.pathname = `/ws/screens/${encodeURIComponent(normalizedSlug)}/events`; upstreamUrl.search = ''; const upstreamHeaders = createRequestAuthHeaders({ method: 'GET', pathname: `/ws/screens/${encodeURIComponent(normalizedSlug)}/events` }); const upstreamSocket = new WebSocket(upstreamUrl.toString(), { headers: upstreamHeaders }); upstreamSockets.set(sourceKey, upstreamSocket); upstreamSocket.onmessage = function (event) { try { const payload = JSON.parse(String(event.data || '{}')); if (!payload || payload.type !== 'snapshot' || String(payload.slug || '').trim() !== normalizedSlug) { return; } setScreenSnapshotSource(normalizedSlug, sourceKey, Array.isArray(payload.connections) ? payload.connections : []); } catch (_error) { } }; upstreamSocket.onclose = function () { upstreamSockets.delete(sourceKey); if (!closed && !(playerSockets.get(sourceKey) && playerSockets.get(sourceKey).readyState === WebSocket.OPEN)) { clearScreenSnapshotSource(normalizedSlug, sourceKey); } }; upstreamSocket.onerror = function () { try { upstreamSocket.close(); } catch (_error) { } }; }); Array.from(upstreamSockets.keys()).forEach(function (sourceKey) { if (!seenKeys.has(sourceKey)) { const upstreamSocket = upstreamSockets.get(sourceKey); upstreamSockets.delete(sourceKey); upstreamSnapshots.delete(sourceKey); upstreamDeviceIds.delete(sourceKey); try { upstreamSocket.close(); } catch (_error) { } } }); broadcastScreenSnapshot(normalizedSlug); } refreshUpstreams(); refreshTimer = setInterval(function () { refreshUpstreams().catch(function (_error) { }); }, 5000); socket.on('close', function () { closed = true; subscriberBucket.delete(socket); if (!subscriberBucket.size) { screenSnapshotSubscribersBySlug.delete(normalizedSlug); } if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } closeUpstreamSockets(); }); socket.on('error', function () { closed = true; subscriberBucket.delete(socket); if (!subscriberBucket.size) { screenSnapshotSubscribersBySlug.delete(normalizedSlug); } if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } closeUpstreamSockets(); }); }); app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) { try { logBridge('Relaying player media sync request to web'); const webBaseUrl = resolveWebBaseUrl(_req); if (!webBaseUrl) { return res.status(502).json({ error: 'Web base URL is not configured.' }); } const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body) ? Object.assign({}, _req.body) : {}; const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, { method: 'POST', headers: Object.assign({ Accept: 'application/json', 'Content-Type': 'application/json' }, createRequestAuthHeaders({ method: 'POST', pathname: '/api/internal/sync/player-media', body: requestBody })), body: JSON.stringify(requestBody) }); logBridge('Web player media sync response received', { ok: Boolean(response && response.ok), status: response && response.status ? response.status : null }); res.status(response.status); const contentType = response.headers.get('content-type'); if (contentType) { res.type(contentType); } res.send(await response.text()); } catch (error) { logBridge('Player media sync relay failed', { error: error && error.message ? error.message : String(error) }); next(error); } }); app.post('/api/internal/sync/player-font', requireRequestAuth, async function (_req, res, next) { try { logBridge('Relaying player font sync request to web'); const webBaseUrl = resolveWebBaseUrl(_req); if (!webBaseUrl) { return res.status(502).json({ error: 'Web base URL is not configured.' }); } const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body) ? Object.assign({}, _req.body) : {}; const response = await fetch(`${webBaseUrl}/api/internal/sync/player-font`, { method: 'POST', headers: Object.assign({ Accept: 'application/json', 'Content-Type': 'application/json' }, createRequestAuthHeaders({ method: 'POST', pathname: '/api/internal/sync/player-font', body: requestBody })), body: JSON.stringify(requestBody) }); logBridge('Web player font sync response received', { ok: Boolean(response && response.ok), status: response && response.status ? response.status : null }); res.status(response.status); const contentType = response.headers.get('content-type'); if (contentType) { res.type(contentType); } res.send(await response.text()); } catch (error) { logBridge('Player font sync relay failed', { error: error && error.message ? error.message : String(error) }); next(error); } }); fs.mkdirSync(config.mediaDir, { recursive: true }); server.listen(config.port, function () { console.log(`Player bridge listening on port ${config.port}`); }); } module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl, resolvePlayerSocketForDeviceId: resolvePlayerSocketForDeviceId }; if (require.main === module) { start().catch(function (error) { console.error(error); process.exit(1); }); }