Split web and player build artifacts

This commit is contained in:
2026-08-08 20:38:44 +01:00
parent 38565a533d
commit c3b5a0053c
33 changed files with 1113 additions and 140 deletions
+2
View File
@@ -1,5 +1,7 @@
*
!package.json
!build/
!build/**
!src/
!src/**
!scripts/
+12 -4
View File
@@ -10,6 +10,16 @@ jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: web
image: git.lzstealth.com/lzstealth/pulse-signage-web
dockerfile: ./build/Dockerfile
- name: player
image: git.lzstealth.com/lzstealth/pulse-signage-player
dockerfile: ./build/Dockerfile.player
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -28,18 +38,16 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: git.lzstealth.com/LZStealth/pulse-signage
images: ${{ matrix.image }}
tags: |
type=raw,value=latest
type=ref,event=tag
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
file: ${{ matrix.dockerfile }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+8
View File
@@ -2,6 +2,14 @@
All notable changes to this project will be documented in this file.
## 2.6.12 - 2026-08-08
### Fixed
- The Docker build inputs now live under `build/`, with separate web and player package manifests so the player image no longer carries the web browser tooling bundle.
- Remote player sync and upload routing now carry the exact connected player device id through the bridge, so startup media and font jobs target the correct player instance in split-device deployments.
- Player websocket registration now learns the public base URL from the actual screen request origin, so localhost and 127.0.0.1 aliases both keep websocket commands working.
## 2.6.11 - 2026-08-08
### Fixed
-20
View File
@@ -1,20 +0,0 @@
FROM node:24-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg chromium nss freetype harfbuzz ttf-freefont
COPY package*.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
COPY scripts ./scripts
RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css
RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css
RUN mkdir -p /app/media
EXPOSE 3000
CMD ["npm", "run", "start:web"]
+17
View File
@@ -0,0 +1,17 @@
FROM node:24-alpine
WORKDIR /app
RUN apk add --no-cache chromium nss freetype harfbuzz ttf-freefont
COPY build/package.web.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
COPY scripts ./scripts
RUN mkdir -p /app/media
EXPOSE 3000
CMD ["npm", "run", "start:web"]
+17
View File
@@ -0,0 +1,17 @@
FROM node:24-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg
COPY build/package.player.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
COPY scripts ./scripts
RUN mkdir -p /app/media
EXPOSE 3000
CMD ["npm", "run", "start:player"]
+19
View File
@@ -0,0 +1,19 @@
{
"name": "pulse-signage-player",
"version": "2.6.12",
"private": false,
"description": "Pulse Signage player application bundle",
"main": "src/common.js",
"scripts": {
"start": "node -r dotenv/config src/player.js",
"start:player": "node -r dotenv/config src/player.js"
},
"dependencies": {
"dotenv": "^17.4.2",
"express": "^4.21.2",
"handlebars": "^4.7.8",
"hls.js": "^1.5.15",
"mysql2": "^3.14.3",
"ws": "^8.21.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "pulse-signage-web",
"version": "2.6.12",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"main": "src/common.js",
"scripts": {
"start": "node -r dotenv/config src/web.js",
"start:web": "node -r dotenv/config src/web.js"
},
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"dotenv": "^17.4.2",
"express": "^4.21.2",
"handlebars": "^4.7.8",
"multer": "^2.2.0",
"mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0",
"sharp": "^0.35.3",
"ws": "^8.21.0"
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
# Shared application settings
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest"
PULSE_SIGNAGE_WEB_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-web:latest"
PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:latest"
PULSE_SIGNAGE_SHARED_SECRET=""
# Database settings for the web, player, and bridge services
+1 -1
View File
@@ -1,5 +1,5 @@
# Shared application settings
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest"
PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:latest"
PULSE_SIGNAGE_SHARED_SECRET=""
# Player settings
+6 -4
View File
@@ -113,7 +113,8 @@ Use this file as a starting point for the public compose stack.
Important values:
- `PULSE_SIGNAGE_IMAGE` - image to run for all app services
- `PULSE_SIGNAGE_WEB_IMAGE` - image to run for the web app and bridge services, typically `.../pulse-signage-web:latest`
- `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run for the player services, typically `.../pulse-signage-player:latest`
- `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests
- `PLAYER_IDENTIFIER` - unique local player identifier
- `DB_*` - MySQL credentials and database name for the stack
@@ -132,7 +133,7 @@ Use this file on a remote player device.
Important values:
- `PULSE_SIGNAGE_IMAGE` - image to run on the device
- `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run on the device, typically `.../pulse-signage-player:latest`
- `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment
- `PLAYER_IDENTIFIER` - unique remote player identifier
- `PLAYER_PUBLIC_URL` - public URL for the remote player
@@ -157,7 +158,8 @@ Leave it blank only if you intentionally want to run without request signing in
| Variable | Used By | Purpose |
| --- | --- | --- |
| `PULSE_SIGNAGE_IMAGE` | web, player, bridge, remote player | Docker image to run for the app services. |
| `PULSE_SIGNAGE_WEB_IMAGE` | web, bridge | Docker image to run for the web app and bridge services. |
| `PULSE_SIGNAGE_PLAYER_IMAGE` | player, remote player | Docker image to run for the player services. |
| `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge, remote player | Shared secret for authenticated requests between services. |
| `DB_HOST` | web, player, bridge | Database host name. |
| `DB_PORT` | web, player, bridge | Database port. |
@@ -218,7 +220,7 @@ Each compose file creates its own named network:
- A remote player must use the same `PULSE_SIGNAGE_SHARED_SECRET` as the bridge it connects to.
- The bridge service is the dashboard-facing command path for connected remote players.
- The remote player should point `BRIDGE_PUBLIC_URL` at the bridge, not at the public web endpoint.
- The `PULSE_SIGNAGE_IMAGE` tag defaults to the published image, but it can be overridden for local builds or custom releases.
- The `PULSE_SIGNAGE_WEB_IMAGE` and `PULSE_SIGNAGE_PLAYER_IMAGE` tags default to the published `pulse-signage-web` and `pulse-signage-player` repositories with `latest` and `v1.2.3` style tags, but they can be overridden for local builds or custom releases.
## Recommended Setup
+1 -1
View File
@@ -3,7 +3,7 @@ name: pulse-signage-remote
services:
player:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest}
restart: unless-stopped
networks:
- pulse_signage
+3 -3
View File
@@ -2,7 +2,7 @@ name: pulse-signage
services:
web:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest}
restart: unless-stopped
networks:
- pulse_signage
@@ -28,7 +28,7 @@ services:
condition: service_healthy
player:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest}
restart: unless-stopped
networks:
- pulse_signage
@@ -52,7 +52,7 @@ services:
condition: service_healthy
player-bridge:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest}
restart: unless-stopped
networks:
- pulse_signage
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.6.11",
"version": "2.6.12",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
+124 -6
View File
@@ -92,6 +92,20 @@ function resolveSnapshotUpstreamBaseUrl(player) {
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') {
@@ -369,8 +383,8 @@ async function start() {
return relativePath;
}
function sendPlayerCommand(commandPayload) {
const socket = getConnectedPlayerSocket();
function sendPlayerCommand(commandPayload, deviceId) {
const socket = resolvePlayerSocketForDeviceId(playerSockets, deviceId);
if (!socket) {
return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' });
}
@@ -513,15 +527,37 @@ async function start() {
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.' });
}
logBridge('Forwarding media upload to player', {
deviceId: deviceId,
relativePath: relativePath,
contentLength: Buffer.isBuffer(req.body) ? req.body.length : 0
});
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);
logBridge('Player media upload completed', {
deviceId: deviceId,
relativePath: relativePath,
ok: Boolean(response && response.ok),
status: response && response.status ? response.status : null
});
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);
}
});
@@ -533,13 +569,34 @@ async function start() {
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.' });
}
logBridge('Forwarding media delete to player', {
deviceId: deviceId,
relativePath: relativePath
});
const response = await sendPlayerCommand({
command: 'media-delete',
relativePath: relativePath
}, deviceId);
logBridge('Player media delete completed', {
deviceId: deviceId,
relativePath: relativePath,
ok: Boolean(response && response.ok),
status: response && response.status ? response.status : null
});
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);
}
});
@@ -1054,19 +1111,32 @@ async function start() {
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'
Accept: 'application/json',
'Content-Type': 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-media'
}))
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);
@@ -1076,6 +1146,53 @@ async function start() {
}
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);
}
});
@@ -1091,7 +1208,8 @@ module.exports = {
start: start,
resolveWebBaseUrl: resolveWebBaseUrl,
resolveScreenCommandTargets: resolveScreenCommandTargets,
resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl
resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl,
resolvePlayerSocketForDeviceId: resolvePlayerSocketForDeviceId
};
if (require.main === module) {
+208 -37
View File
@@ -30,8 +30,12 @@ async function start() {
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,
@@ -67,6 +71,35 @@ async function start() {
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;
@@ -76,7 +109,7 @@ async function start() {
console.info('[player] startup', {
mode: isRemotePlayer ? 'bridge client' : 'local',
connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false,
publicBaseUrl: PLAYER_PUBLIC_URL || null,
publicBaseUrl: getPlayerPublicBaseUrl(),
bridgeBaseUrl: PLAYER_INTERNAL_URL || null,
bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null
});
@@ -104,25 +137,96 @@ async function start() {
return false;
}
const requestBody = {
playerIdentifier: PLAYER_DEVICE_ID,
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL
};
try {
console.info('[player] Triggering startup media sync', {
playerIdentifier: PLAYER_DEVICE_ID,
bridgeBaseUrl: BRIDGE_PUBLIC_URL
});
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-media'
pathname: '/api/internal/sync/player-media',
body: requestBody
});
const response = await fetch(`${BRIDGE_PUBLIC_URL}/api/internal/sync/player-media`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json'
}, authHeaders)
Accept: 'application/json',
'Content-Type': 'application/json'
}, authHeaders),
body: JSON.stringify(requestBody)
});
console.info('[player] Startup media sync response', {
ok: Boolean(response && response.ok),
status: response && response.status ? response.status : null
});
return Boolean(response && response.ok);
} catch (_error) {
console.warn('[player] Startup media sync failed');
return false;
}
}
async function triggerWebFontSync() {
if (!isRemotePlayer || !BRIDGE_PUBLIC_URL) {
return false;
}
const requestBody = {
playerIdentifier: PLAYER_DEVICE_ID,
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL
};
try {
console.info('[player] Triggering startup font sync', {
playerIdentifier: PLAYER_DEVICE_ID,
bridgeBaseUrl: BRIDGE_PUBLIC_URL
});
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-font',
body: requestBody
});
const response = await fetch(`${BRIDGE_PUBLIC_URL}/api/internal/sync/player-font`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json',
'Content-Type': 'application/json'
}, authHeaders),
body: JSON.stringify(requestBody)
});
console.info('[player] Startup font sync response', {
ok: Boolean(response && response.ok),
status: response && response.status ? response.status : null
});
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;
@@ -153,9 +257,18 @@ async function start() {
response.error = 'Invalid media payload.';
} else {
const bodyBuffer = Buffer.from(bodyBase64, 'base64');
console.info('[player] Writing media file', {
relativePath: relativePath,
filePath: filePath,
bytes: bodyBuffer.length
});
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, bodyBuffer);
response.ok = true;
console.info('[player] Media file written', {
relativePath: relativePath,
filePath: filePath
});
}
} else if (command === 'media-delete') {
const relativePath = String(payload.relativePath || payload.filename || '').trim();
@@ -163,6 +276,10 @@ async function start() {
if (!filePath) {
response.error = 'Invalid media path.';
} else {
console.info('[player] Removing media file', {
relativePath: relativePath,
filePath: filePath
});
try {
await fs.promises.unlink(filePath);
} catch (error) {
@@ -171,6 +288,10 @@ async function start() {
}
}
response.ok = true;
console.info('[player] Media file removed', {
relativePath: relativePath,
filePath: filePath
});
}
} else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) !== -1) {
const screenSlug = String(payload.screenSlug || payload.slug || '').trim();
@@ -212,7 +333,7 @@ async function start() {
common: common,
playerRuntime: playerRuntime,
onboardingStore: onboardingStore,
playerPublicBaseUrl: PLAYER_PUBLIC_URL,
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
playerDeviceId: PLAYER_DEVICE_ID
@@ -225,10 +346,10 @@ async function start() {
playerRuntime: playerRuntime,
playerPlaylistService: playerPlaylistService,
rtmpStreamService: rtmpStreamService,
playerPublicBaseUrl: PLAYER_PUBLIC_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
playerDeviceId: PLAYER_DEVICE_ID
playerDeviceId: PLAYER_DEVICE_ID,
onPlayerPublicBaseUrl: setPlayerPublicBaseUrl
});
function createThinClientWebSocketUrl() {
@@ -276,6 +397,54 @@ async function start() {
});
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;
@@ -300,7 +469,7 @@ async function start() {
socket.send(JSON.stringify({
type: 'register',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_URL,
publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_URL
}));
@@ -308,38 +477,35 @@ async function start() {
sendSnapshot(slug);
});
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_URL,
internalBaseUrl: PLAYER_INTERNAL_URL
}));
if (!webMediaSyncTriggered && !webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () {
webMediaSyncTriggered = false;
});
}
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') {
console.info('[player] Bridge registration acknowledged', {
playerIdentifier: PLAYER_DEVICE_ID
});
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({
@@ -355,8 +521,13 @@ async function start() {
});
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);
});
@@ -403,7 +574,7 @@ async function start() {
try {
await recordPlayerHeartbeat(pool, {
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_URL,
publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_URL
}).catch(function (error) {
console.error(error);
@@ -432,7 +603,7 @@ async function start() {
if (PLAYER_DEVICE_ID && !isRemotePlayer) {
const { upsertPlayerRegistration } = require('./player/onboarding');
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_URL, PLAYER_INTERNAL_URL).catch(function (error) {
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) {
console.error(error);
});
}
+5 -4
View File
@@ -16,15 +16,16 @@ function normalizeDeviceId(value) {
}
function getPublicBaseUrl(req, configuredUrl) {
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
if (configured) {
return configured;
}
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0];
const host = forwardedHost || String(req.headers.host || '').trim();
if (host) {
return `${protocol}://${host}`.replace(/\/$/, '');
}
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
return configured || null;
}
function getRequestIp(req) {
+9 -1
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const express = require('express');
const path = require('path');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { getPlayerPublicBaseUrl } = require('./onboarding');
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
@@ -31,10 +32,10 @@ function registerPlayerRoutes(app, options) {
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
const onPlayerPublicBaseUrl = typeof options.onPlayerPublicBaseUrl === 'function' ? options.onPlayerPublicBaseUrl : null;
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.');
@@ -296,6 +297,13 @@ function registerPlayerRoutes(app, options) {
});
app.get('/screen/:slug', function (req, res) {
if (onPlayerPublicBaseUrl) {
try {
onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null));
} catch (_error) {
}
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
if (bridgeBaseUrl) {
+7 -1
View File
@@ -52,7 +52,13 @@ function registerStartupTasks(options) {
}
async function initialize() {
await loadTaskModules(backgroundTaskDirectory, options);
const startupTaskFile = path.join(backgroundTaskDirectory, 'data-source-refresh.js');
const taskModule = require(startupTaskFile);
const exported = getTaskExport(taskModule);
if (typeof exported === 'function') {
await exported(options);
}
}
return {
@@ -9,6 +9,7 @@ function registerFontSyncTask(options) {
backgroundTaskQueue.setTaskHandler('font-sync', async function (task) {
const payload = task && task.payload ? task.payload : task || {};
const uploadDir = String(payload.uploadDir || '').trim();
const playerIdentifier = String(payload.playerIdentifier || payload.deviceId || '').trim();
const operations = Array.isArray(payload.operations)
? payload.operations
: Array.isArray(payload.uploadPaths)
@@ -28,9 +29,9 @@ function registerFontSyncTask(options) {
continue;
}
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir);
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
} else {
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir);
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
}
}
});
@@ -1,16 +1,40 @@
const { collectFontLibrarySyncOperations } = require('../../media/font-library');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const TASK = {
key: 'initial-font-sync',
category: 'fonts'
};
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
function normalizePlayerMetadata(player) {
const playerIdentifier = String(player && player.identifier || '').trim();
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim();
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim();
return {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null,
playerActive: true
};
}
function registerInitialFontSyncTask(options) {
const pool = options && options.pool;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) {
if (!pool || !backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
}
@@ -18,20 +42,45 @@ function registerInitialFontSyncTask(options) {
? uploadSyncService.getPlayerTaskMetadata()
: Promise.resolve({});
return Promise.resolve(metadataPromise).then(function (metadata) {
return Promise.resolve(metadataPromise).then(async function () {
let players = [];
try {
players = await fetchPlayerRegistrations(pool);
} catch (error) {
console.warn('Unable to fetch player registrations for initial font sync:', error);
return null;
}
const livePlayers = Array.isArray(players)
? players.filter(function (player) {
return isRecentPlayerRegistration(player, 60);
})
: [];
if (!livePlayers.length) {
return null;
}
const operations = collectFontLibrarySyncOperations(mediaDir);
return Promise.all(livePlayers.map(function (player) {
const metadata = normalizePlayerMetadata(player);
return backgroundTaskQueue.enqueueTask({
key: TASK.key,
title: 'Initial font sync',
category: TASK.category,
taskType: 'font-sync',
metadata: Object.assign({}, metadata || {}),
metadata: metadata,
payload: {
mode: 'initial',
uploadDir: mediaDir,
operations: collectFontLibrarySyncOperations(mediaDir)
operations: operations,
playerIdentifier: metadata.playerIdentifier,
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
playerInternalBaseUrl: metadata.playerInternalBaseUrl
},
persist: true
});
}));
}).catch(function (error) {
console.warn('Unable to queue initial font sync:', error);
});
@@ -3,12 +3,37 @@ const TASK = {
category: 'media-sync',
};
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
function normalizePlayerMetadata(player) {
const playerIdentifier = String(player && player.identifier || '').trim();
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim();
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim();
return {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null,
playerActive: true
};
}
function registerInitialMediaSyncTask(options) {
const pool = options && options.pool;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) {
if (!pool || !backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
}
@@ -16,19 +41,43 @@ function registerInitialMediaSyncTask(options) {
? uploadSyncService.getPlayerTaskMetadata()
: Promise.resolve({});
return Promise.resolve(metadataPromise).then(function (metadata) {
return Promise.resolve(metadataPromise).then(async function () {
let players = [];
try {
players = await fetchPlayerRegistrations(pool);
} catch (error) {
console.warn('Unable to fetch player registrations for initial media sync:', error);
return null;
}
const livePlayers = Array.isArray(players)
? players.filter(function (player) {
return isRecentPlayerRegistration(player, 60);
})
: [];
if (!livePlayers.length) {
return null;
}
return Promise.all(livePlayers.map(function (player) {
const metadata = normalizePlayerMetadata(player);
return backgroundTaskQueue.enqueueTask({
key: TASK.key,
title: 'Initial media sync',
category: TASK.category,
taskType: 'media-sync',
metadata: Object.assign({}, metadata || {}),
metadata: metadata,
payload: {
mode: 'initial',
uploadDir: mediaDir
uploadDir: mediaDir,
playerIdentifier: metadata.playerIdentifier,
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
playerInternalBaseUrl: metadata.playerInternalBaseUrl
},
persist: true
});
}));
}).catch(function (error) {
console.warn('Unable to queue initial media sync:', error);
});
+73 -15
View File
@@ -36,6 +36,22 @@ function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function appendPlayerDeviceIdToUrl(baseUrl, playerIdentifier) {
const targetBaseUrl = normalizeBaseUrl(baseUrl);
const deviceId = String(playerIdentifier || '').trim();
if (!targetBaseUrl || !deviceId) {
return targetBaseUrl;
}
try {
const url = new URL(targetBaseUrl);
url.searchParams.set('deviceId', deviceId);
return url.toString().replace(/\/$/, '');
} catch (_error) {
return targetBaseUrl;
}
}
function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url);
}
@@ -74,8 +90,8 @@ function createUploadSyncService(options) {
throw new Error('createUploadSyncService requires the upload dependencies.');
}
async function getPlayerInternalBaseUrl() {
const metadata = await getPlayerTaskMetadata();
async function getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
const metadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl);
if (!metadata || metadata.playerActive === false) {
return null;
}
@@ -83,10 +99,43 @@ function createUploadSyncService(options) {
return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
}
async function getPlayerTaskMetadata() {
if (playerTaskMetadata) {
async function getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
const normalizedPreferredPlayerIdentifier = String(preferredPlayerIdentifier || '').trim();
const normalizedPreferredPlayerInternalBaseUrl = String(preferredPlayerInternalBaseUrl || '').trim().replace(/\/$/, '');
if (normalizedPreferredPlayerIdentifier && pool && typeof fetchPlayerRegistrations === 'function') {
try {
const players = await fetchPlayerRegistrations(pool);
const registeredPlayers = Array.isArray(players) ? players : [];
const exactPlayer = registeredPlayers.find(function (player) {
return String(player && player.identifier || '').trim() === normalizedPreferredPlayerIdentifier;
}) || null;
if (exactPlayer) {
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(exactPlayer) || normalizedPreferredPlayerInternalBaseUrl || null;
const resolvedPublicBaseUrl = normalizeBaseUrl(exactPlayer && exactPlayer.public_base_url);
const resolvedIdentifier = String(exactPlayer && exactPlayer.identifier || '').trim();
playerInternalBaseUrl = resolvedInternalBaseUrl || null;
playerTaskMetadata = {
playerIdentifier: resolvedIdentifier || normalizedPreferredPlayerIdentifier || null,
playerPublicBaseUrl: resolvedPublicBaseUrl || null,
playerInternalBaseUrl: resolvedInternalBaseUrl || null,
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null,
playerActive: true
};
return playerTaskMetadata;
}
} catch (_error) {
}
}
if (playerTaskMetadata && playerTaskMetadata.playerActive !== false) {
return playerTaskMetadata;
}
if (playerTaskMetadata && playerTaskMetadata.playerActive === false) {
playerTaskMetadata = null;
playerInternalBaseUrl = null;
}
if (playerTaskMetadataPromise) {
return playerTaskMetadataPromise;
@@ -421,7 +470,8 @@ function createUploadSyncService(options) {
pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), {
type: operation.type === 'delete' ? 'delete' : 'put',
uploadPath: normalizeUploadReference(operation.uploadPath),
uploadDir: operation.uploadDir || null
uploadDir: operation.uploadDir || null,
metadata: operation.metadata || null
});
schedulePendingPlayerUploadSyncFlush();
@@ -443,7 +493,7 @@ function createUploadSyncService(options) {
}
}
async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
return false;
}
@@ -453,6 +503,7 @@ function createUploadSyncService(options) {
return false;
}
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const relativePath = getUploadRelativePath(uploadPath);
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
if (!relativePath || !sourcePath) {
@@ -474,7 +525,8 @@ function createUploadSyncService(options) {
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
body: fileBuffer
});
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
const mediaUploadUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
const response = await fetch(mediaUploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
@@ -497,7 +549,7 @@ function createUploadSyncService(options) {
}
}
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
return false;
}
@@ -507,6 +559,7 @@ function createUploadSyncService(options) {
return false;
}
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const relativePath = getUploadRelativePath(uploadPath);
if (!relativePath) {
return false;
@@ -516,7 +569,8 @@ function createUploadSyncService(options) {
method: 'DELETE',
pathname: `/api/media/${encodeURIComponent(relativePath)}`
});
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
const mediaDeleteUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
const response = await fetch(mediaDeleteUrl, {
method: 'DELETE',
headers: {
Accept: 'application/json',
@@ -744,16 +798,16 @@ function createUploadSyncService(options) {
}
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
? playerMetadata.playerInternalBaseUrl
: await getPlayerInternalBaseUrl();
: await getPlayerInternalBaseUrl(playerMetadata && playerMetadata.playerIdentifier, playerMetadata && playerMetadata.playerInternalBaseUrl);
let successCount = 0;
let failureCount = 0;
for (let i = 0; i < pendingEntries.length; i += 1) {
const operation = pendingEntries[i];
let success = false;
if (operation.type === 'delete') {
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier);
} else {
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier);
}
if (success) {
successCount += 1;
@@ -795,6 +849,8 @@ function createUploadSyncService(options) {
return;
}
const playerMetadata = await getPlayerTaskMetadata(taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
const data = await common.fetchAdminData(pool);
const uploadRefs = new Set();
(data.slides || []).forEach(function (slide) {
@@ -812,7 +868,8 @@ function createUploadSyncService(options) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uploadPath,
uploadDir: uploadDir
uploadDir: uploadDir,
metadata: playerMetadata
});
});
fontLibraryOperations.forEach(function (operation) {
@@ -823,7 +880,8 @@ function createUploadSyncService(options) {
queuePlayerUploadSync({
type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put',
uploadPath: operation.uploadPath,
uploadDir: uploadDir
uploadDir: uploadDir,
metadata: playerMetadata
});
});
await flushPendingPlayerUploadSyncs();
@@ -870,7 +928,7 @@ function createUploadSyncService(options) {
safePayload.operation = Object.assign({}, safePayload.operation);
delete safePayload.operation.pool;
}
const playerMetadata = await getPlayerTaskMetadata();
const playerMetadata = await getPlayerTaskMetadata(safePayload.playerIdentifier, safePayload.playerInternalBaseUrl);
const definition = {
key: taskKey,
+1 -1
View File
@@ -29,7 +29,7 @@ module.exports = function registerMiddleware(app, deps) {
});
app.use(function (req, res, next) {
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media') {
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font') {
return next();
}
+62 -5
View File
@@ -1,6 +1,7 @@
// Internal sync trigger routes for player-driven queue flushes.
const { verifyRequestAuth } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
function requireRequestAuth(req, res, next) {
if (!verifyRequestAuth(req)) {
@@ -11,21 +12,77 @@ function requireRequestAuth(req, res, next) {
}
module.exports = function registerInternalSyncRoutes(app, deps) {
const backgroundTaskQueue = deps && deps.backgroundTaskQueue;
const uploadSyncService = deps && deps.uploadSyncService;
const mediaDir = String(deps && deps.mediaDir || '').trim();
if (!uploadSyncService || typeof uploadSyncService.flushPendingPlayerUploadSyncs !== 'function' || typeof uploadSyncService.runMediaSyncTask !== 'function' || !mediaDir) {
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function' || !uploadSyncService || !mediaDir) {
throw new Error('registerInternalSyncRoutes requires the sync dependencies.');
}
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
try {
await uploadSyncService.runMediaSyncTask({
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
? _req.body
: {};
const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim();
const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim();
const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim();
const task = await backgroundTaskQueue.enqueueTask({
key: 'player-media-sync',
title: 'Player media sync',
category: 'media-sync',
taskType: 'media-sync',
metadata: {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null
},
payload: {
mode: 'initial',
uploadDir: mediaDir
uploadDir: mediaDir,
playerIdentifier: playerIdentifier,
playerPublicBaseUrl: playerPublicBaseUrl,
playerInternalBaseUrl: playerInternalBaseUrl
}
});
await uploadSyncService.flushPendingPlayerUploadSyncs();
res.json({ ok: true });
res.status(202).json({ ok: true, queued: true, task: task });
} catch (error) {
next(error);
}
});
app.post('/api/internal/sync/player-font', requireRequestAuth, async function (_req, res, next) {
try {
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
? _req.body
: {};
const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim();
const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim();
const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim();
const operations = collectFontLibrarySyncOperations(mediaDir);
const task = await backgroundTaskQueue.enqueueTask({
key: 'player-font-sync',
title: 'Player font sync',
category: 'fonts',
taskType: 'font-sync',
metadata: {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null
},
payload: {
mode: 'initial',
uploadDir: mediaDir,
operations: operations,
playerIdentifier: playerIdentifier,
playerPublicBaseUrl: playerPublicBaseUrl,
playerInternalBaseUrl: playerInternalBaseUrl
}
});
res.status(202).json({ ok: true, queued: true, task: task });
} catch (error) {
next(error);
}
+1
View File
@@ -25,6 +25,7 @@ function registerRoutes(app, deps) {
registerSignageRoutes(app, deps);
registerSettingsAndContentRoutes(app, deps);
registerInternalSyncRoutes(app, {
backgroundTaskQueue: deps.backgroundTaskQueue,
uploadSyncService: deps.uploadSyncService,
mediaDir: deps.mediaDir
});
+62
View File
@@ -0,0 +1,62 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { registerInitialFontSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/font-sync');
test('initial font sync queues a separate task for each live player', async () => {
const queuedTasks = [];
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const staleLastSeenAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
await registerInitialFontSyncTask({
pool: {
async query() {
return [[
{
identifier: 'player-one',
public_base_url: 'https://player-one.example',
internal_base_url: 'http://player-one:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-two',
public_base_url: 'https://player-two.example',
internal_base_url: 'http://player-two:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-stale',
public_base_url: 'https://player-stale.example',
internal_base_url: 'http://player-stale:8081',
last_seen_at: staleLastSeenAt
}
]];
}
},
backgroundTaskQueue: {
async enqueueTask(definition) {
queuedTasks.push(definition);
return definition;
}
},
mediaDir: 'e:/Projects Git/pulse-signage/media',
uploadSyncService: {
async getPlayerTaskMetadata() {
return {};
}
}
});
assert.equal(queuedTasks.length, 2);
assert.deepEqual(queuedTasks.map(function (task) {
return task.metadata.playerIdentifier;
}).sort(), ['player-one', 'player-two']);
assert.deepEqual(queuedTasks.map(function (task) {
return task.payload.playerIdentifier;
}).sort(), ['player-one', 'player-two']);
assert.ok(queuedTasks.every(function (task) {
return task.key === 'initial-font-sync' && task.title === 'Initial font sync' && task.taskType === 'font-sync';
}));
});
+62
View File
@@ -0,0 +1,62 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { registerInitialMediaSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/media-sync');
test('initial media sync queues a separate task for each live player', async () => {
const queuedTasks = [];
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const staleLastSeenAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
await registerInitialMediaSyncTask({
pool: {
async query() {
return [[
{
identifier: 'player-one',
public_base_url: 'https://player-one.example',
internal_base_url: 'http://player-one:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-two',
public_base_url: 'https://player-two.example',
internal_base_url: 'http://player-two:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-stale',
public_base_url: 'https://player-stale.example',
internal_base_url: 'http://player-stale:8081',
last_seen_at: staleLastSeenAt
}
]];
}
},
backgroundTaskQueue: {
async enqueueTask(definition) {
queuedTasks.push(definition);
return definition;
}
},
mediaDir: 'e:/Projects Git/pulse-signage/media',
uploadSyncService: {
async getPlayerTaskMetadata() {
return {};
}
}
});
assert.equal(queuedTasks.length, 2);
assert.deepEqual(queuedTasks.map(function (task) {
return task.metadata.playerIdentifier;
}).sort(), ['player-one', 'player-two']);
assert.deepEqual(queuedTasks.map(function (task) {
return task.payload.playerIdentifier;
}).sort(), ['player-one', 'player-two']);
assert.ok(queuedTasks.every(function (task) {
return task.key === 'initial-media-sync' && task.title === 'Initial media sync' && task.taskType === 'media-sync';
}));
});
+158
View File
@@ -0,0 +1,158 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const express = require('express');
require('../src/common');
const { createRequestAuthHeaders } = require('../src/request-auth');
const registerInternalSyncRoutes = require('../src/web/routes/internal/sync');
test('player media sync requests are queued as background tasks', async () => {
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'test-shared-secret';
const queuedTasks = [];
const app = express();
app.use(express.json());
registerInternalSyncRoutes(app, {
backgroundTaskQueue: {
async enqueueTask(definition) {
queuedTasks.push(definition);
return {
id: 42,
key: definition.key,
taskType: definition.taskType,
title: definition.title,
category: definition.category,
status: 'queued',
createdAt: new Date().toISOString(),
startedAt: '',
finishedAt: '',
errorMessage: '',
attempts: 0,
metadata: definition.metadata,
payload: definition.payload
};
}
},
uploadSyncService: {},
mediaDir: 'e:/Projects Git/pulse-signage/media'
});
app.use(function (error, _req, res, _next) {
res.status(error.statusCode || 500).json({ error: String(error && error.message ? error.message : error) });
});
const server = app.listen(0);
try {
const address = server.address();
const body = {
deviceId: 'player-remote',
playerPublicBaseUrl: 'https://player.example.test',
playerInternalBaseUrl: 'http://player-bridge:8090'
};
const headers = Object.assign({
'content-type': 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-media',
body: body
}));
const response = await fetch(`http://127.0.0.1:${address.port}/api/internal/sync/player-media`, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
});
assert.equal(response.status, 202);
assert.equal(queuedTasks.length, 1);
assert.equal(queuedTasks[0].key, 'player-media-sync');
assert.equal(queuedTasks[0].taskType, 'media-sync');
assert.equal(queuedTasks[0].payload.mode, 'initial');
assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote');
assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote');
} finally {
await new Promise(function (resolve) {
server.close(resolve);
});
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
}
});
test('player font sync requests are queued as background tasks', async () => {
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'test-shared-secret';
const queuedTasks = [];
const app = express();
app.use(express.json());
registerInternalSyncRoutes(app, {
backgroundTaskQueue: {
async enqueueTask(definition) {
queuedTasks.push(definition);
return {
id: 43,
key: definition.key,
taskType: definition.taskType,
title: definition.title,
category: definition.category,
status: 'queued',
createdAt: new Date().toISOString(),
startedAt: '',
finishedAt: '',
errorMessage: '',
attempts: 0,
metadata: definition.metadata,
payload: definition.payload
};
}
},
uploadSyncService: {},
mediaDir: 'e:/Projects Git/pulse-signage/media'
});
app.use(function (error, _req, res, _next) {
res.status(error.statusCode || 500).json({ error: String(error && error.message ? error.message : error) });
});
const server = app.listen(0);
try {
const address = server.address();
const body = {
deviceId: 'player-remote',
playerPublicBaseUrl: 'https://player.example.test',
playerInternalBaseUrl: 'http://player-bridge:8090'
};
const headers = Object.assign({
'content-type': 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-font',
body: body
}));
const response = await fetch(`http://127.0.0.1:${address.port}/api/internal/sync/player-font`, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
});
assert.equal(response.status, 202);
assert.equal(queuedTasks.length, 1);
assert.equal(queuedTasks[0].key, 'player-font-sync');
assert.equal(queuedTasks[0].taskType, 'font-sync');
assert.equal(queuedTasks[0].payload.mode, 'initial');
assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote');
assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote');
} finally {
await new Promise(function (resolve) {
server.close(resolve);
});
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
}
});
+13 -1
View File
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
require('../src/common');
const originalWebBaseUrl = process.env.WEB_INTERNAL_URL;
const { resolveWebBaseUrl, resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl } = require('../src/player-bridge/index');
const { resolveWebBaseUrl, resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl, resolvePlayerSocketForDeviceId } = require('../src/player-bridge/index');
test.after(() => {
if (originalWebBaseUrl === undefined) {
@@ -88,3 +88,15 @@ test('resolveSnapshotUpstreamBaseUrl falls back to the public player url for rem
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
}), 'https://remote-player.example');
});
test('resolvePlayerSocketForDeviceId only returns the exact connected player socket', () => {
const playerSocketA = { readyState: 1 };
const playerSocketB = { readyState: 1 };
const playerSockets = new Map([
['player-a', playerSocketA],
['player-b', playerSocketB]
]);
assert.equal(resolvePlayerSocketForDeviceId(playerSockets, 'player-b'), playerSocketB);
assert.equal(resolvePlayerSocketForDeviceId(playerSockets, 'player-missing'), null);
});
+40
View File
@@ -97,6 +97,46 @@ function createPlayerRouteOptions(overrides) {
}, overrides);
}
test('screen route reports the request origin for the player public base url', async () => {
const { app, handlers } = createAppAndHandlers();
let reportedBaseUrl = null;
registerPlayerRoutes(app, {
app,
pool: { async query() { return [[{ id: 1 }]]; } },
common: { renderPlayerPage() { return '<html></html>'; } },
...createPlayerRouteOptions({
playerPlaylistService: {
async buildScreenPlaylist() {
return {
screen: { id: 7, slug: 'test2' },
playlist: null,
slides: [],
rssFeeds: [],
apiSources: [],
timetableGroups: [],
revision: 'abc123'
};
}
},
onPlayerPublicBaseUrl(value) {
reportedBaseUrl = value;
}
})
});
const handler = handlers['/screen/:slug'];
const res = createResponse();
await handler({
params: { slug: 'test2' },
headers: { host: '127.0.0.1:8080' },
socket: { encrypted: false }
}, res);
assert.equal(reportedBaseUrl, 'http://127.0.0.1:8080');
});
test('screen route falls back to offline rendering when playlist build fails', async () => {
const { app, handlers } = createAppAndHandlers();
const renderCalls = [];
+1 -1
View File
@@ -99,7 +99,7 @@ test('fqdn player registration wins over a local configured player target for me
assert.equal(success, true);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin');
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin?deviceId=player-remote');
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
+44
View File
@@ -0,0 +1,44 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const registerMiddleware = require('../src/web/middleware');
test('web middleware bypasses auth for internal player sync routes', () => {
const calls = [];
const app = {
middlewares: [],
use(...args) {
this.middlewares.push(args);
}
};
registerMiddleware(app, {
pool: {},
loadCurrentUser: async () => null,
requireAuth(req, _res, next) {
calls.push(req.path);
next();
},
MEDIA_DIR: path.join(process.cwd(), 'media'),
UPLOADS_DIR: path.join(process.cwd(), 'media', 'uploads-test'),
THUMBNAILS_DIR: path.join(process.cwd(), 'media', 'thumbnails-test'),
ASSET_DIR: path.join(process.cwd(), 'src', 'web', 'public')
});
const authGate = app.middlewares[app.middlewares.length - 1][0];
const nextCalls = [];
authGate({ path: '/api/internal/sync/player-media' }, {}, function () {
nextCalls.push('media');
});
authGate({ path: '/api/internal/sync/player-font' }, {}, function () {
nextCalls.push('font');
});
authGate({ path: '/dashboard' }, {}, function () {
nextCalls.push('dashboard');
});
assert.deepEqual(nextCalls, ['media', 'font', 'dashboard']);
assert.deepEqual(calls, ['/dashboard']);
});