From 7e46fffc347092527f56f4c143217c9d5f48651c Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sat, 8 Aug 2026 13:02:31 +0100 Subject: [PATCH] Release 2.6.7 --- CHANGELOG.md | 7 + docker-compose/.env.example | 10 +- docker-compose/.env.remote.example | 4 +- docker-compose/README.md | 35 +- docker-compose/docker-compose.remote.yml | 4 +- docker-compose/docker-compose.yml | 10 +- package.json | 2 +- src/player-bridge/index.js | 296 +++++++-- src/player.js | 90 ++- src/player/onboarding/index.js | 34 +- .../player-onboarding-form.script.html | 14 +- .../player-onboarding-landing.script.html | 22 +- src/player/player-client-name.script.html | 40 +- src/player/public/js/player-page-playlist.js | 6 +- src/player/routes.js | 36 +- src/player/runtime.js | 18 +- src/web.js | 9 +- src/web/bootstrap.js | 12 +- .../tasks-scheduled/font-sweep.js | 71 +-- .../onboarding-device-prune.js | 32 + src/web/lib/config.js | 12 +- src/web/lib/media/upload-sync.js | 13 + src/web/lib/player-actions.js | 185 ++++-- src/web/public/js/dashboard/dashboard-page.js | 577 ++++++++++------- src/web/public/js/web-ui-helpers.js | 7 +- src/web/routes/admin/client-commands.js | 321 ++++++---- src/web/routes/admin/manage.js | 152 ++++- src/web/routes/register.js | 2 + src/web/views/signage/clients/list.hbs | 8 +- test/admin-client-commands.test.js | 180 +++++- test/background-tasks-font-sweep.test.js | 40 ++ .../background-tasks-onboarding-prune.test.js | 33 + test/client-commands-route.test.js | 197 ++++++ test/player-actions.test.js | 130 ++-- test/player-bridge-web-base-url.test.js | 41 +- test/player-onboarding-bridge.test.js | 2 +- test/player-preload-helpers.test.js | 200 ++++++ test/player-runtime.test.js | 11 +- test/web-dashboard-page.test.js | 601 +++--------------- test/web-manage-routes.test.js | 282 +++++++- test/web-ui-helpers.test.js | 33 + 41 files changed, 2489 insertions(+), 1290 deletions(-) create mode 100644 src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune.js create mode 100644 test/background-tasks-font-sweep.test.js create mode 100644 test/background-tasks-onboarding-prune.test.js create mode 100644 test/client-commands-route.test.js create mode 100644 test/web-ui-helpers.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0683f1c..a691227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## 2.6.7 - 2026-08-08 + +### Fixed + +- Admin client commands now stay on the bridge for remote players, so screen control no longer depends on a public player address. +- The connected-clients screen-group controls now use the same async bulk-command path as the dashboard, including the All Screens option and live pause/blackout toggles. + ## 2.6.6 - 2026-08-07 ### Fixed diff --git a/docker-compose/.env.example b/docker-compose/.env.example index 1fbc4db..bbc954d 100644 --- a/docker-compose/.env.example +++ b/docker-compose/.env.example @@ -12,15 +12,15 @@ MYSQL_ROOT_PASSWORD="root_password" # Player settings PLAYER_IDENTIFIER="player-local" -PLAYER_PUBLIC_BASE_URL="http://localhost:8081" -PLAYER_INTERNAL_BASE_URL="http://player:8081" +PLAYER_PUBLIC_URL="http://localhost:8081" +PLAYER_INTERNAL_URL="http://player:8081" # Web app bootstrap settings SESSION_MAX_AGE_DAYS=14 DEFAULT_ADMIN_USERNAME="admin" DEFAULT_ADMIN_NAME="Admin" -DEFAULT_ADMIN_PASSWORD="admin" -PASSWORD_HASH_ITERATIONS=310000 +DEFAULT_ADMIN_PASSWORD="password123" # Bridge settings for the player-bridge service -WEB_BASE_URL="http://web:8080" \ No newline at end of file +WEB_INTERNAL_URL="http://web:8080" +BRIDGE_INTERNAL_URL="http://player-bridge:8090" \ No newline at end of file diff --git a/docker-compose/.env.remote.example b/docker-compose/.env.remote.example index b68f036..afcab8b 100644 --- a/docker-compose/.env.remote.example +++ b/docker-compose/.env.remote.example @@ -4,8 +4,8 @@ PULSE_SIGNAGE_SHARED_SECRET="" # Player settings PLAYER_IDENTIFIER="player-remote" -PLAYER_PUBLIC_BASE_URL="http://localhost:8081" +PLAYER_PUBLIC_URL="http://localhost:8081" PLAYER_AGENT_RECONNECT_DELAY_MS=5000 # Remote player connectivity settings -THIN_CLIENT_BASE_URL="http://player-agent:8090" \ No newline at end of file +BRIDGE_PUBLIC_URL="http://player-bridge.example.com:8090" \ No newline at end of file diff --git a/docker-compose/README.md b/docker-compose/README.md index 9a68ef3..81b17fe 100644 --- a/docker-compose/README.md +++ b/docker-compose/README.md @@ -59,15 +59,16 @@ Responsibilities: - serves the player UI on port `8081` - connects to MySQL in local mode -- connects to the bridge in remote mode through `THIN_CLIENT_BASE_URL` +- connects to the bridge in remote mode through `BRIDGE_PUBLIC_URL` - registers live connections and accepts control commands Key configuration: -- `PLAYER_PUBLIC_BASE_URL` -- `PLAYER_INTERNAL_BASE_URL` +- `PLAYER_PUBLIC_URL` +- `PLAYER_INTERNAL_URL` +- `BRIDGE_INTERNAL_URL` - `PLAYER_IDENTIFIER` -- `THIN_CLIENT_BASE_URL` in remote mode +- `BRIDGE_PUBLIC_URL` in remote mode - `PULSE_SIGNAGE_SHARED_SECRET` - database settings in local mode @@ -85,7 +86,7 @@ Responsibilities: Key configuration: - `PULSE_SIGNAGE_SHARED_SECRET` -- `WEB_BASE_URL` for the bridge when it should call the web app directly instead of inferring from request headers +- `WEB_INTERNAL_URL` for the bridge when it should call the web app directly instead of inferring from request headers - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` ### `mysql` @@ -116,11 +117,14 @@ Important values: - `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 -- `PLAYER_PUBLIC_BASE_URL` - public URL the player advertises -- `PLAYER_INTERNAL_BASE_URL` - internal URL the web app uses for local player calls +- `PLAYER_PUBLIC_URL` - public URL the player advertises +- `PLAYER_INTERNAL_URL` - internal URL the web app uses for local player calls +- `BRIDGE_INTERNAL_URL` - bridge URL the web app uses for player snapshot and command forwarding +- `WEB_INTERNAL_URL` - internal URL the bridge uses to call the web app directly - `SESSION_MAX_AGE_DAYS` - dashboard session lifetime - `DEFAULT_ADMIN_*` - bootstrap admin account values - `PASSWORD_HASH_ITERATIONS` - password hashing cost +- `MYSQL_ROOT_PASSWORD` - root password for the local MySQL container ### `.env.remote.example` @@ -131,8 +135,8 @@ Important values: - `PULSE_SIGNAGE_IMAGE` - image to run on the device - `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_BASE_URL` - public URL for the remote player -- `THIN_CLIENT_BASE_URL` - bridge URL the player connects back to +- `PLAYER_PUBLIC_URL` - public URL for the remote player +- `BRIDGE_PUBLIC_URL` - bridge URL the player connects back to - `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent ### `PULSE_SIGNAGE_SHARED_SECRET` @@ -166,11 +170,16 @@ Leave it blank only if you intentionally want to run without request signing in | `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. | | `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. | | `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. | -| `PLAYER_INTERNAL_BASE_URL` | web, player | Internal player URL used by the dashboard and player runtime. | -| `THIN_CLIENT_BASE_URL` | web, player, remote player | URL of the bridge service. | -| `PLAYER_PUBLIC_BASE_URL` | player, remote player | Public URL advertised by the player. | +| `PLAYER_INTERNAL_URL` | web, player | Internal player URL used by the dashboard and player runtime. | +| `BRIDGE_INTERNAL_URL` | web | Bridge URL used by the web app for player snapshot and command forwarding. | +| `WEB_INTERNAL_URL` | player-bridge | Internal web URL used by the bridge to call the dashboard app directly. | +| `PLAYER_PUBLIC_URL` | player, remote player | Public URL advertised by the player. | +| `BRIDGE_PUBLIC_URL` | player, remote player | URL of the bridge service. | | `PLAYER_IDENTIFIER` | player | Stable player identifier. | | `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. | +| `MYSQL_DATABASE` | mysql | Database name used by the local MySQL container. | +| `MYSQL_USER` | mysql | Database user used by the local MySQL container. | +| `MYSQL_PASSWORD` | mysql | Database password used by the local MySQL container. | ## Ports @@ -208,7 +217,7 @@ Each compose file creates its own named network: - The public stack expects the app services and MySQL to share the same `PULSE_SIGNAGE_SHARED_SECRET`. - 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 `THIN_CLIENT_BASE_URL` at the bridge, not at the public web endpoint. +- 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. ## Recommended Setup diff --git a/docker-compose/docker-compose.remote.yml b/docker-compose/docker-compose.remote.yml index 2da1408..1b62cd8 100644 --- a/docker-compose/docker-compose.remote.yml +++ b/docker-compose/docker-compose.remote.yml @@ -10,8 +10,8 @@ services: ports: - "8081:8081" environment: - PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} - THIN_CLIENT_BASE_URL: ${THIN_CLIENT_BASE_URL:-} + PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081} + BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} volumes: - pulse-signage:/app/media diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 9c8a024..385d5b0 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -15,11 +15,11 @@ services: DB_USER: ${DB_USER:-pulse-signage} DB_PASSWORD: ${DB_PASSWORD:-signage_password} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} + BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090} SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14} DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin} DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin} - DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin} - PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000} + DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-password123} volumes: - pulse-signage:/app/media command: ["node", "src/web.js"] @@ -35,8 +35,8 @@ services: ports: - "8081:8081" environment: - PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} - PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081} + PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081} + PLAYER_INTERNAL_URL: ${PLAYER_INTERNAL_URL:-http://player:8081} PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} DB_HOST: ${DB_HOST:-mysql} @@ -59,7 +59,7 @@ services: ports: - "8090:8090" environment: - WEB_BASE_URL: ${WEB_BASE_URL:-http://web:8080} + WEB_INTERNAL_URL: ${WEB_INTERNAL_URL:-http://web:8080} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} DB_HOST: ${DB_HOST:-mysql} DB_PORT: ${DB_PORT:-3306} diff --git a/package.json b/package.json index 4556c1a..a02e80f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.6.6", + "version": "2.6.7", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js index 8c1ce9c..886d686 100644 --- a/src/player-bridge/index.js +++ b/src/player-bridge/index.js @@ -44,6 +44,55 @@ function formatPlayerConnectionLabel(deviceId, remoteAddress) { return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : 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 resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) { const key = String(slug || '').trim(); if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') { @@ -51,24 +100,38 @@ function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) } const deviceIds = screenPlayerDeviceIds.get(key); - if (!Array.isArray(deviceIds) || !deviceIds.length) { - return []; + 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; } - return Array.from(new Set(deviceIds.map(function (value) { - return normalizeDeviceId(value); - }).filter(Boolean))).map(function (deviceId) { - const socket = playerSockets.get(deviceId); - if (!socket || socket.readyState !== WebSocket.OPEN) { - return null; - } + 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 { deviceId: deviceId, socket: socket }; - }).filter(Boolean); + return fallbackTargets.length === 1 ? fallbackTargets : []; } function resolveWebBaseUrl(req) { - const configuredWebBaseUrl = String(process.env.WEB_BASE_URL || '').trim().replace(/\/$/, ''); + const configuredWebBaseUrl = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, ''); if (configuredWebBaseUrl) { return configuredWebBaseUrl; } @@ -118,24 +181,130 @@ async function start() { 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 normalizeProxyBaseUrl(value) { - const normalized = String(value || '').trim().replace(/\/$/, ''); - if (!normalized) { - return ''; + function getScreenSnapshotSourceBucket(slug) { + const key = String(slug || '').trim(); + if (!key) { + return null; } - 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; + 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() { @@ -641,6 +810,16 @@ async function start() { socket.playerDeviceId = deviceId; + if (messageType === 'snapshot') { + const slug = String(payload.slug || '').trim(); + if (!slug) { + return; + } + + setScreenSnapshotSource(slug, deviceId, Array.isArray(payload.connections) ? payload.connections : []); + return; + } + if (messageType === 'register') { const player = await upsertPlayerRegistration(pool, { deviceId: deviceId, @@ -719,12 +898,14 @@ async function start() { }); socket.on('close', function () { + clearPlayerSnapshotSources(socket.playerDeviceId); if (removeConnectedPlayerSocket(socket)) { logPlayerDisconnect(socket); } }); socket.on('error', function () { + clearPlayerSnapshotSources(socket.playerDeviceId); if (removeConnectedPlayerSocket(socket)) { logPlayerDisconnect(socket); } @@ -734,33 +915,18 @@ async function start() { screenSnapshotsWs.on('connection', function (socket, request, slug) { const normalizedSlug = String(slug || '').trim(); const upstreamSockets = new Map(); - const upstreamSnapshots = new Map(); const upstreamDeviceIds = new Set(); let refreshTimer = null; let closed = false; - function sendMergedSnapshot() { - if (!normalizedSlug || socket.readyState !== WebSocket.OPEN) { - return; - } - - const connections = []; - upstreamSnapshots.forEach(function (payload) { - if (payload && Array.isArray(payload.connections)) { - connections.push.apply(connections, payload.connections); - } - }); - - storeScreenSnapshot(normalizedSlug, connections, Array.from(upstreamDeviceIds)); - - socket.send(JSON.stringify({ - type: 'snapshot', - slug: normalizedSlug, - connections: connections, - sentAt: new Date().toISOString() - })); + const subscriberBucket = getScreenSnapshotSubscriberBucket(normalizedSlug); + if (!subscriberBucket) { + socket.close(); + return; } + subscriberBucket.add(socket); + function closeUpstreamSockets() { upstreamSockets.forEach(function (upstreamSocket) { try { @@ -769,7 +935,6 @@ async function start() { } }); upstreamSockets.clear(); - upstreamSnapshots.clear(); } async function refreshUpstreams() { @@ -786,12 +951,19 @@ async function start() { const seenKeys = new Set(); players.forEach(function (player) { - const baseUrl = normalizeProxyBaseUrl(player && player.public_base_url); + 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; } - const sourceKey = String(player && player.identifier || player && player.id || baseUrl); seenKeys.add(sourceKey); upstreamDeviceIds.add(sourceKey); if (upstreamSockets.has(sourceKey)) { @@ -814,20 +986,15 @@ async function start() { if (!payload || payload.type !== 'snapshot' || String(payload.slug || '').trim() !== normalizedSlug) { return; } - upstreamSnapshots.set(sourceKey, { - slug: normalizedSlug, - connections: Array.isArray(payload.connections) ? payload.connections : [] - }); - sendMergedSnapshot(); + setScreenSnapshotSource(normalizedSlug, sourceKey, Array.isArray(payload.connections) ? payload.connections : []); } catch (_error) { } }; upstreamSocket.onclose = function () { upstreamSockets.delete(sourceKey); - upstreamSnapshots.delete(sourceKey); - if (!closed) { - sendMergedSnapshot(); + if (!closed && !(playerSockets.get(sourceKey) && playerSockets.get(sourceKey).readyState === WebSocket.OPEN)) { + clearScreenSnapshotSource(normalizedSlug, sourceKey); } }; @@ -852,7 +1019,7 @@ async function start() { } }); - sendMergedSnapshot(); + broadcastScreenSnapshot(normalizedSlug); } refreshUpstreams(); @@ -863,6 +1030,10 @@ async function start() { socket.on('close', function () { closed = true; + subscriberBucket.delete(socket); + if (!subscriberBucket.size) { + screenSnapshotSubscribersBySlug.delete(normalizedSlug); + } if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; @@ -872,6 +1043,10 @@ async function start() { socket.on('error', function () { closed = true; + subscriberBucket.delete(socket); + if (!subscriberBucket.size) { + screenSnapshotSubscribersBySlug.delete(normalizedSlug); + } if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; @@ -915,7 +1090,12 @@ async function start() { }); } -module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets }; +module.exports = { + start: start, + resolveWebBaseUrl: resolveWebBaseUrl, + resolveScreenCommandTargets: resolveScreenCommandTargets, + resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl +}; if (require.main === module) { start().catch(function (error) { diff --git a/src/player.js b/src/player.js index 8a3c67d..1f12a9c 100644 --- a/src/player.js +++ b/src/player.js @@ -18,21 +18,37 @@ const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/d // Player runtime, media API, and websocket wiring. async function start() { const app = express(); - const pool = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '') ? null : common.createPool(); + const pool = String(process.env.BRIDGE_PUBLIC_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 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_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 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 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 onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE); + let thinClientSocket = null; const playerRuntime = createPlayerRuntime({ pool: pool, - normalizeDeviceId: normalizeDeviceId + 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 @@ -59,9 +75,9 @@ async function start() { 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 + publicBaseUrl: PLAYER_PUBLIC_URL || null, + bridgeBaseUrl: PLAYER_INTERNAL_URL || null, + bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null }); } @@ -83,7 +99,7 @@ async function start() { } async function triggerWebMediaSync() { - if (!isRemotePlayer || !THIN_CLIENT_BASE_URL) { + if (!isRemotePlayer || !BRIDGE_PUBLIC_URL) { return false; } @@ -92,7 +108,7 @@ async function start() { method: 'POST', pathname: '/api/internal/sync/player-media' }); - const response = await fetch(`${THIN_CLIENT_BASE_URL}/api/internal/sync/player-media`, { + const response = await fetch(`${BRIDGE_PUBLIC_URL}/api/internal/sync/player-media`, { method: 'POST', headers: Object.assign({ Accept: 'application/json' @@ -194,9 +210,9 @@ async function start() { common: common, playerRuntime: playerRuntime, onboardingStore: onboardingStore, - playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, - playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, - thinClientBaseUrl: THIN_CLIENT_BASE_URL, + playerPublicBaseUrl: PLAYER_PUBLIC_URL, + playerInternalBaseUrl: PLAYER_INTERNAL_URL, + bridgeBaseUrl: BRIDGE_PUBLIC_URL, playerDeviceId: PLAYER_DEVICE_ID }); registerPlayerRoutes(app, { @@ -207,18 +223,18 @@ async function start() { playerRuntime: playerRuntime, playerPlaylistService: playerPlaylistService, rtmpStreamService: rtmpStreamService, - playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, - playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, - thinClientBaseUrl: THIN_CLIENT_BASE_URL, + playerPublicBaseUrl: PLAYER_PUBLIC_URL, + playerInternalBaseUrl: PLAYER_INTERNAL_URL, + bridgeBaseUrl: BRIDGE_PUBLIC_URL, playerDeviceId: PLAYER_DEVICE_ID }); function createThinClientWebSocketUrl() { - if (!THIN_CLIENT_BASE_URL) { + if (!BRIDGE_PUBLIC_URL) { return null; } - return THIN_CLIENT_BASE_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players'; + return BRIDGE_PUBLIC_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players'; } function startThinClientRegistration() { @@ -256,6 +272,23 @@ async function start() { 'x-pulse-request-timestamp': timestamp }, authHeaders) }); + thinClientSocket = socket; + + 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({ @@ -265,10 +298,14 @@ async function start() { socket.send(JSON.stringify({ type: 'register', deviceId: PLAYER_DEVICE_ID, - publicBaseUrl: PLAYER_PUBLIC_BASE_URL, - internalBaseUrl: PLAYER_INTERNAL_BASE_URL + publicBaseUrl: PLAYER_PUBLIC_URL, + internalBaseUrl: PLAYER_INTERNAL_URL })); + playerRuntime.snapshotSlugs().forEach(function (slug) { + sendSnapshot(slug); + }); + if (!webMediaSyncCompleted) { triggerWebMediaSync().then(function (success) { webMediaSyncTriggered = Boolean(success); @@ -285,8 +322,8 @@ async function start() { socket.send(JSON.stringify({ type: 'heartbeat', deviceId: PLAYER_DEVICE_ID, - publicBaseUrl: PLAYER_PUBLIC_BASE_URL, - internalBaseUrl: PLAYER_INTERNAL_BASE_URL + publicBaseUrl: PLAYER_PUBLIC_URL, + internalBaseUrl: PLAYER_INTERNAL_URL })); if (!webMediaSyncTriggered && !webMediaSyncCompleted) { @@ -317,6 +354,7 @@ async function start() { socket.on('close', function () { clearTimers(); + thinClientSocket = null; reconnectTimer = setTimeout(connect, 5000); }); @@ -363,8 +401,8 @@ async function start() { try { await recordPlayerHeartbeat(pool, { deviceId: PLAYER_DEVICE_ID, - publicBaseUrl: PLAYER_PUBLIC_BASE_URL, - internalBaseUrl: PLAYER_INTERNAL_BASE_URL + publicBaseUrl: PLAYER_PUBLIC_URL, + internalBaseUrl: PLAYER_INTERNAL_URL }).catch(function (error) { console.error(error); }); @@ -392,7 +430,7 @@ async function start() { if (PLAYER_DEVICE_ID && !isRemotePlayer) { const { upsertPlayerRegistration } = require('./player/onboarding'); - await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL).catch(function (error) { + await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_URL, PLAYER_INTERNAL_URL).catch(function (error) { console.error(error); }); } diff --git a/src/player/onboarding/index.js b/src/player/onboarding/index.js index 6f38572..e1010b2 100644 --- a/src/player/onboarding/index.js +++ b/src/player/onboarding/index.js @@ -16,7 +16,7 @@ function normalizeDeviceId(value) { } function getPublicBaseUrl(req, configuredUrl) { - const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); if (configured) { return configured; } @@ -50,7 +50,7 @@ function getPlayerPublicBaseUrl(req, configuredUrl) { } function getPlayerInternalBaseUrl(configuredUrl) { - const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); return configured || null; } @@ -203,22 +203,22 @@ function registerPlayerOnboardingRoutes(app, options) { const common = options && options.common ? options.common : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null; - const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); - const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); + const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, ''); const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId); if (!app || !common) { throw new Error('registerPlayerOnboardingRoutes requires app and common.'); } - if (!thinClientBaseUrl && (!pool || !playerRuntime)) { - throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless thinClientBaseUrl is configured.'); + if (!bridgeBaseUrl && (!pool || !playerRuntime)) { + throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless bridgeBaseUrl is configured.'); } const sharedSecret = getSharedSecret(); async function fetchThinClient(req, pathname, options) { - if (!thinClientBaseUrl) { + if (!bridgeBaseUrl) { return null; } @@ -239,7 +239,7 @@ function registerPlayerOnboardingRoutes(app, options) { headers['content-type'] = requestOptions.contentType; } - return fetch(new URL(pathname, thinClientBaseUrl).toString(), { + return fetch(new URL(pathname, bridgeBaseUrl).toString(), { method: method, headers: headers, body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body @@ -305,7 +305,7 @@ function registerPlayerOnboardingRoutes(app, options) { }, async function (req, res, next) { try { const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId; - if (thinClientBaseUrl) { + if (bridgeBaseUrl) { const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), { method: 'GET' }); @@ -317,7 +317,7 @@ function registerPlayerOnboardingRoutes(app, options) { if (!payload) { return res.status(502).json({ error: 'Player bridge returned an invalid response.' }); } - payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null; + payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null; return res.json(payload); } @@ -329,7 +329,7 @@ function registerPlayerOnboardingRoutes(app, options) { screenId: status ? status.screen_id : null, screenSlug: status ? status.screen_slug : null, screenName: status ? status.screen_name : null, - playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null + playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null }); } catch (error) { next(error); @@ -338,7 +338,7 @@ function registerPlayerOnboardingRoutes(app, options) { app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) { try { - if (thinClientBaseUrl) { + if (bridgeBaseUrl) { const response = await fetchThinClient(_req, '/api/onboarding/screens', { method: 'GET' }); @@ -361,7 +361,7 @@ function registerPlayerOnboardingRoutes(app, options) { if (!deviceId) { return res.status(400).json({ error: 'Device ID is required' }); } - const onboardingUrl = `${getPublicBaseUrl(req, playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; + const onboardingUrl = `${getPublicBaseUrl(req, playerPublicUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 }); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); res.set('Cache-Control', 'no-store'); @@ -382,11 +382,11 @@ function registerPlayerOnboardingRoutes(app, options) { return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' }); } - if (thinClientBaseUrl) { + if (bridgeBaseUrl) { const forwardedBody = Object.assign({}, req.body || {}, { deviceId: deviceId }); - const response = await fetch(new URL('/api/onboarding', thinClientBaseUrl).toString(), { + const response = await fetch(new URL('/api/onboarding', bridgeBaseUrl).toString(), { method: 'POST', headers: Object.assign({ 'content-type': 'application/json' @@ -402,7 +402,7 @@ function registerPlayerOnboardingRoutes(app, options) { if (!payload) { return res.status(502).json({ error: 'Player bridge returned an invalid response.' }); } - payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`; + payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`; return res.json(payload); } if (!deviceId) { @@ -423,7 +423,7 @@ function registerPlayerOnboardingRoutes(app, options) { 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 ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`, + playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`, queued: Boolean(status && status.queued) }); } catch (error) { diff --git a/src/player/onboarding/player-onboarding-form.script.html b/src/player/onboarding/player-onboarding-form.script.html index e82e2e7..53a31e8 100644 --- a/src/player/onboarding/player-onboarding-form.script.html +++ b/src/player/onboarding/player-onboarding-form.script.html @@ -7,6 +7,12 @@ var form = document.getElementById("onboarding-form"); var message = document.getElementById("onboarding-message"); var screenSelect = document.getElementById("onboarding-screen-select"); + function getSessionStorageItem(key) { + try { return window.sessionStorage.getItem(key) || ""; } catch (_error) { return ""; } + } + function setSessionStorageItem(key, value) { + try { window.sessionStorage.setItem(key, value); } catch (_error) {} + } function setMessage(value) { if (message) { message.textContent = value || ""; } } function parseResponseError(response) { return response.text().then(function (text) { @@ -41,15 +47,15 @@ } try { if (!deviceId) { - deviceId = window.localStorage.getItem(deviceKey) || ""; + deviceId = window.sessionStorage.getItem(deviceKey) || ""; } if (deviceId) { - window.localStorage.setItem(deviceKey, deviceId); + window.sessionStorage.setItem(deviceKey, deviceId); } } catch (_error) {} loadScreens().then(function () { try { - var storedClientName = window.localStorage.getItem(clientNameKey) || ""; + var storedClientName = getSessionStorageItem(clientNameKey) || ""; var storedScreenSlug = window.localStorage.getItem(screenKey) || ""; var clientNameInput = form.querySelector("input[name=\"clientName\"]"); if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; } @@ -83,7 +89,7 @@ }) .then(function (payload) { if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); } - if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} } + if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); } try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {} setMessage("Onboarding complete."); if (form) { diff --git a/src/player/onboarding/player-onboarding-landing.script.html b/src/player/onboarding/player-onboarding-landing.script.html index 2a89920..67a5341 100644 --- a/src/player/onboarding/player-onboarding-landing.script.html +++ b/src/player/onboarding/player-onboarding-landing.script.html @@ -2,6 +2,12 @@ (function () { var deviceKey = "pulse-signage-player-device-id"; var clientNameKey = "pulse-signage-player-client-name"; + function getSessionStorageItem(key) { + try { return window.sessionStorage.getItem(key) || ""; } catch (_error) { return ""; } + } + function setSessionStorageItem(key, value) { + try { window.sessionStorage.setItem(key, value); } catch (_error) {} + } function getClientNameStorageKey(_screenSlug) { return clientNameKey; } @@ -25,10 +31,10 @@ } function getDeviceId() { var stored = ""; - try { stored = window.localStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; } + try { stored = window.sessionStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; } if (stored) { return stored; } var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "device-" + Date.now() + "-" + Math.random().toString(16).slice(2)); - try { window.localStorage.setItem(deviceKey, next); } catch (_error2) {} + try { window.sessionStorage.setItem(deviceKey, next); } catch (_error2) {} return next; } function setStatus(message) { if (status) { status.textContent = message; } } @@ -83,8 +89,8 @@ }) .then(function (payload) { if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); } - if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} } - if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} } + if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); } + if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {} setLocalMessage("Onboarding complete."); if (localForm) { @@ -99,8 +105,8 @@ .then(function (response) { return response.ok ? response.json() : null; }) .then(function (payload) { if (payload && payload.onboarded && payload.screenSlug) { - if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} } - if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} } + if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); } + if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {} window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug)); return true; @@ -127,8 +133,8 @@ loadScreens().then(function () { try { var storedScreenSlug = window.localStorage.getItem(screenKey) || ""; - var storedClientName = window.localStorage.getItem(clientNameKey) || ""; - if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; } + var storedClientName = getSessionStorageItem(clientNameKey) || ""; + if (!storedClientName && storedScreenSlug) { storedClientName = getSessionStorageItem(getClientNameStorageKey(storedScreenSlug)) || ""; } if (storedClientName && localForm) { var clientNameInput = localForm.querySelector("input[name=\"clientName\"]"); if (clientNameInput) { clientNameInput.value = storedClientName; } diff --git a/src/player/player-client-name.script.html b/src/player/player-client-name.script.html index 94b9e34..8792cd6 100644 --- a/src/player/player-client-name.script.html +++ b/src/player/player-client-name.script.html @@ -4,9 +4,25 @@ const onboardingClientNameStorageKey = 'pulse-signage-player-client-name'; const onboardingDeviceIdStorageKey = 'pulse-signage-player-device-id'; + function getSessionStorageItem(key) { + try { + return window.sessionStorage.getItem(key) || ''; + } catch (_error) { + return ''; + } + } + + function setSessionStorageItem(key, value) { + try { + window.sessionStorage.setItem(key, value); + } catch (_error) { + // ignore storage errors + } + } + function getOnboardingDeviceId() { try { - var storedDeviceId = window.localStorage.getItem(onboardingDeviceIdStorageKey) || ''; + var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey); return String(storedDeviceId || '').trim(); } catch (_error) { return ''; @@ -19,24 +35,14 @@ return onboardingClientName; } try { - var storedClientName = window.localStorage.getItem(onboardingClientNameStorageKey); + var storedClientName = getSessionStorageItem(onboardingClientNameStorageKey); if (storedClientName) { onboardingClientName = storedClientName; - try { - window.localStorage.setItem('pulse-signage-player-client-name', storedClientName); - } catch (_mirrorError) { - // ignore storage errors - } return onboardingClientName; } - var genericClientName = window.localStorage.getItem('pulse-signage-player-client-name'); + var genericClientName = getSessionStorageItem('pulse-signage-player-client-name'); if (genericClientName) { onboardingClientName = genericClientName; - try { - window.localStorage.setItem(onboardingClientNameStorageKey, genericClientName); - } catch (_error) { - // ignore storage errors - } return onboardingClientName; } } catch (_error) { @@ -51,12 +57,8 @@ return; } onboardingClientName = normalizedName; - try { - window.localStorage.setItem('pulse-signage-player-client-name', normalizedName); - window.localStorage.setItem(onboardingClientNameStorageKey, normalizedName); - } catch (_error) { - // ignore storage errors - } + setSessionStorageItem('pulse-signage-player-client-name', normalizedName); + setSessionStorageItem(onboardingClientNameStorageKey, normalizedName); if (socket && socket.readyState === WebSocket.OPEN) { sendCommandState(socket); } diff --git a/src/player/public/js/player-page-playlist.js b/src/player/public/js/player-page-playlist.js index 5d6b6c5..8ee0482 100644 --- a/src/player/public/js/player-page-playlist.js +++ b/src/player/public/js/player-page-playlist.js @@ -188,13 +188,13 @@ function syncWebpagePreloads(sourceSlides, targetIndex) { preloadSignature = signature; } -// Return a stable client id for this browser session. +// Return a stable client id for this screen session. function getCommandClientId() { if (commandClientId) { return commandClientId; } try { - var storedClientId = window.localStorage.getItem(commandClientStorageKey); + var storedClientId = window.sessionStorage.getItem(commandClientStorageKey); if (storedClientId) { commandClientId = storedClientId; return commandClientId; @@ -204,7 +204,7 @@ function getCommandClientId() { } commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2)); try { - window.localStorage.setItem(commandClientStorageKey, commandClientId); + window.sessionStorage.setItem(commandClientStorageKey, commandClientId); } catch (_error2) { // ignore storage errors } diff --git a/src/player/routes.js b/src/player/routes.js index 8b3d986..efb88f2 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -31,23 +31,23 @@ 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 playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); - const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); - const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); + 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; if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) { throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.'); } - if (!thinClientBaseUrl && (!pool || !playerPlaylistService)) { - throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless thinClientBaseUrl is configured.'); + if (!bridgeBaseUrl && (!pool || !playerPlaylistService)) { + throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless bridgeBaseUrl is configured.'); } const sharedSecret = getSharedSecret(); - async function fetchThinClient(req, pathname, options) { - if (!thinClientBaseUrl) { + async function fetchBridge(req, pathname, options) { + if (!bridgeBaseUrl) { return null; } @@ -71,7 +71,7 @@ function registerPlayerRoutes(app, options) { headers['content-type'] = requestOptions.contentType; } - return fetch(new URL(pathname, thinClientBaseUrl).toString(), { + return fetch(new URL(pathname, bridgeBaseUrl).toString(), { method: method, headers: headers, body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body @@ -179,8 +179,8 @@ function registerPlayerRoutes(app, options) { }); app.get('/api/media/config', requireRequestAuth, function (_req, res) { - if (thinClientBaseUrl) { - void fetch(new URL('/api/media/config', thinClientBaseUrl).toString(), { + if (bridgeBaseUrl) { + void fetch(new URL('/api/media/config', bridgeBaseUrl).toString(), { method: 'GET', headers: createRequestAuthHeaders({ method: 'GET', @@ -298,9 +298,9 @@ function registerPlayerRoutes(app, options) { app.get('/screen/:slug', function (req, res) { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Pragma', 'no-cache'); - if (thinClientBaseUrl) { + if (bridgeBaseUrl) { const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(req.params.slug || '').trim() }).token; - void fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), { + void fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), { method: 'GET', headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {} }).then(async function (response) { @@ -342,8 +342,8 @@ function registerPlayerRoutes(app, options) { app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) { try { - if (thinClientBaseUrl) { - const response = await fetchThinClient(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', { + if (bridgeBaseUrl) { + const response = await fetchBridge(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', { method: 'GET' }); if (!response) { @@ -397,8 +397,8 @@ function registerPlayerRoutes(app, options) { app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) { try { - if (thinClientBaseUrl) { - const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', { + if (bridgeBaseUrl) { + const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', { method: 'GET' }); if (!response) { @@ -440,8 +440,8 @@ function registerPlayerRoutes(app, options) { app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) { try { - if (thinClientBaseUrl) { - const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', { + if (bridgeBaseUrl) { + const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', { method: 'GET' }); if (!response) { diff --git a/src/player/runtime.js b/src/player/runtime.js index aa7f225..61d2e8b 100644 --- a/src/player/runtime.js +++ b/src/player/runtime.js @@ -21,6 +21,7 @@ function normalizePlayerPublicBaseUrl(pageUrl) { function createPlayerRuntime(options) { const pool = options && options.pool ? options.pool : null; + const notifySnapshot = typeof options.notifySnapshot === 'function' ? options.notifySnapshot : null; const normalizeDeviceId = typeof options.normalizeDeviceId === 'function' ? options.normalizeDeviceId : function (value) { @@ -222,6 +223,10 @@ function createPlayerRuntime(options) { return allConnections; } + function snapshotSlugs() { + return Array.from(connectionsBySlug.keys()); + } + async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) { return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections()); } @@ -229,6 +234,16 @@ function createPlayerRuntime(options) { function broadcastConnectionSnapshot(slug) { const key = String(slug || '').trim(); const bucket = dashboardListenersBySlug.get(key); + const connections = snapshotConnections(slug); + if (notifySnapshot) { + try { + notifySnapshot({ + slug: key, + connections: connections + }); + } catch (_error) { + } + } if (!bucket || !bucket.size) { return; } @@ -236,7 +251,7 @@ function createPlayerRuntime(options) { const payload = JSON.stringify({ type: 'snapshot', slug: key, - connections: snapshotConnections(slug), + connections: connections, sentAt: new Date().toISOString() }); @@ -508,6 +523,7 @@ function createPlayerRuntime(options) { broadcastAnnouncementRefresh: broadcastAnnouncementRefresh, snapshotConnections: snapshotConnections, snapshotAllConnections: snapshotAllConnections, + snapshotSlugs: snapshotSlugs, isClientNameAvailableOnScreen: isClientNameAvailableOnScreen, sendCommandToConnection: sendCommandToConnection, broadcastCommand: broadcastCommand diff --git a/src/web.js b/src/web.js index 255a963..98cc103 100644 --- a/src/web.js +++ b/src/web.js @@ -62,7 +62,8 @@ async function start() { const playerActionService = createPlayerActionService({ pool: pool, common: common, - playerInternalBaseUrl: webConfig.thinClientBaseUrl + playerInternalBaseUrl: webConfig.playerInternalUrl, + bridgeInternalBaseUrl: webConfig.bridgeInternalUrl }); const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand); @@ -70,8 +71,8 @@ async function start() { const webBootstrap = createWebBootstrap({ pool: pool, common: common, - playerInternalBaseUrl: webConfig.playerInternalBaseUrl, - thinClientBaseUrl: webConfig.thinClientBaseUrl, + playerInternalBaseUrl: webConfig.playerInternalUrl, + bridgeInternalBaseUrl: webConfig.bridgeInternalUrl, uploadDir: webConfig.uploadsDir, formatDashboardDate: formatDashboardDate, notifyPlayerScreens: notifyPlayerScreens, @@ -210,7 +211,7 @@ async function start() { initializeBackgroundTasks: initializeBackgroundTasks, captureSlideThumbnail: captureSlideThumbnail, server: server, - webBaseUrl: webConfig.webBaseUrl, + webBaseUrl: webConfig.webInternalUrl, dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs }); diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js index 3ed2f1e..f5e4b69 100644 --- a/src/web/bootstrap.js +++ b/src/web/bootstrap.js @@ -8,8 +8,8 @@ const { createRequestAuthHeaders } = require('#src/request-auth'); function createWebBootstrap(options) { const pool = options && options.pool; const common = options && options.common; - const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); - const configuredThinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); + const configuredPlayerInternalUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); + const configuredBridgeInternalUrl = String(options && options.bridgeInternalBaseUrl || process.env.BRIDGE_INTERNAL_URL || '').trim().replace(/\/$/, ''); const uploadDir = String(options && options.uploadDir || '').trim(); const dashboardRefreshIntervalMs = 5000; const formatDashboardDate = options && options.formatDashboardDate; @@ -27,7 +27,7 @@ function createWebBootstrap(options) { let dashboardRefreshInFlight = null; let broadcastDashboardState = null; function getPlayerSnapshotSocketUrl(slug) { - const resolvedPlayerInternalBaseUrl = configuredThinClientBaseUrl || configuredPlayerInternalBaseUrl; + const resolvedPlayerInternalBaseUrl = configuredBridgeInternalUrl || configuredPlayerInternalUrl; if (!resolvedPlayerInternalBaseUrl) { throw new Error('Unable to resolve the player internal base URL.'); } @@ -109,7 +109,7 @@ function createWebBootstrap(options) { const dashboardStateService = createDashboardStateService({ pool: pool, common: common, - thinClientBaseUrl: configuredThinClientBaseUrl, + thinClientBaseUrl: configuredBridgeInternalUrl, playerSnapshotCache: playerSnapshotCache, playerSnapshotSockets: playerSnapshotSockets, ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription, @@ -120,7 +120,7 @@ function createWebBootstrap(options) { const uploadSyncService = createUploadSyncService({ pool: pool, common: common, - playerInternalBaseUrl: configuredPlayerInternalBaseUrl, + playerInternalBaseUrl: configuredPlayerInternalUrl, playerSnapshotCache: playerSnapshotCache, notifyPlayerScreens: notifyPlayerScreens, backgroundTaskQueue: backgroundTaskQueue @@ -253,7 +253,7 @@ function createWebBootstrap(options) { return { upload: upload, uploadSyncService: uploadSyncService, - playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null, + playerInternalBaseUrl: configuredPlayerInternalUrl || null, buildDashboardState: buildDashboardState, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, diff --git a/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js b/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js index 7ba61d2..95f787c 100644 --- a/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js +++ b/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js @@ -18,57 +18,50 @@ function registerFontSweepTask(options) { const uploadSyncService = options && options.uploadSyncService; const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer; const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer; - const getPlayerTaskMetadata = uploadSyncService && uploadSyncService.getPlayerTaskMetadata; const mediaDir = String(options && options.mediaDir || '').trim(); if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) { throw new Error('registerFontSweepTask requires the font sweep dependencies.'); } - const metadataPromise = typeof getPlayerTaskMetadata === 'function' - ? Promise.resolve(getPlayerTaskMetadata()) - : Promise.resolve({}); + backgroundTaskQueue.registerRecurringTask({ + key: TASK.key, + title: TASK.title, + category: TASK.category, + intervalMs: TASK.intervalMs, + metadata: { + mediaDir: mediaDir + }, + run: async function () { + const desiredOperations = collectFontLibrarySyncOperations(mediaDir); + const desiredUploadPaths = new Set(desiredOperations.map(function (operation) { + return operation && operation.uploadPath ? operation.uploadPath : ''; + }).filter(Boolean)); + const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir); - return metadataPromise.then(function (metadata) { - backgroundTaskQueue.registerRecurringTask({ - key: TASK.key, - title: TASK.title, - category: TASK.category, - intervalMs: TASK.intervalMs, - metadata: Object.assign({ - mediaDir: mediaDir - }, metadata || {}), - run: async function () { - const desiredOperations = collectFontLibrarySyncOperations(mediaDir); - const desiredUploadPaths = new Set(desiredOperations.map(function (operation) { - return operation && operation.uploadPath ? operation.uploadPath : ''; - }).filter(Boolean)); - const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir); - - for (let i = 0; i < desiredOperations.length; i += 1) { - const operation = desiredOperations[i] || {}; - const uploadPath = String(operation.uploadPath || '').trim(); - if (!uploadPath) { - continue; - } - - if (String(operation.type || '').trim().toLowerCase() === 'delete') { - await removeUploadFileFromPlayer(uploadPath, mediaDir); - } else { - await pushUploadFileToPlayer(uploadPath, mediaDir); - } + for (let i = 0; i < desiredOperations.length; i += 1) { + const operation = desiredOperations[i] || {}; + const uploadPath = String(operation.uploadPath || '').trim(); + if (!uploadPath) { + continue; } - for (let i = 0; i < currentUploadPaths.length; i += 1) { - const uploadPath = String(currentUploadPaths[i] || '').trim(); - if (!uploadPath || desiredUploadPaths.has(uploadPath)) { - continue; - } - + if (String(operation.type || '').trim().toLowerCase() === 'delete') { await removeUploadFileFromPlayer(uploadPath, mediaDir); + } else { + await pushUploadFileToPlayer(uploadPath, mediaDir); } } - }); + + for (let i = 0; i < currentUploadPaths.length; i += 1) { + const uploadPath = String(currentUploadPaths[i] || '').trim(); + if (!uploadPath || desiredUploadPaths.has(uploadPath)) { + continue; + } + + await removeUploadFileFromPlayer(uploadPath, mediaDir); + } + } }); } diff --git a/src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune.js b/src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune.js new file mode 100644 index 0000000..6772f9d --- /dev/null +++ b/src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune.js @@ -0,0 +1,32 @@ +const TASK = { + key: 'onboarding-device-prune', + title: 'Onboarding device prune', + category: 'cleanup', + trigger: 'scheduled recurring task, hourly', + purpose: 'remove stale onboarding device bindings that have been idle for more than one minute.', + taskType: 'recurring-run', + intervalMs: 60 * 60 * 1000 +}; + +function registerOnboardingDevicePruneTask(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const pool = options && options.pool; + const common = options && options.common; + + if (!backgroundTaskQueue || !pool || !common || typeof common.pruneStaleOnboardingDevices !== 'function') { + throw new Error('registerOnboardingDevicePruneTask requires the onboarding prune dependencies.'); + } + + backgroundTaskQueue.registerRecurringTask({ + key: TASK.key, + title: TASK.title, + category: TASK.category, + intervalMs: TASK.intervalMs, + metadata: {}, + run: async function () { + await common.pruneStaleOnboardingDevices(pool); + } + }); +} + +module.exports = { registerOnboardingDevicePruneTask }; \ No newline at end of file diff --git a/src/web/lib/config.js b/src/web/lib/config.js index 27da7b5..408a9dd 100644 --- a/src/web/lib/config.js +++ b/src/web/lib/config.js @@ -5,9 +5,9 @@ function createWebConfig() { const uploadsDir = path.join(mediaDir, 'uploads'); const thumbnailsDir = path.join(mediaDir, 'thumbnails'); const assetDir = path.join(__dirname, '..', 'public'); - const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, ''); - const thinClientBaseUrl = (process.env.THIN_CLIENT_BASE_URL || 'http://player-bridge:8090').replace(/\/$/, ''); - const webBaseUrl = (process.env.WEB_BASE_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, ''); + const playerInternalUrl = (process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, ''); + const bridgeInternalUrl = (process.env.BRIDGE_INTERNAL_URL || 'http://player-bridge:8090').replace(/\/$/, ''); + const webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, ''); const sessionCookieName = 'digital_signage_session'; const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14); const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000; @@ -20,9 +20,9 @@ function createWebConfig() { uploadsDir: uploadsDir, thumbnailsDir: thumbnailsDir, assetDir: assetDir, - playerInternalBaseUrl: playerInternalBaseUrl, - thinClientBaseUrl: thinClientBaseUrl, - webBaseUrl: webBaseUrl, + playerInternalUrl: playerInternalUrl, + bridgeInternalUrl: bridgeInternalUrl, + webInternalUrl: webInternalUrl, sessionCookieName: sessionCookieName, sessionMaxAgeMs: sessionMaxAgeMs, dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 66077dd..1f551b3 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -438,6 +438,9 @@ function createUploadSyncService(options) { console.warn('Unable to flush pending upload syncs:', error); }); }, 5000); + if (pendingPlayerUploadSyncFlushTimer && typeof pendingPlayerUploadSyncFlushTimer.unref === 'function') { + pendingPlayerUploadSyncFlushTimer.unref(); + } } async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { @@ -677,6 +680,11 @@ function createUploadSyncService(options) { return pendingPlaylistUploadSyncFlushInFlight; } + if (pendingPlaylistUploadSyncFlushTimer) { + clearTimeout(pendingPlaylistUploadSyncFlushTimer); + pendingPlaylistUploadSyncFlushTimer = null; + } + if (!pendingPlaylistUploadSyncs.size) { return null; } @@ -715,6 +723,11 @@ function createUploadSyncService(options) { return pendingPlayerUploadSyncFlushInFlight; } + if (pendingPlayerUploadSyncFlushTimer) { + clearTimeout(pendingPlayerUploadSyncFlushTimer); + pendingPlayerUploadSyncFlushTimer = null; + } + if (!pendingPlayerUploadSyncs.size) { return null; } diff --git a/src/web/lib/player-actions.js b/src/web/lib/player-actions.js index a990ff9..bf1225d 100644 --- a/src/web/lib/player-actions.js +++ b/src/web/lib/player-actions.js @@ -1,5 +1,5 @@ const { createRequestAuthHeaders } = require('#src/request-auth'); -const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry'); +const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry'); function isLocalLikeBaseUrl(value) { let host = ''; @@ -33,24 +33,10 @@ function isRecentPlayerRegistration(player, staleSeconds) { return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; } -async function fetchRecentPlayerRegistrations(pool) { - if (!pool || typeof fetchPlayerRegistrations !== 'function') { - return []; - } - - try { - const players = await fetchPlayerRegistrations(pool); - return (Array.isArray(players) ? players : []).filter(function (player) { - return isRecentPlayerRegistration(player, 60); - }); - } catch (_error) { - return []; - } -} - function createPlayerActionService(options) { const pool = options && options.pool; const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); + const configuredBridgeInternalBaseUrl = String(options && options.bridgeInternalBaseUrl || '').trim().replace(/\/$/, ''); const common = options && options.common; if (!common) { @@ -151,6 +137,79 @@ function createPlayerActionService(options) { }); } + async function fetchScreenConnectionsFromBaseUrl(baseUrl, slug) { + const targetBaseUrl = normalizeBaseUrl(baseUrl); + if (!targetBaseUrl) { + return null; + } + + const authHeaders = createRequestAuthHeaders({ + method: 'GET', + pathname: `/api/screens/${encodeURIComponent(slug)}/connections` + }); + + const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, { + method: 'GET', + headers: { + Accept: 'application/json', + ...authHeaders + } + }); + + if (!response.ok) { + const errorText = await response.text().catch(function () { return ''; }); + const error = new Error(errorText || `Unable to fetch connections for player ${slug}.`); + error.statusCode = response.status; + throw error; + } + + return response.json().catch(function () { + return null; + }); + } + + async function forwardPlayerCommandToDevice(deviceId, commandOrPayload) { + const targetDeviceId = String(deviceId || '').trim(); + const targetBridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl); + if (!targetDeviceId) { + throw new Error('Device ID is required.'); + } + if (!targetBridgeBaseUrl) { + throw new Error('Unable to resolve the player bridge base URL.'); + } + + const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null + ? Object.assign({}, commandOrPayload) + : { command: commandOrPayload }; + + const authHeaders = createRequestAuthHeaders({ + method: 'POST', + pathname: `/api/players/${encodeURIComponent(targetDeviceId)}/commands`, + body: payload + }); + + const response = await fetch(`${targetBridgeBaseUrl}/api/players/${encodeURIComponent(targetDeviceId)}/commands`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...authHeaders + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text().catch(function () { return ''; }); + const error = new Error(errorText || `Unable to send command to player ${targetDeviceId}.`); + error.statusCode = response.status; + throw error; + } + + return response.json().catch(function () { + return { ok: true }; + }); + } + async function forwardPlayerCommand(slug, commandOrPayload, connectionId) { const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId); @@ -190,69 +249,62 @@ function createPlayerActionService(options) { } async function getScreenConnections(slug) { - const authHeaders = createRequestAuthHeaders({ - method: 'GET', - pathname: `/api/screens/${encodeURIComponent(slug)}/connections` - }); - const recentPlayers = await fetchRecentPlayerRegistrations(pool); - const targetBaseUrls = Array.from(new Set((recentPlayers.length ? recentPlayers : []).map(function (player) { - return normalizeBaseUrl(player && player.public_base_url); - }).filter(Boolean))); - - if (!targetBaseUrls.length) { - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); - if (resolvedPlayerInternalBaseUrl) { - targetBaseUrls.push(resolvedPlayerInternalBaseUrl); - } - } - + const bridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl); + const playerBaseUrl = await getPlayerInternalBaseUrl(); + const targetBaseUrls = Array.from(new Set([bridgeBaseUrl, playerBaseUrl].map(normalizeBaseUrl).filter(Boolean))); if (!targetBaseUrls.length) { throw new Error('Unable to resolve the player internal base URL.'); } - const results = await Promise.all(targetBaseUrls.map(async function (baseUrl) { - const response = await fetch(`${baseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...authHeaders - } - }); - - if (!response.ok) { - return null; - } - - return response.json().catch(function () { - return null; - }); + const results = await Promise.allSettled(targetBaseUrls.map(function (baseUrl) { + return fetchScreenConnectionsFromBaseUrl(baseUrl, slug); })); + const successfulResults = results.filter(function (result) { + return result.status === 'fulfilled' && result.value; + }).map(function (result) { + return result.value; + }); + + if (!successfulResults.length) { + const rejection = results.find(function (result) { + return result.status === 'rejected'; + }); + throw rejection ? rejection.reason : new Error(`Unable to fetch connections for player ${slug}.`); + } + const mergedConnections = []; - let screen = null; - let degraded = false; - results.forEach(function (result) { - if (!result) { - degraded = true; - return; - } - if (!screen && result.screen) { - screen = result.screen; - } - if (Array.isArray(result.connections)) { - mergedConnections.push.apply(mergedConnections, result.connections); - } - if (result.degraded) { - degraded = true; - } + const seenKeys = new Set(); + + successfulResults.forEach(function (result) { + const connections = Array.isArray(result && result.connections) ? result.connections : []; + connections.forEach(function (connection) { + const key = [ + String(connection && connection.id || '').trim(), + String(connection && connection.clientId || '').trim(), + String(connection && connection.deviceId || '').trim(), + String(connection && connection.playerPublicBaseUrl || '').trim() + ].join('|'); + if (!key || seenKeys.has(key)) { + return; + } + seenKeys.add(key); + mergedConnections.push(connection); + }); }); return { - screen: screen, + screen: successfulResults.find(function (result) { + return Boolean(result && result.screen); + }) ? successfulResults.find(function (result) { + return Boolean(result && result.screen); + }).screen : null, screenSlug: slug, count: mergedConnections.length, connections: mergedConnections, - degraded: degraded + degraded: results.some(function (result) { + return result.status === 'fulfilled' && Boolean(result.value && result.value.degraded); + }) }; } @@ -302,6 +354,7 @@ function createPlayerActionService(options) { forwardAnnouncementRefresh: forwardAnnouncementRefresh, getScreenConnections: getScreenConnections, forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl, + forwardPlayerCommandToDevice: forwardPlayerCommandToDevice, getScreenDeleteBlockMessage: getScreenDeleteBlockMessage, getSlideDeleteBlockMessage: getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage, diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js index 9eb6e4b..46a65fa 100644 --- a/src/web/public/js/dashboard/dashboard-page.js +++ b/src/web/public/js/dashboard/dashboard-page.js @@ -8,8 +8,6 @@ var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp; var LIST_PAGE_SIZE = 25; var latestDashboardState = null; - var ALL_SCREENS_SLUG = '__all__'; - var ALL_SCREENS_LABEL = 'All screens'; function getClientSearchInput() { var table = document.getElementById('dashboard-clients-table'); @@ -20,6 +18,147 @@ return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]'); } + function getScreenCommandSelect() { + return document.querySelector('[data-screen-command-select]'); + } + + function getScreenCommandForms() { + if (!document.querySelectorAll) { + return []; + } + + return Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]')); + } + + function getSelectedScreenClients(state) { + var select = getScreenCommandSelect(); + var selectedSlug = select ? String(select.value || '').trim() : ''; + var clients = Array.isArray(state && state.clients) ? state.clients : []; + + if (!selectedSlug || selectedSlug === '__all__') { + return clients; + } + + return clients.filter(function (client) { + return String(client && client.screen_slug || '').trim() === selectedSlug; + }); + } + + function getSelectedScreenLabel() { + var select = getScreenCommandSelect(); + if (!select) { + return ''; + } + + var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null; + return selectedOption ? String(selectedOption.getAttribute('data-screen-name') || selectedOption.textContent || '').trim() : ''; + } + + function updateToggleButton(button, form, state) { + if (!button) { + return; + } + + var action = String(form && form.getAttribute('data-screen-command-action') || '').trim(); + var selectedLabel = getSelectedScreenLabel() || 'selected screen group'; + var isAllScreens = String(getScreenCommandSelect() && getScreenCommandSelect().value || '').trim() === '__all__'; + var clients = getSelectedScreenClients(state); + var hasClients = clients.length > 0; + var allPaused = hasClients && clients.every(function (client) { + return Boolean(client && client.paused); + }); + var allBlackout = hasClients && clients.every(function (client) { + return Boolean(client && client.blackout); + }); + + if (action === 'pause') { + var pauseLabel = allPaused ? 'Resume ' + (isAllScreens ? 'all clients' : 'screen') : 'Pause ' + (isAllScreens ? 'all clients' : 'screen'); + var pauseConfirm = allPaused ? 'Resume ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?' : 'Pause ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?'; + var pauseIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill'; + button.innerHTML = '' + escapeHtml(pauseLabel); + setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info'); + button.setAttribute('aria-label', pauseLabel); + button.setAttribute('title', pauseLabel); + if (form) { + var pauseInput = form.querySelector('input[name="paused"]'); + if (pauseInput) { + pauseInput.value = allPaused ? 'false' : 'true'; + } + form.setAttribute('data-confirm-message', pauseConfirm); + } + return; + } + + if (action === 'blackout') { + var blackoutLabel = allBlackout ? 'Restore ' + (isAllScreens ? 'all clients' : 'screen') : 'Blackout ' + (isAllScreens ? 'all clients' : 'screen'); + var blackoutConfirm = allBlackout ? 'Restore ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?' : 'Blackout ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?'; + var blackoutIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash'; + button.innerHTML = '' + escapeHtml(blackoutLabel); + setButtonVariant(button, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary'); + button.setAttribute('aria-label', blackoutLabel); + button.setAttribute('title', blackoutLabel); + if (form) { + var blackoutInput = form.querySelector('input[name="blackout"]'); + if (blackoutInput) { + blackoutInput.value = allBlackout ? 'false' : 'true'; + } + form.setAttribute('data-confirm-message', blackoutConfirm); + } + } + } + + function updateScreenCommandControls() { + var select = getScreenCommandSelect(); + if (!select) { + return; + } + + var selectedSlug = String(select.value || '').trim(); + var actionTarget = '/clients/' + encodeURIComponent(selectedSlug || '__all__') + '/commands'; + var selectedName = getSelectedScreenLabel(); + var selectedClients = getSelectedScreenClients(latestDashboardState); + var allPaused = selectedClients.length > 0 && selectedClients.every(function (client) { + return Boolean(client && client.paused); + }); + var allBlackout = selectedClients.length > 0 && selectedClients.every(function (client) { + return Boolean(client && client.blackout); + }); + + getScreenCommandForms().forEach(function (form) { + if (!form) { + return; + } + + form.setAttribute('action', actionTarget); + + var action = String(form.getAttribute('data-screen-command-action') || '').trim(); + var button = form.querySelector('button[type="submit"]'); + + if (action === 'pause' || action === 'blackout') { + updateToggleButton(button, form, latestDashboardState); + return; + } + + if (button) { + button.setAttribute('aria-label', selectedName ? selectedName : 'Selected screen group'); + } + }); + } + + function initScreenCommandControls() { + var select = getScreenCommandSelect(); + if (!select || (select.dataset && select.dataset.bound === 'true')) { + return; + } + + if (select.dataset) { + select.dataset.bound = 'true'; + } + + select.addEventListener('change', updateScreenCommandControls); + updateScreenCommandControls(); + } + function getClientListQueryState() { var searchParams = new URLSearchParams(String(window.location && window.location.search || '')); var searchInput = getClientSearchInput(); @@ -170,77 +309,6 @@ return clients.slice((query.page - 1) * LIST_PAGE_SIZE, ((query.page - 1) * LIST_PAGE_SIZE) + LIST_PAGE_SIZE); } - function getClientMoveModalElements() { - return { - modal: document.getElementById('client-move-screen-modal'), - form: document.getElementById('client-move-screen-form'), - targetSelect: document.getElementById('client-move-screen-target'), - connectionInput: document.querySelector('[data-client-move-connection-id]'), - deviceInput: document.querySelector('[data-client-move-device-id]'), - clientNameInput: document.querySelector('[data-client-move-client-name]'), - playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]') - }; - } - - function getClientMoveScreens() { - if (latestDashboardState && Array.isArray(latestDashboardState.screens) && latestDashboardState.screens.length) { - return latestDashboardState.screens.slice().sort(compareScreensByConnectedClients); - } - - var select = document.getElementById('client-move-screen-target'); - if (!select || !select.options) { - return []; - } - - return Array.prototype.slice.call(select.options).map(function (option) { - return { - slug: String(option.value || '').trim(), - name: String(option.textContent || option.value || '').trim() - }; - }).filter(function (screen) { - return Boolean(screen && screen.slug); - }); - } - - function updateClientMoveModalFromRow(row) { - var elements = getClientMoveModalElements(); - if (!elements.form || !elements.targetSelect || !row) { - return; - } - - var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim(); - var connectionId = String(row.getAttribute('data-client-key') || '').trim(); - var deviceId = String(row.getAttribute('data-client-device-id') || '').trim(); - var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim(); - var clientNameCell = row.querySelector('td[data-label="Client"] > div'); - var clientName = String(clientNameCell && clientNameCell.textContent || '').trim(); - var options = Array.prototype.slice.call(elements.targetSelect.options || []); - - options.forEach(function (option) { - option.disabled = false; - if (String(option.value || '').trim() === currentScreenSlug) { - option.disabled = true; - } - }); - - elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#'; - if (elements.connectionInput) { - elements.connectionInput.value = connectionId; - } - if (elements.deviceInput) { - elements.deviceInput.value = deviceId; - } - if (elements.clientNameInput) { - elements.clientNameInput.value = clientName; - } - if (elements.playerBaseUrlInput) { - elements.playerBaseUrlInput.value = playerBaseUrl; - } - elements.targetSelect.value = ''; - if (elements.form.querySelector('button[type="submit"]')) { - elements.form.querySelector('button[type="submit"]').disabled = false; - } - } function renderClientActionCell(client) { var paused = Boolean(client.paused); var pauseButtonClass = 'btn btn-sm btn-info'; @@ -257,7 +325,7 @@ return [ '
', '
', - '', + '', '
', '
', '
', @@ -266,6 +334,60 @@ ].join(''); } + function getClientMoveModalElements() { + return { + modal: document.getElementById('client-move-screen-modal'), + form: document.getElementById('client-move-screen-form'), + targetSelect: document.getElementById('client-move-screen-target'), + connectionInput: document.querySelector('[data-client-move-connection-id]'), + deviceInput: document.querySelector('[data-client-move-device-id]'), + clientNameInput: document.querySelector('[data-client-move-client-name]'), + playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]') + }; + } + + function updateClientMoveModalFromRow(row) { + var elements = getClientMoveModalElements(); + if (!elements.form || !elements.targetSelect || !row) { + return; + } + + var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim(); + var connectionId = String(row.getAttribute('data-client-id') || '').trim(); + var deviceId = String(row.getAttribute('data-client-device-id') || '').trim(); + var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim(); + var clientNameCell = row.querySelector('td[data-label="Client"] > div'); + var clientName = String(clientNameCell && clientNameCell.textContent || '').trim(); + var options = Array.prototype.slice.call(elements.targetSelect.options || []); + + options.forEach(function (option) { + if (!option) { + return; + } + option.disabled = String(option.value || '').trim() === currentScreenSlug; + }); + + elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#'; + if (elements.connectionInput) { + elements.connectionInput.value = connectionId; + } + if (elements.deviceInput) { + elements.deviceInput.value = deviceId; + } + if (elements.clientNameInput) { + elements.clientNameInput.value = clientName; + } + if (elements.playerBaseUrlInput) { + elements.playerBaseUrlInput.value = playerBaseUrl; + } + if (elements.targetSelect) { + elements.targetSelect.value = ''; + } + if (elements.form.querySelector('button[type="submit"]')) { + elements.form.querySelector('button[type="submit"]').disabled = false; + } + } + function updateClientActionCell(cell, client) { if (!cell) { return; @@ -440,7 +562,7 @@ var actionCell = hasActionsColumn ? '' + renderClientActionCell(client) + '' : ''; return [ - '', + '', '
' + clientName + '
', '
' + screenName + '
', '' + currentSlide + '', @@ -467,7 +589,8 @@ var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : 'No slide currently showing'; row.setAttribute('data-client-key', escapeHtml(getClientRowKey(client))); - row.setAttribute('data-client-id', escapeHtml(client.clientId || '')); + row.setAttribute('data-client-id', escapeHtml(client.id || '')); + row.setAttribute('data-client-client-id', escapeHtml(client.clientId || '')); row.setAttribute('data-client-device-id', escapeHtml(client.deviceId || '')); row.setAttribute('data-client-screen-slug', escapeHtml(client.screen_slug || '')); row.setAttribute('data-client-player-base-url', escapeHtml(client.player_url || '')); @@ -627,6 +750,112 @@ updateClientTable(latestDashboardState, true); } + function initClientMoveHandler() { + var elements = getClientMoveModalElements(); + if (!elements.modal || !elements.form || !elements.targetSelect) { + return; + } + + if (typeof elements.modal.addEventListener === 'function') { + elements.modal.addEventListener('show.bs.modal', function (event) { + var trigger = event && event.relatedTarget ? event.relatedTarget : null; + var row = trigger && trigger.closest ? trigger.closest('tr[data-client-key]') : null; + if (row) { + updateClientMoveModalFromRow(row); + } + }); + } + + document.addEventListener('click', function (event) { + var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null; + if (!moveButton) { + return; + } + + if (event && typeof event.preventDefault === 'function') { + event.preventDefault(); + } + + var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null; + if (!row) { + return; + } + + updateClientMoveModalFromRow(row); + + if (window.pulseModal && typeof window.pulseModal.show === 'function') { + window.pulseModal.show(elements.modal); + return; + } + + if (window.bootstrap && window.bootstrap.Modal) { + window.bootstrap.Modal.getOrCreateInstance(elements.modal).show(); + } + }); + + elements.form.addEventListener('submit', function (event) { + if (elements.form.dataset && elements.form.dataset.busy === 'true') { + event.preventDefault(); + return; + } + + var targetScreenSlug = String(elements.targetSelect.value || '').trim(); + if (!targetScreenSlug) { + event.preventDefault(); + window.alert('Choose a target screen.'); + return; + } + + event.preventDefault(); + elements.form.dataset.busy = 'true'; + + var formData = new FormData(elements.form); + var body = new URLSearchParams(); + formData.forEach(function (value, key) { + body.append(key, value); + }); + + fetch(elements.form.action, { + method: (elements.form.method || 'POST').toUpperCase(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json, text/plain, */*' + }, + body: body.toString(), + credentials: 'same-origin' + }).then(function (response) { + if (!response.ok) { + return response.text().then(function (text) { + var error = new Error(text || 'Unable to move client.'); + try { + var payload = JSON.parse(text); + if (payload && (payload.error || payload.message)) { + error = new Error(String(payload.error || payload.message)); + } + } catch (_error) { + // fall back to the raw text body + } + throw error; + }); + } + + if (window.pulseModal && typeof window.pulseModal.hide === 'function') { + window.pulseModal.hide(elements.modal); + } else if (window.bootstrap && window.bootstrap.Modal) { + window.bootstrap.Modal.getOrCreateInstance(elements.modal).hide(); + } + return response.json().catch(function () { + return { ok: true }; + }); + }).catch(function (error) { + window.alert(error && error.message ? error.message : 'Unable to move client.'); + }).finally(function () { + delete elements.form.dataset.busy; + }); + }); + } + function updateKioskLauncherModal(state) { var modal = document.getElementById('dashboard-kiosk-launcher-modal'); if (!modal) { @@ -696,163 +925,6 @@ } grid.innerHTML = screens.map(renderScreenTile).join(''); } - - function updateScreenCommandControls(state) { - var select = document.getElementById('screen-command-select'); - if (!select) { - return; - } - - var forms = Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]')); - var pill = document.querySelector('[data-screen-command-pill]'); - var nameNode = document.querySelector('[data-screen-command-name]'); - var metaNode = document.querySelector('[data-screen-command-meta]'); - var screens = Array.isArray(state && state.screens) ? state.screens : []; - var screenBySlug = {}; - - screens.forEach(function (screen) { - if (screen && screen.slug) { - screenBySlug[String(screen.slug)] = screen; - } - }); - - if (!screens.length) { - select.value = ''; - select.disabled = true; - forms.forEach(function (form) { - form.querySelectorAll('button, input').forEach(function (control) { - control.disabled = true; - }); - }); - if (pill) { - pill.classList.remove('is-live'); - pill.classList.add('is-idle'); - pill.textContent = 'No screens'; - } - if (nameNode) { - nameNode.textContent = 'No target available'; - } - if (metaNode) { - metaNode.textContent = 'Create a screen before using screen-level commands.'; - } - return; - } - - select.disabled = false; - - var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null; - var isAllSelected = Boolean(selectedOption && String(selectedOption.getAttribute('data-screen-target-all') || '').toLowerCase() === 'true') || select.value === ALL_SCREENS_SLUG; - var selectedScreen = isAllSelected - ? { - slug: ALL_SCREENS_SLUG, - name: String(selectedOption && selectedOption.textContent || ALL_SCREENS_LABEL).trim() || ALL_SCREENS_LABEL - } - : screenBySlug[select.value] || null; - var selectedSlug = isAllSelected - ? ALL_SCREENS_SLUG - : String(selectedScreen && selectedScreen.slug || '').trim(); - var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) { - if (isAllSelected) { - return true; - } - return String(client && client.screen_slug || '').trim() === selectedSlug; - }) : []; - var connectionCount = selectedClients.length; - var hasClients = connectionCount > 0; - var allPaused = hasClients && selectedClients.every(function (client) { - return Boolean(client && client.paused); - }); - var allBlackout = hasClients && selectedClients.every(function (client) { - return Boolean(client && client.blackout); - }); - var connectionLabel = hasClients ? connectionCount + ' connected client' + (connectionCount === 1 ? '' : 's') : 'No clients connected'; - - if (pill) { - pill.classList.toggle('is-live', hasClients); - pill.classList.toggle('is-idle', !hasClients); - pill.textContent = connectionLabel; - } - if (nameNode) { - nameNode.textContent = selectedScreen - ? String(selectedScreen.name || 'Selected screen') - : 'Select a target screen group'; - } - if (metaNode) { - metaNode.textContent = !selectedSlug - ? 'Choose a screen group before sending commands.' - : isAllSelected - ? 'Commands sent here target every client across every screen group.' - : 'Commands sent here target every client currently using this screen.'; - } - - var commandTargetSlug = selectedSlug || ''; - - forms.forEach(function (form) { - var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase(); - var commandInput = form.querySelector('input[name="command"]'); - var button = form.querySelector('button[type="submit"]'); - if (commandInput) { - if (command === 'pause') { - commandInput.value = allPaused ? 'pause' : 'pause'; - var pauseStateInput = form.querySelector('input[name="paused"]'); - if (pauseStateInput) { - pauseStateInput.value = allPaused ? 'false' : 'true'; - } - if (button) { - button.innerHTML = '' + (allPaused ? (isAllSelected ? 'Resume all screens' : 'Resume screen') : (isAllSelected ? 'Pause all screens' : 'Pause screen')); - } - setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info'); - form.setAttribute('data-confirm-message', allPaused - ? (isAllSelected ? 'Resume all connected clients on all screens?' : 'Resume all connected clients on this screen?') - : (isAllSelected ? 'Pause all connected clients on all screens?' : 'Pause all connected clients on this screen?')); - } else if (command === 'blackout') { - commandInput.value = 'blackout'; - var blackoutStateInput = form.querySelector('input[name="blackout"]'); - if (blackoutStateInput) { - blackoutStateInput.value = allBlackout ? 'false' : 'true'; - } - if (button) { - button.innerHTML = '' + (allBlackout ? (isAllSelected ? 'Restore all screens' : 'Restore screen') : (isAllSelected ? 'Blackout all screens' : 'Blackout screen')); - } - setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary'); - form.setAttribute('data-confirm-message', allBlackout - ? (isAllSelected ? 'Restore all connected clients on all screens?' : 'Restore all connected clients on this screen?') - : (isAllSelected ? 'Blackout all connected clients on all screens?' : 'Blackout all connected clients on this screen?')); - } else { - commandInput.value = command || commandInput.value || ''; - } - } - form.action = commandTargetSlug ? '/clients/' + encodeURIComponent(commandTargetSlug) + '/commands' : '#'; - if (command === 'reload') { - form.setAttribute('data-confirm-message', isAllSelected ? 'Reload all screens?' : 'Reload selected screen?'); - if (button) { - button.innerHTML = '' + (isAllSelected ? 'Reload all screens' : 'Reload screen'); - } - } - Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) { - control.disabled = !commandTargetSlug; - }); - }); - } - - function readScreenCommandStateFromDom() { - var select = document.getElementById('screen-command-select'); - if (!select) { - return { screens: [] }; - } - - return { - screens: Array.prototype.slice.call(select.options || []).map(function (option) { - return { - slug: String(option.value || '').trim(), - name: String(option.getAttribute('data-screen-name') || option.textContent || option.value || '').trim(), - player_connection_count: Number(option.getAttribute('data-player-connection-count') || 0), - playlist_name: String(option.getAttribute('data-playlist-name') || '').trim() - }; - }) - }; - } - function updateDashboardQuickActions(state) { var pauseButton = document.getElementById('dashboard-pause-all-button'); var blackoutButton = document.getElementById('dashboard-blackout-all-button'); @@ -889,7 +961,7 @@ var blackoutButtonIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash'; blackoutButton.innerHTML = '' + escapeHtml(label); - setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], 'btn-secondary'); + setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary'); if (blackoutInput) { blackoutInput.value = allBlackout ? 'false' : 'true'; } @@ -907,10 +979,10 @@ window.webLatestDashboardState = latestDashboardState; updateStats(state); updateScreenGrid(state); - updateScreenCommandControls(state); updateClientTable(state); updateKioskLauncherModal(state); updateDashboardQuickActions(state); + updateScreenCommandControls(); } function sendClientRename(screenSlug, connectionId, clientId, deviceId, clientName) { @@ -967,8 +1039,8 @@ return; } - var connectionId = String(row.getAttribute('data-client-key') || '').trim(); - var clientId = String(row.getAttribute('data-client-id') || '').trim(); + var connectionId = String(row.getAttribute('data-client-id') || '').trim(); + var clientId = String(row.getAttribute('data-client-client-id') || '').trim(); var deviceId = String(row.getAttribute('data-client-device-id') || '').trim(); var screenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim(); var currentName = String(cell.textContent || '').trim(); @@ -1003,18 +1075,41 @@ return; } + if (typeof elements.modal.addEventListener === 'function') { + elements.modal.addEventListener('show.bs.modal', function (event) { + var trigger = event && event.relatedTarget ? event.relatedTarget : null; + var row = trigger && trigger.closest ? trigger.closest('tr[data-client-key]') : null; + if (row) { + updateClientMoveModalFromRow(row); + } + }); + } + document.addEventListener('click', function (event) { var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null; if (!moveButton) { return; } + if (event && typeof event.preventDefault === 'function') { + event.preventDefault(); + } + var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null; if (!row) { return; } updateClientMoveModalFromRow(row); + + if (window.pulseModal && typeof window.pulseModal.show === 'function') { + window.pulseModal.show(elements.modal); + return; + } + + if (window.bootstrap && window.bootstrap.Modal) { + window.bootstrap.Modal.getOrCreateInstance(elements.modal).show(); + } }); elements.form.addEventListener('submit', function (event) { @@ -1131,11 +1226,5 @@ initClientRenameHandler(); initClientMoveHandler(); initKioskLauncherModal(); - var screenCommandSelect = document.getElementById('screen-command-select'); - if (screenCommandSelect) { - updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom()); - screenCommandSelect.addEventListener('change', function () { - updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom()); - }); - } + initScreenCommandControls(); }()); diff --git a/src/web/public/js/web-ui-helpers.js b/src/web/public/js/web-ui-helpers.js index 56f3353..bda1878 100644 --- a/src/web/public/js/web-ui-helpers.js +++ b/src/web/public/js/web-ui-helpers.js @@ -37,7 +37,12 @@ } function getClientRowKey(client) { - return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim(); + var clientName = String(client && client.client_name ? client.client_name : '').trim(); + if (clientName) { + return clientName; + } + + return String(client && client.screen_slug ? client.screen_slug : client && client.deviceId ? client.deviceId : client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim(); } function getClientDisplayName(client) { diff --git a/src/web/routes/admin/client-commands.js b/src/web/routes/admin/client-commands.js index 682c44a..f7161d9 100644 --- a/src/web/routes/admin/client-commands.js +++ b/src/web/routes/admin/client-commands.js @@ -12,40 +12,122 @@ module.exports = function registerScreenCommandRoutes(app, deps) { const withClientNameReservation = deps.withClientNameReservation; const broadcastDashboardState = deps.broadcastDashboardState; const requirePermission = deps.requirePermission; - const ALL_SCREENS_SLUG = '__all__'; - async function resolveScreenPlayerBaseUrls(screenSlug, connectionId) { - if (typeof getScreenConnections !== 'function' || !screenSlug) { + function normalizeExplicitPlayerBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); + } + + function normalizeConnectionBaseUrl(connection) { + return normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl); + } + + function normalizeBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); + } + + async function fetchPlayerRegistrations() { + if (typeof common.fetchPlayerRegistrations !== 'function') { return []; } try { - const response = await getScreenConnections(screenSlug); - const liveConnections = Array.isArray(response && response.connections) ? response.connections : []; - if (!liveConnections.length) { - return []; - } - - const normalizedConnectionId = String(connectionId || '').trim(); - const liveConnection = normalizedConnectionId - ? liveConnections.find(function (connection) { - const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim(); - const candidateDeviceId = String(connection && connection.deviceId || '').trim(); - return candidateConnectionId === normalizedConnectionId || candidateDeviceId === normalizedConnectionId; - }) - : null; - const targetConnections = liveConnection ? [liveConnection] : liveConnections; - - return Array.from(new Set(targetConnections.map(function (connection) { - return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, ''); - }).filter(Boolean))); + const registrations = await common.fetchPlayerRegistrations(pool); + return Array.isArray(registrations) ? registrations : []; } catch (_error) { return []; } } - function normalizeExplicitPlayerBaseUrl(value) { - return String(value || '').trim().replace(/\/$/, ''); + async function fetchScreenConnections(screenSlug) { + if (typeof getScreenConnections !== 'function') { + return []; + } + + try { + const response = await getScreenConnections(screenSlug); + return Array.isArray(response && response.connections) ? response.connections : []; + } catch (_error) { + return []; + } + } + + async function fetchAllScreenTargets() { + const [rows] = await pool.query( + `SELECT slug + FROM d_screens + WHERE slug IS NOT NULL + ORDER BY slug ASC` + ); + + return (rows || []) + .map(function (row) { + return { + slug: String(row && row.slug ? row.slug : '').trim() + }; + }) + .filter(function (row) { + return Boolean(row.slug); + }); + } + + async function resolvePlayerBaseUrlsForConnections(connections) { + const connectionList = Array.isArray(connections) ? connections : []; + const seen = new Set(); + const registrations = await fetchPlayerRegistrations(); + + return connectionList.map(function (connection) { + const selectedPublicBaseUrl = normalizeConnectionBaseUrl(connection); + if (!selectedPublicBaseUrl || seen.has(selectedPublicBaseUrl)) { + return ''; + } + seen.add(selectedPublicBaseUrl); + const matchedPlayer = registrations.find(function (player) { + return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === selectedPublicBaseUrl; + }) || null; + return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || selectedPublicBaseUrl; + }).filter(Boolean); + } + + async function resolvePlayerBaseUrlForExplicitPublicBaseUrl(playerBaseUrl) { + const normalizedPublicBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl); + if (!normalizedPublicBaseUrl) { + return ''; + } + + const registrations = await fetchPlayerRegistrations(); + const matchedPlayer = registrations.find(function (player) { + return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === normalizedPublicBaseUrl; + }) || null; + + return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || normalizedPublicBaseUrl; + } + + async function forwardPlayerCommandForConnections(screenSlug, connections, commandPayload, connectionId, deviceId) { + const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections); + if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') { + const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) { + return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined); + })); + + return { + ok: true, + sent: results.filter(function (result) { + return result.status === 'fulfilled'; + }).length, + results: results + }; + } + + return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined); + } + + async function forwardPlayerCommandForPlayerBaseUrl(screenSlug, playerBaseUrl, commandPayload, connectionId, deviceId) { + const targetBaseUrl = await resolvePlayerBaseUrlForExplicitPublicBaseUrl(playerBaseUrl); + if (targetBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') { + return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined); + } + + return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined); } function isRecentPlayerRegistration(player, staleSeconds) { @@ -56,32 +138,13 @@ module.exports = function registerScreenCommandRoutes(app, deps) { return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; } - async function resolveAllPlayerBaseUrls() { - if (!common || typeof common.fetchPlayerRegistrations !== 'function') { - return []; - } - - try { - const players = await common.fetchPlayerRegistrations(pool); - return Array.from(new Set((Array.isArray(players) ? players : []) - .filter(function (player) { - return isRecentPlayerRegistration(player, 60); - }) - .map(function (player) { - return String(player && player.public_base_url || '').trim().replace(/\/$/, ''); - }) - .filter(Boolean))); - } catch (_error) { - return []; - } - } app.post('/clients/:slug/commands', requirePermission('clients.allow'), 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 explicitPlayerBaseUrl = normalizeExplicitPlayerBaseUrl((req.body && (req.body.playerBaseUrl || req.body.playerPublicBaseUrl)) || req.query.playerBaseUrl || req.query.playerPublicBaseUrl || ''); + const playerBaseUrl = String((req.body && req.body.playerBaseUrl) || req.query.playerBaseUrl || '').trim(); const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') ? req.body.blackout : req.query.blackout; @@ -93,14 +156,25 @@ module.exports = function registerScreenCommandRoutes(app, deps) { return res.status(400).json({ error: 'Command is required' }); } - if (slug === ALL_SCREENS_SLUG) { - if (command === 'setclientname' || command === 'moveclient') { + if (slug === '__all__') { + if (command !== 'reload' && command !== 'pause' && command !== 'blackout') { return res.status(400).json({ error: 'This command requires a specific screen.' }); } - const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC'); - if (!screenRows.length) { - return res.status(404).json({ error: 'No screens found' }); + const targets = await fetchAllScreenTargets(); + if (!targets.length) { + if (typeof broadcastDashboardState === 'function') { + await broadcastDashboardState(); + } + + return res.json({ + ok: true, + allScreens: true, + command: command, + targetScreenCount: 0, + targetPlayerCount: 0, + sent: 0 + }); } const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) @@ -109,36 +183,41 @@ module.exports = function registerScreenCommandRoutes(app, deps) { if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') { commandPayload.blackout = blackoutValue; } + if (command === 'pause' && req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused') && commandPayload && typeof commandPayload === 'object') { + commandPayload.paused = req.body.paused; + } - await Promise.all(screenRows.map(function (screenRow) { - const screenSlug = String(screenRow && screenRow.slug || '').trim(); - return resolveScreenPlayerBaseUrls(screenSlug, connectionId).then(function (playerBaseUrls) { - if (playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') { - return Promise.all(playerBaseUrls.map(function (playerBaseUrl) { - return forwardPlayerCommandToBaseUrl(playerBaseUrl, screenSlug, commandPayload, connectionId || undefined); - })); - } + const results = await Promise.allSettled(targets.map(async function (target) { + const liveConnections = await fetchScreenConnections(target.slug); + if (liveConnections.length) { + return forwardPlayerCommandForConnections(target.slug, liveConnections, commandPayload); + } - return forwardPlayerCommand(screenSlug, commandPayload); - }); + return forwardPlayerCommand(target.slug, commandPayload); })); + const sentCount = results.reduce(function (total, result) { + if (result.status !== 'fulfilled') { + return total; + } + + const sentValue = Number(result.value && typeof result.value.sent === 'number' ? result.value.sent : 1); + return total + (Number.isFinite(sentValue) && sentValue > 0 ? sentValue : 1); + }, 0); + if (typeof broadcastDashboardState === 'function') { await broadcastDashboardState(); } return res.json({ - screen: { - id: null, - name: 'All screens', - slug: ALL_SCREENS_SLUG - }, - screenSlug: slug, - command: command, - connectionId: connectionId || null, - targetScreenCount: screenRows.length, ok: true, - allScreens: true + allScreens: true, + command: command, + targetScreenCount: targets.length, + targetPlayerCount: sentCount, + sent: sentCount, + blackout: command === 'blackout' ? Boolean(commandPayload.blackout) : undefined, + paused: command === 'pause' ? Boolean(commandPayload.paused) : undefined }); } @@ -147,28 +226,6 @@ module.exports = function registerScreenCommandRoutes(app, deps) { return res.status(404).json({ error: 'Screen not found' }); } - if (explicitPlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function' && command !== 'moveclient') { - const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) - ? Object.assign({}, req.body, { command: command }) - : { command: command }; - if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') { - commandPayload.blackout = blackoutValue; - } - - const result = await forwardPlayerCommandToBaseUrl(explicitPlayerBaseUrl, slug, commandPayload, connectionId || undefined); - - if (typeof broadcastDashboardState === 'function') { - await broadcastDashboardState(); - } - - return res.json(Object.assign({ - screen: screenRows[0], - screenSlug: slug, - command: command, - connectionId: connectionId || null - }, result && typeof result === 'object' ? result : {})); - } - if (command === 'setclientname') { const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim(); const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim(); @@ -231,12 +288,21 @@ module.exports = function registerScreenCommandRoutes(app, deps) { } if (!onboardingRow) { - await forwardPlayerCommand(slug, { - command: command, - clientName: clientName, - clientId: connectionId || deviceId || null, - deviceId: deviceId || null - }, connectionId || deviceId || undefined); + if (playerBaseUrl) { + await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, { + command: command, + clientName: clientName, + clientId: connectionId || deviceId || null, + deviceId: deviceId || null + }, connectionId || deviceId || undefined, deviceId || null); + } else { + await forwardPlayerCommandForConnections(slug, liveConnections, { + command: command, + clientName: clientName, + clientId: connectionId || deviceId || null, + deviceId: deviceId || null + }, connectionId || deviceId || undefined, deviceId || null); + } if (typeof broadcastDashboardState === 'function') { await broadcastDashboardState(); @@ -323,52 +389,35 @@ module.exports = function registerScreenCommandRoutes(app, deps) { return res.status(500).json({ error: 'Client binding is unavailable.' }); } - let liveConnections = []; - try { - const [screenSlugs] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC'); - const liveResults = await Promise.all((screenSlugs || []).map(async function (row) { - const screenSlug = String(row && row.slug ? row.slug : '').trim(); - if (!screenSlug || typeof getScreenConnections !== 'function') { - return []; - } - try { - const liveResponse = await getScreenConnections(screenSlug); - return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : []; - } catch (_error) { - return []; - } - })); - liveConnections = liveResults.flat(); - } catch (_error) { - liveConnections = []; - } + const liveConnections = await fetchScreenConnections(slug); const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections); + let targetPlayerUrl = ''; const liveConnection = Array.isArray(liveConnections) ? liveConnections.find(function (connection) { const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim(); const candidateDeviceId = String(connection && connection.deviceId || '').trim(); return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId; - }) + }) || liveConnections[0] || null : null; - const sourcePlayerBaseUrl = String( - explicitPlayerBaseUrl || - (liveConnection && liveConnection.playerPublicBaseUrl) || - (typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') || - '' - ).trim().replace(/\/$/, ''); - const targetPlayerUrl = sourcePlayerBaseUrl ? `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`; + const sourcePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || ''); + if (sourcePlayerBaseUrl) { + targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`; + } + if (!targetPlayerUrl) { + targetPlayerUrl = `/screen/${encodeURIComponent(targetScreenSlug)}`; + } - if (sourcePlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') { - await forwardPlayerCommandToBaseUrl(sourcePlayerBaseUrl, slug, { + if (playerBaseUrl) { + await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, { command: 'redirect', url: targetPlayerUrl - }, connectionId || deviceId || undefined); + }, connectionId || deviceId || undefined, deviceId || null); } else { - await forwardPlayerCommand(slug, { + await forwardPlayerCommandForConnections(slug, liveConnections, { command: 'redirect', url: targetPlayerUrl - }, connectionId || deviceId || undefined); + }, connectionId || deviceId || undefined, deviceId || null); } if (typeof broadcastDashboardState === 'function') { @@ -395,16 +444,10 @@ module.exports = function registerScreenCommandRoutes(app, deps) { commandPayload.blackout = blackoutValue; } - const playerBaseUrls = await resolveScreenPlayerBaseUrls(slug, connectionId); - const result = playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function' - ? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { - return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandPayload, connectionId); - })).then(function (results) { - return Array.isArray(results) && results.length ? results[0] : { ok: true }; - }) - : (connectionId - ? await forwardPlayerCommand(slug, commandPayload, connectionId) - : await forwardPlayerCommand(slug, commandPayload)); + const liveConnections = await fetchScreenConnections(slug); + const result = playerBaseUrl + ? await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, commandPayload, connectionId, null) + : await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null); if (typeof broadcastDashboardState === 'function') { await broadcastDashboardState(); diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js index 500b267..b11eb53 100644 --- a/src/web/routes/admin/manage.js +++ b/src/web/routes/admin/manage.js @@ -11,6 +11,7 @@ module.exports = function registerManageRoutes(app, deps) { const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage; const getScreenConnections = deps.getScreenConnections; const forwardPlayerCommand = deps.forwardPlayerCommand; + const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl; const requirePermission = deps.requirePermission; const SCREEN_NAME_MAX_LENGTH = 255; @@ -25,13 +26,106 @@ module.exports = function registerManageRoutes(app, deps) { return text.slice(0, limit); } - async function fetchAllScreenSlugs() { - const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC'); + function normalizeBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); + } + + function normalizeExplicitPlayerBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); + } + + async function fetchPlayerRegistrations() { + if (typeof common.fetchPlayerRegistrations !== 'function') { + return []; + } + + try { + const registrations = await common.fetchPlayerRegistrations(pool); + return Array.isArray(registrations) ? registrations : []; + } catch (_error) { + return []; + } + } + + 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; + } + + async function fetchLiveConnectionsForScreen(slug) { + if (typeof getScreenConnections !== 'function') { + return []; + } + + try { + const response = await getScreenConnections(slug); + return Array.isArray(response && response.connections) ? response.connections : []; + } catch (_error) { + return []; + } + } + + async function resolvePlayerBaseUrlsForConnections(connections) { + const connectionList = Array.isArray(connections) ? connections : []; + const seen = new Set(); + const registrations = await fetchPlayerRegistrations(); + + return connectionList.map(function (connection) { + const selectedPublicBaseUrl = normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl); + if (!selectedPublicBaseUrl || seen.has(selectedPublicBaseUrl)) { + return ''; + } + + seen.add(selectedPublicBaseUrl); + const matchedPlayer = registrations.find(function (player) { + return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === selectedPublicBaseUrl; + }) || null; + + return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || selectedPublicBaseUrl; + }).filter(Boolean); + } + + async function forwardPlayerCommandForConnections(slug, connections, commandPayload) { + const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections); + if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') { + const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) { + return forwardPlayerCommandToBaseUrl(targetBaseUrl, slug, commandPayload); + })); + + return { + ok: true, + sent: results.filter(function (result) { + return result.status === 'fulfilled'; + }).length, + results: results + }; + } + + return forwardPlayerCommand(slug, commandPayload); + } + + async function fetchAllScreenTargets() { + const [rows] = await pool.query( + `SELECT s.slug + FROM d_screens s + WHERE s.slug IS NOT NULL + ORDER BY s.slug ASC` + ); return (rows || []) .map(function (row) { - return String(row && row.slug ? row.slug : '').trim(); + return { + slug: String(row && row.slug ? row.slug : '').trim(), + playerId: '', + publicBaseUrl: '', + internalBaseUrl: '' + }; }) - .filter(Boolean); + .filter(function (row) { + return Boolean(row.slug); + }); } app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) { @@ -52,8 +146,8 @@ module.exports = function registerManageRoutes(app, deps) { return res.status(400).json({ error: 'Unsupported command' }); } - const slugs = await fetchAllScreenSlugs(); - if (!slugs.length) { + const targets = await fetchAllScreenTargets(); + if (!targets.length) { await broadcastDashboardState(); return res.json({ ok: true, command: command, sent: 0 }); } @@ -70,7 +164,22 @@ module.exports = function registerManageRoutes(app, deps) { } : 'reload'; - const sentCount = await notifyPlayerScreens(slugs, payload); + const results = await Promise.allSettled(targets.map(async function (target) { + const liveConnections = await fetchLiveConnectionsForScreen(target.slug); + if (liveConnections.length) { + return forwardPlayerCommandForConnections(target.slug, liveConnections, payload); + } + + return forwardPlayerCommand(target.slug, payload); + })); + const sentCount = results.reduce(function (total, result) { + if (result.status !== 'fulfilled') { + return total; + } + + const sentValue = Number(result.value && typeof result.value.sent === 'number' ? result.value.sent : 1); + return total + (Number.isFinite(sentValue) && sentValue > 0 ? sentValue : 1); + }, 0); await broadcastDashboardState(); return res.json({ @@ -149,13 +258,28 @@ module.exports = function registerManageRoutes(app, deps) { await notifyPlayerScreens([previousSlug], 'refresh'); } if (previousSlug && previousSlug !== slug) { - const playerBaseUrl = typeof common.fetchPlayerPublicBaseUrl === 'function' - ? await common.fetchPlayerPublicBaseUrl(pool) - : ''; - await forwardPlayerCommand(previousSlug, { - command: 'redirect', - url: playerBaseUrl ? `${playerBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}` - }); + const previousScreenTargets = await pool.query( + `SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url + FROM d_screens s + LEFT JOIN d_players p ON p.device_id = s.player_id + WHERE s.slug = ? + LIMIT 1`, + [previousSlug] + ); + const previousTargetRow = previousScreenTargets[0] && previousScreenTargets[0][0] || null; + const previousInternalBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.internal_base_url) || ''; + const previousPublicBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.public_base_url) || ''; + if (previousInternalBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') { + await forwardPlayerCommandToBaseUrl(previousInternalBaseUrl, previousSlug, { + command: 'redirect', + url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}` + }); + } else { + await forwardPlayerCommand(previousSlug, { + command: 'redirect', + url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}` + }); + } } redirectAfterSave(req, res, '/screens?edit=' + screen.id, { closeUrl: '/screens', diff --git a/src/web/routes/register.js b/src/web/routes/register.js index 6a39c3e..8d4a667 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -125,6 +125,8 @@ function registerSignageRoutes(app, deps) { getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage, getScreenConnections: deps.playerActionService.getScreenConnections, forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand, + forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl, + forwardPlayerCommandToDevice: deps.playerActionService.forwardPlayerCommandToDevice, requirePermission: deps.requirePermission }); diff --git a/src/web/views/signage/clients/list.hbs b/src/web/views/signage/clients/list.hbs index 11d213e..e88f1c2 100644 --- a/src/web/views/signage/clients/list.hbs +++ b/src/web/views/signage/clients/list.hbs @@ -40,16 +40,16 @@
-
+
-
+
-
+ @@ -138,7 +138,7 @@
- +
diff --git a/test/admin-client-commands.test.js b/test/admin-client-commands.test.js index 4ca7d5e..b1f134c 100644 --- a/test/admin-client-commands.test.js +++ b/test/admin-client-commands.test.js @@ -44,7 +44,18 @@ test('move client rebinding redirects the live player to the target screen', asy return [[]]; } }, - common: {}, + common: { + fetchPlayerRegistrations: async () => ([ + { + public_base_url: 'http://player-a.example', + internal_base_url: 'http://player-a.internal' + }, + { + public_base_url: 'http://player-b.example', + internal_base_url: 'http://player-b.internal' + } + ]) + }, forwardPlayerCommand(slug, payload, connectionId) { calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId }); return { ok: true }; @@ -67,6 +78,19 @@ test('move client rebinding redirects the live player to the target screen', asy }; } + if (slug === 'target-screen') { + return { + connections: [ + { + id: 'target-conn-1', + clientId: 'target-conn-1', + deviceId: 'target-device-1', + playerPublicBaseUrl: 'https://remote-target.example' + } + ] + }; + } + return { connections: [] }; }, isClientNameAvailable: async () => true, @@ -115,34 +139,78 @@ test('move client rebinding redirects the live player to the target screen', asy assert.equal(response.body.targetScreenSlug, 'target-screen'); assert.equal(response.body.playerUrl, 'http://remote-player.example/screen/target-screen'); assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true); - assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example' && entry.payload && entry.payload.command === 'redirect'), true); + assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen' && entry.payload && entry.payload.command === 'redirect'), false); + assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example'), true); }); test('screen control commands can target all screens', async () => { const calls = []; const { app, handlers } = createHandlers(); - registerScreenCommandRoutes(app, { + registerScreenCommandRoutes(app, { pool: { async query(sql) { calls.push({ kind: 'query', sql }); - if (String(sql || '').includes('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC')) { + if (String(sql || '').includes('FROM d_screens') && String(sql || '').includes('ORDER BY slug ASC')) { return [[ - { id: 1, name: 'Alpha', slug: 'alpha' }, - { id: 2, name: 'Beta', slug: 'beta' } + { slug: 'alpha' }, + { slug: 'beta' } ]]; } return [[]]; } }, - common: {}, + common: { + fetchPlayerRegistrations: async () => ([ + { + public_base_url: 'http://player-a.example', + internal_base_url: 'http://player-a.internal' + }, + { + public_base_url: 'http://player-b.example', + internal_base_url: 'http://player-b.internal' + } + ]) + }, forwardPlayerCommand(slug, payload) { calls.push({ kind: 'forwardPlayerCommand', slug, payload }); return { ok: true }; }, - getScreenConnections: async () => ({ connections: [] }), + forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) { + calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId }); + return { ok: true }; + }, + getScreenConnections: async (slug) => { + if (slug === 'alpha') { + return { + connections: [ + { + id: 'alpha-1', + clientId: 'alpha-1', + deviceId: 'alpha-device', + playerPublicBaseUrl: 'http://player-a.example' + } + ] + }; + } + + if (slug === 'beta') { + return { + connections: [ + { + id: 'beta-1', + clientId: 'beta-1', + deviceId: 'beta-device', + playerPublicBaseUrl: 'http://player-b.example' + } + ] + }; + } + + return { connections: [] }; + }, isClientNameAvailable: async () => true, withClientNameReservation: async (_pool, _name, callback) => callback(), broadcastDashboardState: async () => { @@ -184,11 +252,13 @@ test('screen control commands can target all screens', async () => { assert.equal(response.body.ok, true); assert.equal(response.body.allScreens, true); assert.equal(response.body.targetScreenCount, 2); - assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommand').map((entry) => entry.slug), ['alpha', 'beta']); + assert.equal(response.body.targetPlayerCount, 2); + assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl), ['http://player-a.internal', 'http://player-b.internal']); assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true); + assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false); }); -test('screen control commands use the live connection player url when available', async () => { +test('screen control commands use the bridge for live connections', async () => { const calls = []; const { app, handlers } = createHandlers(); @@ -197,6 +267,17 @@ test('screen control commands use the live connection player url when available' async query(sql, params) { calls.push({ kind: 'query', sql, params }); + if (String(sql || '').includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) { + return [[ + { + slug: 'source-screen', + player_id: 'player-a', + public_base_url: 'http://player-a.example', + internal_base_url: 'http://player-a.internal' + } + ]]; + } + if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-screen') { return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]]; } @@ -204,15 +285,22 @@ test('screen control commands use the live connection player url when available' return [[]]; } }, - common: {}, + common: { + fetchPlayerRegistrations: async () => ([ + { + public_base_url: 'http://player-a.example', + internal_base_url: 'http://player-a.internal' + }, + { + public_base_url: 'http://player-b.example', + internal_base_url: 'http://player-b.internal' + } + ]) + }, forwardPlayerCommand(slug, payload, connectionId) { calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId }); return { ok: true }; }, - forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) { - calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId }); - return { ok: true }; - }, getScreenConnections: async (slug) => { if (slug === 'source-screen') { return { @@ -268,11 +356,11 @@ test('screen control commands use the live connection player url when available' assert.equal(response.statusCode, 200); assert.equal(response.body.ok, true); - assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://local-player.example'), true); - assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false); + assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen'), true); + assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://local-player.example'), false); }); -test('screen control commands fan out to every live player for the selected screen', async () => { +test('screen control commands fan out through the bridge for the selected screen', async () => { const calls = []; const { app, handlers } = createHandlers(); @@ -281,6 +369,15 @@ test('screen control commands fan out to every live player for the selected scre async query(sql, params) { calls.push({ kind: 'query', sql, params }); + if (String(sql || '').includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) { + return [[{ + slug: 'source-screen', + player_id: 'player-a', + public_base_url: 'http://player-a.example', + internal_base_url: 'http://player-a.internal' + }]]; + } + if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-screen') { return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]]; } @@ -288,7 +385,18 @@ test('screen control commands fan out to every live player for the selected scre return [[]]; } }, - common: {}, + common: { + fetchPlayerRegistrations: async () => ([ + { + public_base_url: 'http://player-a.example', + internal_base_url: 'http://player-a.internal' + }, + { + public_base_url: 'http://player-b.example', + internal_base_url: 'http://player-b.internal' + } + ]) + }, forwardPlayerCommand(slug, payload, connectionId) { calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId }); return { ok: true }; @@ -305,7 +413,7 @@ test('screen control commands fan out to every live player for the selected scre id: 'conn-1', clientId: 'conn-1', deviceId: 'device-123', - playerPublicBaseUrl: 'http://local-player-a.example' + playerPublicBaseUrl: 'http://local-player.example' }, { id: 'conn-2', @@ -357,14 +465,14 @@ test('screen control commands fan out to every live player for the selected scre assert.equal(response.statusCode, 200); assert.equal(response.body.ok, true); + assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen'), false); assert.deepEqual( calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl), - ['http://local-player-a.example', 'http://local-player-b.example'] + ['http://local-player.example', 'http://local-player-b.example'] ); - assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false); }); -test('all screens commands fan out across the live player for each screen', async () => { +test('all screens commands fan out through the bridge for each screen', async () => { const calls = []; const { app, handlers } = createHandlers(); @@ -373,23 +481,34 @@ test('all screens commands fan out across the live player for each screen', asyn async query(sql) { calls.push({ kind: 'query', sql }); - if (String(sql || '').includes('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC')) { + if (String(sql || '').includes('FROM d_screens') && String(sql || '').includes('ORDER BY slug ASC')) { return [[ - { id: 1, name: 'Alpha', slug: 'alpha' }, - { id: 2, name: 'Beta', slug: 'beta' } + { slug: 'alpha' }, + { slug: 'beta' } ]]; } return [[]]; } }, - common: {}, + common: { + fetchPlayerRegistrations: async () => ([ + { + public_base_url: 'http://player-a.example', + internal_base_url: 'http://player-a.internal' + }, + { + public_base_url: 'http://player-b.example', + internal_base_url: 'http://player-b.internal' + } + ]) + }, forwardPlayerCommand(slug, payload) { calls.push({ kind: 'forwardPlayerCommand', slug, payload }); return { ok: true }; }, - forwardPlayerCommandToBaseUrl(baseUrl, slug, payload) { - calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload }); + forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) { + calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId }); return { ok: true }; }, getScreenConnections: async (slug) => { @@ -461,10 +580,11 @@ test('all screens commands fan out across the live player for each screen', asyn assert.equal(response.body.ok, true); assert.equal(response.body.allScreens, true); assert.equal(response.body.targetScreenCount, 2); + assert.equal(response.body.targetPlayerCount, 2); assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true); assert.deepEqual( calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl), - ['http://player-a.example', 'http://player-b.example'] + ['http://player-a.internal', 'http://player-b.internal'] ); assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false); }); diff --git a/test/background-tasks-font-sweep.test.js b/test/background-tasks-font-sweep.test.js new file mode 100644 index 0000000..9c19ce3 --- /dev/null +++ b/test/background-tasks-font-sweep.test.js @@ -0,0 +1,40 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { registerFontSweepTask } = require('../src/web/lib/background-tasks/tasks-scheduled/font-sweep'); + +test('font sweep registers a generic cleanup recurring task without player metadata', async () => { + let registeredTask = null; + const calls = []; + + registerFontSweepTask({ + backgroundTaskQueue: { + registerRecurringTask(task) { + registeredTask = task; + } + }, + mediaDir: '/tmp/media', + uploadSyncService: { + async pushUploadFileToPlayer(uploadPath, mediaDir) { + calls.push(['put', uploadPath, mediaDir]); + }, + async removeUploadFileFromPlayer(uploadPath, mediaDir) { + calls.push(['delete', uploadPath, mediaDir]); + } + } + }); + + assert.ok(registeredTask); + assert.equal(registeredTask.key, 'font-sweep'); + assert.equal(registeredTask.title, 'Font sweep'); + assert.equal(registeredTask.category, 'cleanup'); + assert.equal(registeredTask.intervalMs, 24 * 60 * 60 * 1000); + assert.deepEqual(registeredTask.metadata, { mediaDir: '/tmp/media' }); + + await registeredTask.run(); + + assert.deepEqual(calls, [ + ['put', '/media/fonts/fonts.json', '/tmp/media'], + ['put', '/media/fonts/fonts.css', '/tmp/media'] + ]); +}); \ No newline at end of file diff --git a/test/background-tasks-onboarding-prune.test.js b/test/background-tasks-onboarding-prune.test.js new file mode 100644 index 0000000..bbbb014 --- /dev/null +++ b/test/background-tasks-onboarding-prune.test.js @@ -0,0 +1,33 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { registerOnboardingDevicePruneTask } = require('../src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune'); + +test('onboarding device prune registers an hourly recurring cleanup job', async () => { + let registeredTask = null; + const calls = []; + + registerOnboardingDevicePruneTask({ + backgroundTaskQueue: { + registerRecurringTask(task) { + registeredTask = task; + } + }, + pool: {}, + common: { + async pruneStaleOnboardingDevices(pool) { + calls.push(pool); + } + } + }); + + assert.ok(registeredTask); + assert.equal(registeredTask.key, 'onboarding-device-prune'); + assert.equal(registeredTask.title, 'Onboarding device prune'); + assert.equal(registeredTask.category, 'cleanup'); + assert.equal(registeredTask.intervalMs, 60 * 60 * 1000); + + await registeredTask.run(); + + assert.deepEqual(calls, [{}]); +}); \ No newline at end of file diff --git a/test/client-commands-route.test.js b/test/client-commands-route.test.js new file mode 100644 index 0000000..ad06d4b --- /dev/null +++ b/test/client-commands-route.test.js @@ -0,0 +1,197 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +require('../src/common'); + +const registerScreenCommandRoutes = require('../src/web/routes/admin/client-commands'); + +function createAppHarness() { + const handlers = {}; + const app = { + post(path, ...routeHandlers) { + handlers[path] = routeHandlers; + } + }; + + return { app, handlers }; +} + +test('client command route forwards all screens commands', async () => { + const { app, handlers } = createAppHarness(); + const calls = []; + registerScreenCommandRoutes(app, { + pool: { + async query(sql) { + calls.push({ kind: 'query', sql }); + + if (String(sql).includes('FROM d_screens') && String(sql).includes('ORDER BY slug ASC')) { + return [[{ slug: 'alpha' }, { slug: 'beta' }]]; + } + + return [[[]]]; + } + }, + common: { + async fetchPlayerRegistrations() { + return [ + { + public_base_url: 'http://player-a.example', + internal_base_url: 'http://player-a.internal' + }, + { + public_base_url: 'http://player-b.example', + internal_base_url: 'http://player-b.internal' + } + ]; + } + }, + forwardPlayerCommand(screenSlug, commandPayload) { + calls.push({ kind: 'forwardPlayerCommand', screenSlug, commandPayload }); + return { ok: true }; + }, + forwardPlayerCommandToBaseUrl(baseUrl, screenSlug, commandPayload) { + calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, screenSlug, commandPayload }); + return { ok: true }; + }, + getScreenConnections: async (slug) => { + if (slug === 'alpha') { + return { connections: [{ playerPublicBaseUrl: 'http://player-a.example' }] }; + } + if (slug === 'beta') { + return { connections: [{ playerPublicBaseUrl: 'http://player-b.example' }] }; + } + return { connections: [] }; + }, + isClientNameAvailable() { + return true; + }, + withClientNameReservation() { + throw new Error('withClientNameReservation should not be called for __all__'); + }, + broadcastDashboardState() { + calls.push({ kind: 'broadcastDashboardState' }); + }, + requirePermission() { + return function (_req, _res, next) { + next(); + }; + } + }); + + const routeHandlers = handlers['/clients/:slug/commands']; + assert.equal(Array.isArray(routeHandlers), true); + + const response = { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + return this; + } + }; + + await routeHandlers[1]({ + params: { slug: '__all__' }, + body: { command: 'reload' }, + query: {}, + currentUser: { id: 1 } + }, response, () => {}); + + assert.equal(response.statusCode, 200); + assert.equal(response.body.ok, true); + assert.equal(response.body.allScreens, true); + assert.equal(response.body.targetScreenCount, 2); + assert.equal(response.body.sent, 2); + assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl), ['http://player-a.internal', 'http://player-b.internal']); + assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true); +}); + +test('client command route forwards a single screen command', async () => { + const { app, handlers } = createAppHarness(); + const calls = []; + + registerScreenCommandRoutes(app, { + pool: { + async query(sql) { + if (String(sql).includes('SELECT id, name, slug FROM d_screens WHERE slug = ?')) { + return [[{ id: 7, name: 'Demo Lobby', slug: 'demo-lobby' }]]; + } + if (String(sql).includes('SELECT client_name')) { + return [[null]]; + } + if (String(sql).includes('SELECT slug FROM d_screens ORDER BY slug ASC')) { + return [[[]]]; + } + if (String(sql).includes('UPDATE d_onboarding_devices')) { + return [{ affectedRows: 0 }]; + } + if (String(sql).includes('FROM d_onboarding_devices d')) { + return [[[]]]; + } + return [[[]]]; + } + }, + common: { + async fetchPlayerRegistrations() { + return []; + } + }, + forwardPlayerCommand(screenSlug, commandPayload, connectionId) { + calls.push({ kind: 'forward', screenSlug, commandPayload, connectionId }); + return Promise.resolve({ ok: true, sent: 1 }); + }, + forwardPlayerCommandToBaseUrl() { + throw new Error('forwardPlayerCommandToBaseUrl should not be called for this path'); + }, + getScreenConnections() { + return Promise.resolve({ connections: [] }); + }, + isClientNameAvailable() { + return true; + }, + withClientNameReservation() { + throw new Error('withClientNameReservation should not be called for reload'); + }, + broadcastDashboardState() {}, + requirePermission() { + return function (_req, _res, next) { + next(); + }; + } + }); + + const routeHandlers = handlers['/clients/:slug/commands']; + assert.equal(Array.isArray(routeHandlers), true); + + const response = { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + return this; + } + }; + + await routeHandlers[1]({ + params: { slug: 'demo-lobby' }, + body: { command: 'reload' }, + query: {}, + currentUser: { id: 1 } + }, response, () => {}); + + assert.equal(response.statusCode, 200); + assert.equal(calls.length, 1); + assert.equal(calls[0].kind, 'forward'); + assert.equal(calls[0].screenSlug, 'demo-lobby'); + assert.deepEqual(calls[0].commandPayload, { command: 'reload' }); + assert.equal(calls[0].connectionId, undefined); + assert.equal(response.body.ok, true); +}); diff --git a/test/player-actions.test.js b/test/player-actions.test.js index 6363294..c0ce1e2 100644 --- a/test/player-actions.test.js +++ b/test/player-actions.test.js @@ -5,7 +5,7 @@ require('../src/common'); const { createPlayerActionService } = require('../src/web/lib/player-actions'); -test('player actions prefer the exact configured player registration over a remote FQDN row', async () => { +test('player actions use the configured bridge url for commands', async () => { const fetchCalls = []; const originalFetch = global.fetch; global.fetch = async function (url, init) { @@ -29,20 +29,7 @@ test('player actions prefer the exact configured player registration over a remo const playerActionService = createPlayerActionService({ common: {}, - pool: { - async query() { - return [[ - { - identifier: 'player-local', - internal_base_url: 'http://player:8081' - }, - { - identifier: 'player-remote', - internal_base_url: 'https://pulse-dev-bridge.lzstealth.com' - } - ]]; - } - } + playerInternalBaseUrl: 'http://player:8081' }); const originalIdentifier = process.env.PLAYER_IDENTIFIER; @@ -60,12 +47,57 @@ test('player actions prefer the exact configured player registration over a remo } }); -test('player actions merge screen connections from every recent player registration', async () => { +test('player actions read screen connections from the bridge base url', async () => { const fetchCalls = []; const originalFetch = global.fetch; global.fetch = async function (url, init) { fetchCalls.push({ url, init }); - if (String(url).includes('player-a.example')) { + return { + ok: true, + status: 200, + headers: { get() { return null; } }, + async json() { + return { + screenSlug: 'demo', + connections: [ + { id: 'bridge-1', playerPublicBaseUrl: 'http://bridge.example' } + ] + }; + }, + async text() { + return JSON.stringify({ ok: true }); + } + }; + }; + + const playerActionService = createPlayerActionService({ + common: {}, + pool: { + async query() { + return [[[]]]; + } + }, + playerInternalBaseUrl: 'http://bridge.example' + }); + + try { + const response = await playerActionService.getScreenConnections('demo'); + + assert.equal(response.count, 1); + assert.deepEqual(response.connections.map(function (connection) { return connection.id; }), ['bridge-1']); + assert.equal(fetchCalls.length, 1); + assert.equal(fetchCalls[0].url, 'http://bridge.example/api/screens/demo/connections'); + } finally { + global.fetch = originalFetch; + } +}); + +test('player actions merge bridge and player screen connections', async () => { + const fetchCalls = []; + const originalFetch = global.fetch; + global.fetch = async function (url, init) { + fetchCalls.push({ url, init }); + if (String(url).indexOf('player-bridge:8090') !== -1) { return { ok: true, status: 200, @@ -74,7 +106,7 @@ test('player actions merge screen connections from every recent player registrat return { screenSlug: 'demo', connections: [ - { id: 'a-1', playerPublicBaseUrl: 'http://player-a.example' } + { id: 'bridge-1', playerPublicBaseUrl: 'http://bridge.example' } ] }; }, @@ -92,7 +124,7 @@ test('player actions merge screen connections from every recent player registrat return { screenSlug: 'demo', connections: [ - { id: 'b-1', playerPublicBaseUrl: 'http://player-b.example' } + { id: 'player-1', playerPublicBaseUrl: 'http://player.example' } ] }; }, @@ -104,30 +136,54 @@ test('player actions merge screen connections from every recent player registrat const playerActionService = createPlayerActionService({ common: {}, - pool: { - async query() { - return [[ - { - identifier: 'player-a', - public_base_url: 'http://player-a.example', - last_seen_at: new Date().toISOString() - }, - { - identifier: 'player-b', - public_base_url: 'http://player-b.example', - last_seen_at: new Date().toISOString() - } - ]]; - } - } + playerInternalBaseUrl: 'http://player:8081', + bridgeInternalBaseUrl: 'http://player-bridge:8090' }); try { const response = await playerActionService.getScreenConnections('demo'); - assert.equal(response.count, 2); - assert.deepEqual(response.connections.map(function (connection) { return connection.id; }), ['a-1', 'b-1']); assert.equal(fetchCalls.length, 2); + assert.deepEqual(fetchCalls.map(function (call) { return call.url; }).sort(), [ + 'http://player-bridge:8090/api/screens/demo/connections', + 'http://player:8081/api/screens/demo/connections' + ]); + assert.equal(response.count, 2); + assert.deepEqual(response.connections.map(function (connection) { return connection.id; }).sort(), ['bridge-1', 'player-1']); + } finally { + global.fetch = originalFetch; + } +}); + +test('player actions send device commands through the bridge base url', async () => { + const fetchCalls = []; + const originalFetch = global.fetch; + global.fetch = async function (url, init) { + fetchCalls.push({ url, init }); + return { + ok: true, + status: 200, + headers: { get() { return null; } }, + async json() { + return { ok: true, sent: true }; + }, + async text() { + return JSON.stringify({ ok: true, sent: true }); + } + }; + }; + + const playerActionService = createPlayerActionService({ + common: {}, + bridgeInternalBaseUrl: 'http://player-bridge:8090' + }); + + try { + const response = await playerActionService.forwardPlayerCommandToDevice('device-123', { command: 'pause' }); + + assert.deepEqual(response, { ok: true, sent: true }); + assert.equal(fetchCalls.length, 1); + assert.equal(fetchCalls[0].url, 'http://player-bridge:8090/api/players/device-123/commands'); } finally { global.fetch = originalFetch; } diff --git a/test/player-bridge-web-base-url.test.js b/test/player-bridge-web-base-url.test.js index 14bbec3..6e3b41f 100644 --- a/test/player-bridge-web-base-url.test.js +++ b/test/player-bridge-web-base-url.test.js @@ -3,19 +3,19 @@ const assert = require('node:assert/strict'); require('../src/common'); -const originalWebBaseUrl = process.env.WEB_BASE_URL; -const { resolveWebBaseUrl, resolveScreenCommandTargets } = require('../src/player-bridge/index'); +const originalWebBaseUrl = process.env.WEB_INTERNAL_URL; +const { resolveWebBaseUrl, resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl } = require('../src/player-bridge/index'); test.after(() => { if (originalWebBaseUrl === undefined) { - delete process.env.WEB_BASE_URL; + delete process.env.WEB_INTERNAL_URL; } else { - process.env.WEB_BASE_URL = originalWebBaseUrl; + process.env.WEB_INTERNAL_URL = originalWebBaseUrl; } }); -test('resolveWebBaseUrl prefers WEB_BASE_URL', () => { - process.env.WEB_BASE_URL = 'https://web.example.test/app/'; +test('resolveWebBaseUrl prefers WEB_INTERNAL_URL', () => { + process.env.WEB_INTERNAL_URL = 'https://web.example.test/app/'; const resolved = resolveWebBaseUrl({ headers: { @@ -30,7 +30,7 @@ test('resolveWebBaseUrl prefers WEB_BASE_URL', () => { }); test('resolveWebBaseUrl keeps external https hosts on the default port', () => { - delete process.env.WEB_BASE_URL; + delete process.env.WEB_INTERNAL_URL; const resolved = resolveWebBaseUrl({ headers: { @@ -60,4 +60,31 @@ test('resolveScreenCommandTargets only returns open sockets for the requested sc assert.deepEqual(targets.map(function (target) { return target.deviceId; }), ['player-a']); +}); + +test('resolveScreenCommandTargets falls back to a single open socket when the screen map is empty', () => { + const playerSockets = new Map([ + ['player-a', { readyState: 1, playerDeviceId: 'player-a' }] + ]); + const screenPlayerDeviceIds = new Map(); + + const targets = resolveScreenCommandTargets('demo-conference', playerSockets, screenPlayerDeviceIds); + + assert.deepEqual(targets.map(function (target) { + return target.deviceId; + }), ['player-a']); +}); + +test('resolveSnapshotUpstreamBaseUrl prefers a local internal player url', () => { + assert.equal(resolveSnapshotUpstreamBaseUrl({ + public_base_url: 'https://pulse-dev-player.lzstealth.com', + internal_base_url: 'http://player-dev:8081' + }), 'http://player-dev:8081'); +}); + +test('resolveSnapshotUpstreamBaseUrl falls back to the public player url for remote players', () => { + assert.equal(resolveSnapshotUpstreamBaseUrl({ + public_base_url: 'https://remote-player.example', + internal_base_url: 'https://pulse-dev-bridge.lzstealth.com' + }), 'https://remote-player.example'); }); \ No newline at end of file diff --git a/test/player-onboarding-bridge.test.js b/test/player-onboarding-bridge.test.js index cb03bcc..9fe77e7 100644 --- a/test/player-onboarding-bridge.test.js +++ b/test/player-onboarding-bridge.test.js @@ -108,7 +108,7 @@ function registerThinClientRoutes(fetchImpl) { return 'form'; } }, - thinClientBaseUrl: 'http://bridge.test', + bridgeBaseUrl: 'http://bridge.test', playerPublicBaseUrl: 'http://public.test' }); diff --git a/test/player-preload-helpers.test.js b/test/player-preload-helpers.test.js index 13eb1ba..0889a3e 100644 --- a/test/player-preload-helpers.test.js +++ b/test/player-preload-helpers.test.js @@ -9,6 +9,15 @@ function loadScript(scriptPath, sandbox) { vm.runInNewContext(source, sandbox, { filename: scriptPath }); } +function loadHtmlScript(scriptPath, sandbox) { + const source = fs.readFileSync(scriptPath, 'utf8').match(/