Files
pulse-signage/src/player-bridge/index.js
T

926 lines
32 KiB
JavaScript

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';
const normalizedRemoteAddress = normalizeRemoteAddress(remoteAddress);
return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : normalizedDeviceId;
}
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);
if (!Array.isArray(deviceIds) || !deviceIds.length) {
return [];
}
return 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);
}
function resolveWebBaseUrl(req) {
const configuredWebBaseUrl = String(process.env.WEB_BASE_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 screenPlayerDeviceIds = new Map();
const pendingPlayerCommands = new Map();
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;
}
}
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, socket.bridgeRemoteAddress)} 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) {
const socket = getConnectedPlayerSocket();
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 bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
const response = await sendPlayerCommand({
command: 'media-put',
relativePath: relativePath,
bodyBase64: bodyBuffer.toString('base64')
});
res.status(response.status || (response.ok ? 200 : 502)).json(response);
} catch (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 response = await sendPlayerCommand({
command: 'media-delete',
relativePath: relativePath
});
res.status(response.status || (response.ok ? 200 : 502)).json(response);
} catch (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 === 'register') {
const player = await upsertPlayerRegistration(pool, {
deviceId: deviceId,
publicBaseUrl: payload.publicBaseUrl,
internalBaseUrl: payload.internalBaseUrl
});
playerSockets.set(deviceId, socket);
logBridge(`Player ${formatPlayerConnectionLabel(deviceId, socket.bridgeRemoteAddress)} 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)) {
const remoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
logBridge(remoteAddress ? `Player (ip ${remoteAddress}) denied with wrong shared secret` : 'Player denied with wrong shared secret');
socket.destroy();
return;
}
playersWs.handleUpgrade(request, socket, head, function (ws) {
ws.bridgeRemoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
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 () {
if (removeConnectedPlayerSocket(socket)) {
logPlayerDisconnect(socket);
}
});
socket.on('error', function () {
if (removeConnectedPlayerSocket(socket)) {
logPlayerDisconnect(socket);
}
});
});
screenSnapshotsWs.on('connection', function (socket, request, slug) {
const normalizedSlug = String(slug || '').trim();
const upstreamSockets = new Map();
const upstreamSnapshots = new Map();
const upstreamDeviceIds = new Set();
let refreshTimer = null;
let closed = false;
function sendMergedSnapshot() {
if (!normalizedSlug || socket.readyState !== WebSocket.OPEN) {
return;
}
const connections = [];
upstreamSnapshots.forEach(function (payload) {
if (payload && Array.isArray(payload.connections)) {
connections.push.apply(connections, payload.connections);
}
});
storeScreenSnapshot(normalizedSlug, connections, Array.from(upstreamDeviceIds));
socket.send(JSON.stringify({
type: 'snapshot',
slug: normalizedSlug,
connections: connections,
sentAt: new Date().toISOString()
}));
}
function closeUpstreamSockets() {
upstreamSockets.forEach(function (upstreamSocket) {
try {
upstreamSocket.close();
} catch (_error) {
}
});
upstreamSockets.clear();
upstreamSnapshots.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 baseUrl = normalizeProxyBaseUrl(player && player.public_base_url);
if (!baseUrl) {
return;
}
const sourceKey = String(player && player.identifier || player && player.id || baseUrl);
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;
}
upstreamSnapshots.set(sourceKey, {
slug: normalizedSlug,
connections: Array.isArray(payload.connections) ? payload.connections : []
});
sendMergedSnapshot();
} catch (_error) {
}
};
upstreamSocket.onclose = function () {
upstreamSockets.delete(sourceKey);
upstreamSnapshots.delete(sourceKey);
if (!closed) {
sendMergedSnapshot();
}
};
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) {
}
}
});
sendMergedSnapshot();
}
refreshUpstreams();
refreshTimer = setInterval(function () {
refreshUpstreams().catch(function (_error) {
});
}, 5000);
socket.on('close', function () {
closed = true;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
closeUpstreamSockets();
});
socket.on('error', function () {
closed = true;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
closeUpstreamSockets();
});
});
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
try {
const webBaseUrl = resolveWebBaseUrl(_req);
if (!webBaseUrl) {
return res.status(502).json({ error: 'Web base URL is not configured.' });
}
const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-media'
}))
});
res.status(response.status);
const contentType = response.headers.get('content-type');
if (contentType) {
res.type(contentType);
}
res.send(await response.text());
} catch (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 };
if (require.main === module) {
start().catch(function (error) {
console.error(error);
process.exit(1);
});
}