Add player control-plane and dashboard updates

This commit is contained in:
2026-08-07 22:35:27 +01:00
parent a4f8a807ff
commit 2bd47fb96a
36 changed files with 5081 additions and 263 deletions
+322 -19
View File
@@ -2,6 +2,7 @@ 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');
@@ -10,16 +11,20 @@ const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding }
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 = common.createPool();
const pool = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '') ? null : common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 8081);
const PLAYER_PUBLIC_BASE_URL = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const PLAYER_INTERNAL_BASE_URL = String(process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const PLAYER_IDENTIFIER = '1';
const THIN_CLIENT_BASE_URL = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
const isRemotePlayer = Boolean(THIN_CLIENT_BASE_URL);
const PLAYER_INTERNAL_BASE_URL = String(isRemotePlayer ? THIN_CLIENT_BASE_URL : (process.env.PLAYER_INTERNAL_BASE_URL || PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, '');
const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier();
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');
@@ -29,24 +34,170 @@ async function start() {
pool: pool,
normalizeDeviceId: normalizeDeviceId
});
const playerPlaylistService = createPlayerPlaylistService({
pool: pool,
common: common,
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
});
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 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: PLAYER_PUBLIC_BASE_URL || null,
bridgeBaseUrl: PLAYER_INTERNAL_BASE_URL || null,
bridgeWebSocketUrl: THIN_CLIENT_BASE_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() {
if (!isRemotePlayer || !THIN_CLIENT_BASE_URL) {
return false;
}
try {
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-media'
});
const response = await fetch(`${THIN_CLIENT_BASE_URL}/api/internal/sync/player-media`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json'
}, authHeaders)
});
return Boolean(response && response.ok);
} catch (_error) {
return false;
}
}
let webMediaSyncCompleted = false;
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: PLAYER_PUBLIC_BASE_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
thinClientBaseUrl: THIN_CLIENT_BASE_URL,
playerDeviceId: PLAYER_DEVICE_ID
});
registerPlayerRoutes(app, {
pool: pool,
@@ -58,24 +209,165 @@ async function start() {
rtmpStreamService: rtmpStreamService,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
playerIdentifier: PLAYER_IDENTIFIER
thinClientBaseUrl: THIN_CLIENT_BASE_URL,
playerDeviceId: PLAYER_DEVICE_ID
});
app.use(function (error, _req, res, _next) {
console.error(error);
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
});
function createThinClientWebSocketUrl() {
if (!THIN_CLIENT_BASE_URL) {
return null;
}
fs.mkdirSync(MEDIA_DIR, { recursive: true });
await ensureFontLibrary(MEDIA_DIR);
return THIN_CLIENT_BASE_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)
});
socket.on('open', function () {
logPlayerStartup({
connected: true
});
socket.send(JSON.stringify({
type: 'register',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
}));
if (!webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () {
webMediaSyncTriggered = false;
});
}
heartbeatTimer = setInterval(function () {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(JSON.stringify({
type: 'heartbeat',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
}));
if (!webMediaSyncTriggered && !webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () {
webMediaSyncTriggered = false;
});
}
}, DB_SYNC_INTERVAL_MS);
});
socket.on('message', function (rawMessage) {
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 () {
clearTimers();
reconnectTimer = setTimeout(connect, 5000);
});
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 common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
await recordPlayerHeartbeat(pool, {
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
}).catch(function (error) {
console.error(error);
});
if (playerRuntime.snapshotAllConnections().length > 0) {
await common.pruneStaleOnboardingDevices(pool);
@@ -98,16 +390,27 @@ async function start() {
await syncDatabaseState();
if (PLAYER_IDENTIFIER) {
if (PLAYER_DEVICE_ID && !isRemotePlayer) {
const { upsertPlayerRegistration } = require('./player/onboarding');
await upsertPlayerRegistration(pool, PLAYER_IDENTIFIER, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL, null);
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_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 };