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