1343 lines
46 KiB
JavaScript
1343 lines
46 KiB
JavaScript
// Thin-client bridge for player registration, snapshots, commands, and heartbeats.
|
|
|
|
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 { 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,
|
|
mediaDir: config.mediaDir,
|
|
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 findPlayerByPairingCode(pairingCode) {
|
|
const normalizedCode = String(pairingCode || '').trim().toUpperCase();
|
|
if (!normalizedCode) {
|
|
return null;
|
|
}
|
|
for (const [deviceId, socket] of playerSockets.entries()) {
|
|
const pairingCodes = socket && Array.isArray(socket.pairingSessions)
|
|
? socket.pairingSessions.map(function (entry) { return entry.code; })
|
|
: (socket && Array.isArray(socket.pairingCodes) ? socket.pairingCodes : [socket && socket.pairingCode]);
|
|
if (socket && pairingCodes.some(function (code) {
|
|
return String(code || '').trim().toUpperCase() === normalizedCode;
|
|
})) {
|
|
const session = socket.pairingSessions && socket.pairingSessions.find(function (entry) {
|
|
return String(entry.code || '').trim().toUpperCase() === normalizedCode;
|
|
});
|
|
return { deviceId: deviceId, socket: socket, clientId: session && session.clientId ? session.clientId : null, code: normalizedCode };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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 = [];
|
|
const connectionIndexes = new Map();
|
|
(Array.isArray(connections) ? connections : []).forEach(function (connection) {
|
|
const identity = connection && typeof connection === 'object'
|
|
? [String(connection.deviceId || '').trim(), String(connection.clientId || '').trim()].join('|')
|
|
: '';
|
|
if (!identity || !connectionIndexes.has(identity)) {
|
|
if (identity) {
|
|
connectionIndexes.set(identity, normalizedConnections.length);
|
|
}
|
|
normalizedConnections.push(connection);
|
|
return;
|
|
}
|
|
normalizedConnections[connectionIndexes.get(identity)] = connection;
|
|
});
|
|
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();
|
|
};
|
|
}
|
|
|
|
function requireOnboardingAuth(req, res, next) {
|
|
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
|
|
const payload = verifyPageAuthToken(token);
|
|
if (payload && ['onboarding', 'player'].indexOf(String(payload.scope || '').trim()) !== -1) {
|
|
req.playerPageAuth = payload;
|
|
return next();
|
|
}
|
|
|
|
if (verifyRequestAuth(req)) {
|
|
return next();
|
|
}
|
|
|
|
return res.status(401).json({ error: 'Onboarding authentication required.' });
|
|
}
|
|
|
|
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 : {});
|
|
}));
|
|
|
|
logBridge('Screen command result', {
|
|
screenSlug: slug,
|
|
command: command,
|
|
connectionId: connectionId || null,
|
|
targets: targetPlayers.map(function (target) { return target.deviceId; }),
|
|
results: results.map(function (result) {
|
|
return { playerIdentifier: result.playerIdentifier, ok: result.ok, status: result.status, error: result.error || null };
|
|
})
|
|
});
|
|
|
|
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.post('/api/screens/:slug/announcements/refresh', requireRequestAuth, function (req, res) {
|
|
const slug = String(req.params.slug || '').trim();
|
|
if (!slug) {
|
|
return res.status(400).json({ error: 'Screen slug is required.' });
|
|
}
|
|
|
|
const targetPlayers = Array.from(playerSockets.values()).filter(function (socket) {
|
|
return socket && socket.readyState === WebSocket.OPEN;
|
|
}).map(function (socket) {
|
|
return { socket: socket };
|
|
});
|
|
let sent = 0;
|
|
targetPlayers.forEach(function (target) {
|
|
if (!target || !target.socket || target.socket.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
target.socket.send(JSON.stringify({
|
|
type: 'command',
|
|
command: 'announcement-refresh',
|
|
screenSlug: slug,
|
|
sentAt: new Date().toISOString()
|
|
}));
|
|
sent += 1;
|
|
} catch (_error) {
|
|
}
|
|
});
|
|
|
|
return res.json({ ok: true, screenSlug: slug, sent: sent });
|
|
});
|
|
|
|
app.get('/api/onboarding/status', async function (req, res, next) {
|
|
try {
|
|
const requestedDeviceId = normalizeDeviceId(req.query.deviceId);
|
|
const deviceId = requestedDeviceId;
|
|
const status = await getOnboardingStatus(pool, deviceId);
|
|
res.json({
|
|
deviceId: requestedDeviceId,
|
|
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/resolve', requireRequestAuth, function (req, res) {
|
|
const pairing = findPlayerByPairingCode(req.query.pairingCode);
|
|
if (!pairing) {
|
|
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
|
|
}
|
|
res.json({
|
|
deviceId: pairing.deviceId,
|
|
clientId: pairing.clientId || null,
|
|
});
|
|
});
|
|
|
|
app.get('/api/onboarding/url', requireRequestAuth, function (req, res) {
|
|
const pairing = findPlayerByPairingCode(req.query.pairingCode);
|
|
if (!pairing) {
|
|
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
|
|
}
|
|
const webBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
|
if (!webBaseUrl) {
|
|
return res.status(503).json({ error: 'WEB_PUBLIC_URL is not configured on the bridge.' });
|
|
}
|
|
res.json({ url: `${webBaseUrl}/pairing?code=${encodeURIComponent(pairing.code)}` });
|
|
});
|
|
|
|
app.get('/api/onboarding/qr', requireRequestAuth, async function (req, res, next) {
|
|
try {
|
|
const pairing = findPlayerByPairingCode(req.query.pairingCode);
|
|
if (!pairing) {
|
|
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
|
|
}
|
|
const webBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
|
if (!webBaseUrl) {
|
|
return res.status(503).json({ error: 'WEB_PUBLIC_URL is not configured on the bridge.' });
|
|
}
|
|
const svg = await createStyledQrCodeSvg({
|
|
value: `${webBaseUrl}/pairing?code=${encodeURIComponent(pairing.code)}`,
|
|
qr_margin: 20,
|
|
qr_dots_type: 'dots',
|
|
qr_dots_color: '#f4f8f5',
|
|
qr_corners_square_type: 'dot',
|
|
qr_corners_square_color: '#f4f8f5',
|
|
qr_corners_dot_type: 'dot',
|
|
qr_corners_dot_color: '#f0bd70',
|
|
qr_background_transparent: true
|
|
});
|
|
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
|
res.set('Cache-Control', 'no-store');
|
|
res.send(svg);
|
|
} 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.post('/api/onboarding', express.json(), requireOnboardingAuth, async function (req, res, next) {
|
|
try {
|
|
const pairingCode = String(req.body && req.body.pairingCode || '').trim().toUpperCase();
|
|
const pairing = findPlayerByPairingCode(pairingCode);
|
|
const clientId = normalizeDeviceId(req.body && req.body.clientId);
|
|
if (!pairing) {
|
|
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
|
|
}
|
|
if (!clientId) {
|
|
return res.status(400).json({ error: 'Client ID is required.' });
|
|
}
|
|
const deviceId = pairing.deviceId;
|
|
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, clientId, clientName, screenSlug, null, []);
|
|
await bindPlayerToScreen(pool, deviceId, screenSlug);
|
|
await sendPlayerCommandToSocket(pairing.socket, {
|
|
command: 'redirect',
|
|
url: `${String(pairing.socket.publicBaseUrl || getPlayerPublicBaseUrl(req)).replace(/\/$/, '')}/screen/${encodeURIComponent(screenSlug)}`,
|
|
clientId: clientId
|
|
});
|
|
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 {
|
|
const clientId = String(req.headers['x-pulse-client-id'] || '').trim();
|
|
const status = await getOnboardingStatus(pool, clientId);
|
|
if (!clientId || !status || String(status.screen_slug || '').trim() !== String(req.params.slug || '').trim()) {
|
|
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
|
|
}
|
|
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 {
|
|
const clientId = String(req.headers['x-pulse-client-id'] || '').trim();
|
|
const status = await getOnboardingStatus(pool, clientId);
|
|
if (!clientId || !status || String(status.screen_slug || '').trim() !== String(req.params.slug || '').trim()) {
|
|
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
|
|
}
|
|
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.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;
|
|
socket.pairingSessions = Array.isArray(payload.pairingSessions) ? payload.pairingSessions.map(function (entry) {
|
|
return {
|
|
deviceId: normalizeDeviceId(entry && entry.deviceId),
|
|
clientId: normalizeDeviceId(entry && entry.clientId),
|
|
code: String(entry && entry.code || '').trim().toUpperCase()
|
|
};
|
|
}).filter(function (entry) { return entry.deviceId && entry.code; }) : [];
|
|
socket.pairingCodes = Array.from(new Set((Array.isArray(payload.pairingCodes) ? payload.pairingCodes : [payload.pairingCode]).map(function (value) {
|
|
return String(value || '').trim().toUpperCase();
|
|
}).filter(Boolean)));
|
|
socket.pairingCode = socket.pairingCodes[0] || '';
|
|
|
|
if (messageType === 'snapshot') {
|
|
const slug = String(payload.slug || '').trim();
|
|
if (!slug) {
|
|
return;
|
|
}
|
|
|
|
const snapshotPlayerPublicBaseUrl = String(payload.playerPublicBaseUrl || socket.publicBaseUrl || '').trim().replace(/\/$/, '');
|
|
const connections = Array.isArray(payload.connections) ? payload.connections.map(function (connection) {
|
|
if (!connection || typeof connection !== 'object' || !snapshotPlayerPublicBaseUrl) {
|
|
return connection;
|
|
}
|
|
return Object.assign({}, connection, { playerPublicBaseUrl: snapshotPlayerPublicBaseUrl });
|
|
}) : [];
|
|
setScreenSnapshotSource(slug, deviceId, connections);
|
|
return;
|
|
}
|
|
|
|
if (messageType === 'register') {
|
|
socket.publicBaseUrl = String(payload.publicBaseUrl || '').trim().replace(/\/$/, '');
|
|
const player = await upsertPlayerRegistration(pool, {
|
|
deviceId: deviceId,
|
|
publicBaseUrl: payload.publicBaseUrl,
|
|
internalBaseUrl: payload.internalBaseUrl
|
|
});
|
|
|
|
const previousSocket = playerSockets.get(deviceId);
|
|
playerSockets.set(deviceId, socket);
|
|
if (previousSocket && previousSocket !== socket && previousSocket.readyState !== WebSocket.CLOSED) {
|
|
try {
|
|
previousSocket.close(1000, 'Replaced by a newer player connection.');
|
|
} catch (_error) {
|
|
}
|
|
}
|
|
logBridge(`Player ${formatPlayerConnectionLabel(deviceId)} has connected`);
|
|
socket.send(JSON.stringify({ type: 'registered', ok: true, player: player }));
|
|
return;
|
|
}
|
|
|
|
if (messageType === 'heartbeat') {
|
|
socket.publicBaseUrl = String(payload.publicBaseUrl || socket.publicBaseUrl || '').trim().replace(/\/$/, '');
|
|
const player = await recordPlayerHeartbeat(pool, {
|
|
deviceId: deviceId,
|
|
publicBaseUrl: payload.publicBaseUrl,
|
|
internalBaseUrl: payload.internalBaseUrl
|
|
});
|
|
if (typeof common.touchOnboardingDeviceLastSeen === 'function') {
|
|
const onboardingClientIds = (Array.isArray(payload.connections) ? payload.connections : []).map(function (connection) {
|
|
return connection && connection.clientId;
|
|
});
|
|
await common.touchOnboardingDeviceLastSeen(pool, onboardingClientIds);
|
|
}
|
|
|
|
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 () {
|
|
if (removeConnectedPlayerSocket(socket)) {
|
|
clearPlayerSnapshotSources(socket.playerDeviceId);
|
|
logPlayerDisconnect(socket);
|
|
}
|
|
});
|
|
|
|
socket.on('error', function () {
|
|
if (removeConnectedPlayerSocket(socket)) {
|
|
clearPlayerSnapshotSources(socket.playerDeviceId);
|
|
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);
|
|
});
|
|
}
|