From 2bd47fb96affc42f45e34e1de7cb233ae11dbd73 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Fri, 7 Aug 2026 22:23:46 +0100 Subject: [PATCH] Add player control-plane and dashboard updates --- CHANGELOG.md | 59 +- docker-compose/README.md | 220 +++++ docs/api.md | 17 + docs/schema.md | 5 +- src/player-bridge/index.js | 925 ++++++++++++++++++ src/player.js | 341 ++++++- .../player-onboarding-landing.script.html | 10 +- src/player/playlist.js | 7 +- src/player/render.js | 2 +- src/web/bootstrap.js | 47 +- src/web/lib/dashboard-state.js | 6 +- src/web/lib/media/upload-sync.js | 260 ++++- src/web/lib/player-actions.js | 181 +++- src/web/public/css/theme-custom.css | 5 +- src/web/public/js/dashboard/dashboard-page.js | 482 +++++++-- src/web/public/js/regions/type/time-date.js | 1 + src/web/public/js/table/table-search.js | 24 +- src/web/routes/admin/client-commands.js | 179 +++- src/web/routes/register.js | 19 + src/web/routes/signage/clients/routes.js | 31 +- .../signage/playlists/form-view-model.js | 7 +- src/web/routes/signage/screens/routes.js | 69 +- src/web/views/signage/clients/list.hbs | 10 +- src/web/views/signage/screens/form.hbs | 35 +- test/admin-client-commands.test.js | 470 +++++++++ test/clients-routes.test.js | 111 +++ test/dashboard-state.test.js | 126 +++ test/player-actions.test.js | 134 +++ test/player-bridge-web-base-url.test.js | 63 ++ test/player-playlist.test.js | 5 +- test/playlist-form-view-model.test.js | 29 + test/table-pagination-height.test.js | 133 +++ test/upload-sync.test.js | 229 +++++ test/web-bootstrap.test.js | 87 ++ test/web-dashboard-page.test.js | 730 +++++++++++++- test/web-screens-routes.test.js | 285 +++++- 36 files changed, 5081 insertions(+), 263 deletions(-) create mode 100644 docker-compose/README.md create mode 100644 src/player-bridge/index.js create mode 100644 test/admin-client-commands.test.js create mode 100644 test/clients-routes.test.js create mode 100644 test/dashboard-state.test.js create mode 100644 test/player-actions.test.js create mode 100644 test/player-bridge-web-base-url.test.js create mode 100644 test/table-pagination-height.test.js create mode 100644 test/web-bootstrap.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ea946..0c940e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,62 @@ All notable changes to this project will be documented in this file. +## 2.6.5 - 2026-08-07 + +### Added + +- Multiple players are now supported across the player registry, dashboard snapshots, and screen group management, so a deployment can track more than one connected player at a time. +- The player-agent can now run remotely, which lets a player connect through the bridge instead of requiring everything to stay on the same host. +- The Docker Compose setup now includes a public stack with a player bridge service and a separate remote player stack, so local and split-device deployments share the same documented layout. +- Dedicated local and remote compose manifests now live at `docker-compose/docker-compose.yml` and `docker-compose/docker-compose.remote.yml`, with matching example env files for the two deployment modes. +- Admin client commands now have a dedicated route and test coverage, which keeps dashboard actions aligned with the current player control-plane flow. + +### Changed + +- Screen group views now show each registered player's public base URL and computed player URL on the edit screen instead of assuming a single local player. +- The dashboard status indicator now reports the connected player count, which makes the live feed status more explicit when several players are online. +- Player command dispatch, playlist playback, and render preloading were tightened so the player keeps using the active web base URL and can warm assets before the slide is shown. +- Playlist video-duration selection now uses the longest matching video region when a slide contains more than one video region. +- Media sync, font sync, and upload sync now follow the newer player-agent workflow, keeping background tasks and dashboard refreshes consistent with split-device deployments. +- The onboarding landing page now shows a built-in QR placeholder and falls back to it when the QR request fails, so the screen never starts blank. +- Upload sync now resolves the player internal base URL once per batch and reuses it for queued upload and delete operations, keeping mirrored media writes pointed at the active player endpoint. +- The connected clients list now defaults to client name, then IP address, so rows stay in a stable order when connection details change. + +### Fixed + +- Connected clients search results now keep the row action column, because the row template resolves `currentUser` from the parent view context while rendering inside the `clients` loop. +- The move-client modal now carries the row's player base URL through to the redirect step, so clients move correctly even when local and bridge-backed players are mixed. +- Paginated table cards now only apply their minimum height when the table content would otherwise exceed that threshold, which keeps short result sets compact in smaller browser windows. + +## 2.6.4 - 2026-08-07 + +### Fixed + +- The screen table no longer stores a player foreign key, and screen/player URLs now resolve from the current player registration instead of a screen assignment. + +## 2.6.3 - 2026-08-07 + +### Fixed + +- The screen table no longer enforces a foreign key to the player table, which keeps player assignments flexible while preserving the existing screen schema. + +## 2.6.2 - 2026-08-07 + +### Fixed + +- The player registry now upgrades to an integer player id with a separate player identifier so the schema migration can complete cleanly on existing databases. +- Dashboard screen lookups now tolerate the upgraded player table layout during the rollout. + +## 2.6.1 - 2026-08-06 + +### Fixed + + +### Changed + +- The web side now resolves the player base URL from the database-backed player registration record instead of depending on a web-container environment fallback. +- Player registrations now store a separate friendly identifier, so a player can keep the same device key while showing a label like `Shop2`. + ## 2.5.14 - 2026-08-06 ### Added @@ -9,9 +65,6 @@ All notable changes to this project will be documented in this file. - Added branded Windows and Linux kiosk launcher downloads on the dashboard, with updated copy that explains the launcher behavior more clearly. - Kiosk launchers now start the browser in kiosk mode, suppress notifications for Chromium-based browsers, and use the correct Firefox kiosk flag. -### Changed - -- The web side now resolves the player base URL from the database-backed player registration record instead of depending on a web-container environment fallback. ## 2.5.13 - 2026-08-05 diff --git a/docker-compose/README.md b/docker-compose/README.md new file mode 100644 index 0000000..9a68ef3 --- /dev/null +++ b/docker-compose/README.md @@ -0,0 +1,220 @@ +# Docker Compose Setup + +This folder contains the Docker Compose definitions for Pulse Signage, including the public stack and the remote player stack. + +## Files + +- [docker-compose.yml](docker-compose.yml) - full public stack with web, player, player bridge, and MySQL. +- [.env.example](.env.example) - sample environment values for the public stack. +- [docker-compose.remote.yml](docker-compose.remote.yml) - remote player-only stack for machines that sit behind the player bridge. +- [.env.remote.example](.env.remote.example) - sample environment values for the remote stack. + +## Stack Overview + +### Public stack + +The main compose stack is the most complete setup. It runs: + +- `web` - the dashboard and admin app. +- `player` - the local screen player. +- `player-bridge` - the bridge service that proxies dashboard commands and player communication. +- `mysql` - the database used by the web, player, and bridge services. + +This is the stack to use when you want the full app running on one machine. + +### Remote player + +The remote stack runs only the `player` service. + +Use it when the player is installed on a remote device and connects back through the player bridge instead of running the full app separately. + +## Services + +### `web` + +The dashboard and admin application. + +Responsibilities: + +- serves the web UI on port `8080` +- reads and writes application data from MySQL +- forwards player actions through the configured bridge base URL +- renders connected clients, dashboard pages, and admin workflows + +Key configuration: + +- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` +- `PULSE_SIGNAGE_SHARED_SECRET` +- `SESSION_MAX_AGE_DAYS` +- `DEFAULT_ADMIN_USERNAME` +- `DEFAULT_ADMIN_NAME` +- `DEFAULT_ADMIN_PASSWORD` +- `PASSWORD_HASH_ITERATIONS` + +### `player` + +The screen runtime that renders playlists and receives commands. + +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` +- registers live connections and accepts control commands + +Key configuration: + +- `PLAYER_PUBLIC_BASE_URL` +- `PLAYER_INTERNAL_BASE_URL` +- `PLAYER_IDENTIFIER` +- `THIN_CLIENT_BASE_URL` in remote mode +- `PULSE_SIGNAGE_SHARED_SECRET` +- database settings in local mode + +### `player-bridge` + +The bridge layer that connects the dashboard to the player network. + +Responsibilities: + +- serves the bridge API on port `8090` +- forwards authenticated dashboard commands to registered players +- exposes screen connection snapshots and player registration data +- proxies command traffic between the web app and remote players + +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 +- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` + +### `mysql` + +The MySQL 8.4 database used by the public stack. + +Responsibilities: + +- stores application data, screen state, onboarding state, and registry records +- provides persistent storage through `mysql_data` + +Key configuration: + +- `MYSQL_DATABASE` +- `MYSQL_USER` +- `MYSQL_PASSWORD` +- `MYSQL_ROOT_PASSWORD` + +## Environment Files + +### `.env.example` + +Use this file as a starting point for the public compose stack. + +Important values: + +- `PULSE_SIGNAGE_IMAGE` - image to run for all app services +- `PULSE_SIGNAGE_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 +- `SESSION_MAX_AGE_DAYS` - dashboard session lifetime +- `DEFAULT_ADMIN_*` - bootstrap admin account values +- `PASSWORD_HASH_ITERATIONS` - password hashing cost + +### `.env.remote.example` + +Use this file on a remote player device. + +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_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent + +### `PULSE_SIGNAGE_SHARED_SECRET` + +This secret is the shared signing key for requests between the services. Use a single value for every service that needs to talk to the same stack, including the web app, player, bridge, and any remote player that connects back to that bridge. + +Recommended shape: + +- at least 32 random bytes +- ideally 64 hex characters, or another equally long cryptographically random string +- not a password, phrase, or anything human-readable + +If you want a quick local value, generate one with a password manager or a command such as `openssl rand -hex 32`. + +Leave it blank only if you intentionally want to run without request signing in a throwaway local setup. + +## Main Configuration Variables + +| Variable | Used By | Purpose | +| --- | --- | --- | +| `PULSE_SIGNAGE_IMAGE` | web, player, bridge, remote player | Docker image to run for the app services. | +| `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge, remote player | Shared secret for authenticated requests between services. | +| `DB_HOST` | web, player, bridge | Database host name. | +| `DB_PORT` | web, player, bridge | Database port. | +| `DB_NAME` | web, player, bridge, mysql | Database name. | +| `DB_USER` | web, player, bridge, mysql | Database user. | +| `DB_PASSWORD` | web, player, bridge, mysql | Database password. | +| `MYSQL_ROOT_PASSWORD` | mysql | Root password for the local MySQL container. | +| `SESSION_MAX_AGE_DAYS` | web | Session cookie lifetime. | +| `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. | +| `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_IDENTIFIER` | player | Stable player identifier. | +| `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. | + +## Ports + +Public stack ports: + +- `8080` - web dashboard +- `8081` - player +- `8090` - player bridge +- `3306` - MySQL + +Remote stack ports: + +- `8081` - player only + +## Volumes + +### Public stack + +- `mysql_data` - persistent MySQL data. +- `pulse-signage` - shared media and cache volume for the app services. + +### Remote stack + +- `pulse-signage` - shared media and cache volume for the remote player. + +## Networks + +Each compose file creates its own named network: + +- `pulse-signage` for the public stack +- `pulse-signage-remote` for remote player deployment. + +## Notes + +- 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 `PULSE_SIGNAGE_IMAGE` tag defaults to the published image, but it can be overridden for local builds or custom releases. + +## Recommended Setup + +1. Copy `.env.example` to a local `.env` file for the public stack. +2. Copy `.env.remote.example` to a device-specific `.env` file for the remote player. +3. Make sure `PULSE_SIGNAGE_SHARED_SECRET` matches everywhere. +4. Start the public stack first, then start the remote player after the bridge is reachable. +5. Verify that the player appears in Connected clients before testing screen commands. diff --git a/docs/api.md b/docs/api.md index 711899b..157ad07 100644 --- a/docs/api.md +++ b/docs/api.md @@ -78,6 +78,11 @@ Response fields: Returns the upload directory configured for the player service. Access: internal-only. +Response fields: + +- `mediaDir` +- `uploadDir` + ### `PUT /api/media/{filename}` Writes an uploaded file into the player upload directory. Access: internal-only and write-protected behind your deployment boundary. @@ -95,6 +100,14 @@ Query fields: - `source` required - `disableAudio` optional +Response fields: + +- `key` +- `playlistUrl` +- `disableAudio` +- `ready` +- `live` + ### `GET /api/rtmp/streams/{key}/index.m3u8` Returns the RTMP session HLS manifest. Access: internal-only. @@ -114,6 +127,7 @@ Response fields: - `slides` - `rssFeeds` - `apiSources` +- `timetableGroups` - `revision` ### `GET /api/screens/{slug}/connections` @@ -198,6 +212,7 @@ Response fields: `GET /api/media/config` returns an object with: +- `mediaDir` - `uploadDir` ### RTMP Session Response @@ -208,6 +223,7 @@ Response fields: - `playlistUrl` - `disableAudio` - `ready` +- `live` ### Onboarding Write Response @@ -230,6 +246,7 @@ Response fields: - `slides` - `rssFeeds` - `apiSources` +- `timetableGroups` - `revision` ### Connections Response diff --git a/docs/schema.md b/docs/schema.md index df18fa4..2f4f855 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -21,7 +21,8 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ### `a_roles` -- `id`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `id`, `role_key`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `role_key` is unique. - `name` is unique. ### `a_permissions` @@ -93,7 +94,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ### `c_playlist_slides` -- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `disable_audio`, `created_at`, `created_by`, `modified_at`, `modified_by` - Foreign keys: - `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE` - `slide_id` -> `c_slides.id` with `ON DELETE CASCADE` diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js new file mode 100644 index 0000000..8c1ce9c --- /dev/null +++ b/src/player-bridge/index.js @@ -0,0 +1,925 @@ +const express = require('express'); +const crypto = require('crypto'); +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +const { WebSocketServer, WebSocket } = require('ws'); +const common = require('../common'); +const { verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth'); +const { normalizeDeviceId, upsertPlayerRegistration, recordPlayerHeartbeat } = require('#src/data/player-registry'); +const { createPlayerPlaylistService } = require('../player/playlist'); +const { buildThumbnailPreviewData } = require('../player/thumbnail-preview'); +const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerPublicBaseUrl } = require('../player/onboarding'); +const { createStyledQrCodeSvg } = require('../data/qr-code'); +const { verifyPageAuthToken } = require('#src/request-auth'); + +function createThinClientConfig() { + return { + port: Number(process.env.THIN_CLIENT_PORT || 8090), + mediaDir: String(process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media')).trim() + }; +} + +function logBridge(message, details) { + if (details === undefined) { + console.info(`[player-bridge] ${message}`); + return; + } + + console.info(`[player-bridge] ${message}`, details); +} + +function normalizeRemoteAddress(value) { + const address = String(value || '').trim(); + if (!address) { + return ''; + } + + return address.toLowerCase().startsWith('::ffff:') ? address.slice(7) : address; +} + +function formatPlayerConnectionLabel(deviceId, remoteAddress) { + const normalizedDeviceId = String(deviceId || '').trim() || 'unknown-player'; + const normalizedRemoteAddress = normalizeRemoteAddress(remoteAddress); + return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : normalizedDeviceId; +} + +function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) { + const key = String(slug || '').trim(); + if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') { + return []; + } + + const deviceIds = screenPlayerDeviceIds.get(key); + if (!Array.isArray(deviceIds) || !deviceIds.length) { + return []; + } + + 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; + } + + return { deviceId: deviceId, socket: socket }; + }).filter(Boolean); +} + +function resolveWebBaseUrl(req) { + const configuredWebBaseUrl = String(process.env.WEB_BASE_URL || '').trim().replace(/\/$/, ''); + if (configuredWebBaseUrl) { + return configuredWebBaseUrl; + } + + const forwardedHost = String(req && req.headers && req.headers['x-forwarded-host'] || '').trim().split(',')[0]; + const host = forwardedHost || String(req && req.headers && req.headers.host || '').trim(); + if (!host) { + return null; + } + + const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0]; + const protocol = forwardedProto || (req && req.socket && req.socket.encrypted ? 'https' : 'http'); + + let url = null; + try { + url = new URL(`${protocol}://${host}`); + } catch (_error) { + return null; + } + + if (url.port === '8090') { + url.port = '8080'; + } else if (!url.port) { + const forwardedPort = String(req && req.headers && req.headers['x-forwarded-port'] || '').trim().split(',')[0]; + if (forwardedPort) { + url.port = forwardedPort === '8090' ? '8080' : forwardedPort; + } else if (protocol === 'http') { + url.port = '8080'; + } + } + + return url.toString().replace(/\/$/, ''); +} + +async function start() { + const app = express(); + const server = http.createServer(app); + const pool = common.createPool(); + const config = createThinClientConfig(); + const playerPlaylistService = createPlayerPlaylistService({ + pool: pool, + common: common, + snapshotDir: path.join(config.mediaDir, 'player-cache', 'screen-playlists') + }); + app.use(express.json()); + const playersWs = new WebSocketServer({ noServer: true }); + const screenSnapshotsWs = new WebSocketServer({ noServer: true }); + const playerSockets = new Map(); + const screenSnapshotCache = new Map(); + const screenPlayerDeviceIds = new Map(); + const pendingPlayerCommands = new Map(); + + 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; + } + } + + async function fetchPlayerSnapshotRegistrations() { + if (typeof common.fetchPlayerRegistrations === 'function') { + return common.fetchPlayerRegistrations(pool); + } + + const [rows] = await pool.query( + `SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, modified_at + FROM d_players + ORDER BY modified_at DESC, identifier ASC` + ); + return rows; + } + + function getConnectedPlayerSocket() { + for (const socket of playerSockets.values()) { + if (socket && socket.readyState === WebSocket.OPEN) { + return socket; + } + } + return null; + } + + function getConnectedPlayerCount() { + return Array.from(playerSockets.values()).filter(function (socket) { + return socket && socket.readyState === WebSocket.OPEN; + }).length; + } + + function getConnectedPlayerDeviceId() { + const socket = getConnectedPlayerSocket(); + return socket && socket.playerDeviceId ? String(socket.playerDeviceId).trim() : ''; + } + + function removeConnectedPlayerSocket(socket) { + if (!socket || !socket.playerDeviceId) { + return false; + } + + const current = playerSockets.get(socket.playerDeviceId); + if (current !== socket) { + return false; + } + + playerSockets.delete(socket.playerDeviceId); + return true; + } + + function logPlayerDisconnect(socket) { + if (!socket || !socket.playerDeviceId) { + return; + } + + logBridge(`Player ${formatPlayerConnectionLabel(socket.playerDeviceId, socket.bridgeRemoteAddress)} has disconnected`); + } + + function resolveMediaPath(fileName) { + const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, ''); + if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return null; + } + return relativePath; + } + + function sendPlayerCommand(commandPayload) { + const socket = getConnectedPlayerSocket(); + if (!socket) { + return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' }); + } + + return sendPlayerCommandToSocket(socket, commandPayload); + } + + function sendPlayerCommandToSocket(socket, commandPayload) { + if (!socket || socket.readyState !== WebSocket.OPEN) { + return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' }); + } + + const requestId = crypto.randomUUID(); + const payload = Object.assign({ + type: 'command', + requestId: requestId + }, commandPayload || {}); + + return new Promise(function (resolve) { + const timeout = setTimeout(function () { + pendingPlayerCommands.delete(requestId); + resolve({ ok: false, status: 504, error: 'Player command timed out.' }); + }, 10000); + + pendingPlayerCommands.set(requestId, { + resolve: function (message) { + clearTimeout(timeout); + pendingPlayerCommands.delete(requestId); + resolve(message); + } + }); + + try { + socket.send(JSON.stringify(payload)); + } catch (error) { + clearTimeout(timeout); + pendingPlayerCommands.delete(requestId); + resolve({ ok: false, status: 502, error: error && error.message ? error.message : 'Unable to send player command.' }); + } + }); + } + + function getScreenPlayerDeviceIds(slug) { + const key = String(slug || '').trim(); + const deviceIds = screenPlayerDeviceIds.get(key); + return Array.isArray(deviceIds) ? deviceIds.slice() : []; + } + + function storeScreenSnapshot(slug, connections, deviceIds) { + const key = String(slug || '').trim(); + const normalizedConnections = Array.isArray(connections) ? connections : []; + const normalizedDeviceIds = Array.isArray(deviceIds) ? deviceIds.map(function (value) { + return normalizeDeviceId(value); + }).filter(Boolean) : []; + + screenSnapshotCache.set(key, { + slug: key, + count: normalizedConnections.length, + connections: normalizedConnections + }); + screenPlayerDeviceIds.set(key, Array.from(new Set(normalizedDeviceIds))); + } + + app.get('/health', function (_req, res) { + res.json({ ok: true, service: 'player-bridge' }); + }); + + function requireRequestAuth(req, res, next) { + if (!verifyRequestAuth(req)) { + return res.status(401).json({ error: 'Request authentication required.' }); + } + + next(); + } + + app.get('/api/players', requireRequestAuth, async function (_req, res, next) { + try { + const [rows] = await pool.query( + `SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, created_at, modified_at + FROM d_players + ORDER BY modified_at DESC, identifier ASC` + ); + res.json({ players: rows }); + } catch (error) { + next(error); + } + }); + + app.get('/api/players/connected-count', requireRequestAuth, function (_req, res) { + res.json({ connectedPlayersCount: getConnectedPlayerCount() }); + }); + + app.get('/api/screens/:slug/connections', requireRequestAuth, async function (req, res, next) { + try { + const slug = String(req.params.slug || '').trim(); + if (!slug) { + return res.status(400).json({ error: 'Screen slug is required.' }); + } + + const snapshot = screenSnapshotCache.get(slug) || { slug: slug, count: 0, connections: [] }; + res.json({ + screenSlug: slug, + count: Number(snapshot.count || 0), + connections: Array.isArray(snapshot.connections) ? snapshot.connections : [] + }); + } catch (error) { + next(error); + } + }); + + function requirePageAuth(allowedScopes) { + return function (req, res, next) { + const token = String(req.headers['x-pulse-page-auth'] || '').trim(); + const payload = verifyPageAuthToken(token); + if (!payload) { + return res.status(401).json({ error: 'Page authentication required.' }); + } + + const scopes = Array.isArray(allowedScopes) ? allowedScopes : []; + if (scopes.length && scopes.indexOf(String(payload.scope || '').trim()) === -1) { + return res.status(403).json({ error: 'Page authentication scope is not allowed for this route.' }); + } + + req.playerPageAuth = payload; + next(); + }; + } + + app.get('/api/media/config', requireRequestAuth, function (_req, res) { + res.json({ + mediaDir: config.mediaDir, + uploadDir: path.join(config.mediaDir, 'uploads') + }); + }); + + app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) { + try { + const relativePath = resolveMediaPath(req.params.filename); + if (!relativePath) { + return res.status(400).json({ error: 'Filename is required' }); + } + + const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); + const response = await sendPlayerCommand({ + command: 'media-put', + relativePath: relativePath, + bodyBase64: bodyBuffer.toString('base64') + }); + + res.status(response.status || (response.ok ? 200 : 502)).json(response); + } catch (error) { + next(error); + } + }); + + app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) { + try { + const relativePath = resolveMediaPath(req.params.filename); + if (!relativePath) { + return res.status(400).json({ error: 'Filename is required' }); + } + + const response = await sendPlayerCommand({ + command: 'media-delete', + relativePath: relativePath + }); + + res.status(response.status || (response.ok ? 200 : 502)).json(response); + } catch (error) { + next(error); + } + }); + + app.post('/api/screens/:slug/commands', requireRequestAuth, async function (req, res, next) { + try { + const slug = String(req.params.slug || '').trim(); + const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase(); + const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim(); + const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') + ? req.body.blackout + : req.query.blackout; + + if (!slug) { + return res.status(400).json({ error: 'Screen slug is required.' }); + } + if (!command) { + return res.status(400).json({ error: 'Command is required.' }); + } + + const targetPlayers = resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds); + + if (!targetPlayers.length) { + return res.status(404).json({ error: 'Player is not connected.' }); + } + + const payload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) + ? Object.assign({}, req.body, { command: command, screenSlug: slug }) + : { command: command, screenSlug: slug }; + if (command === 'blackout' && blackoutValue !== undefined) { + payload.blackout = blackoutValue; + } + + const results = await Promise.all(targetPlayers.map(async function (target) { + const requestBody = Object.assign({}, payload, connectionId ? { connectionId: connectionId } : {}); + const response = await sendPlayerCommandToSocket(target.socket, requestBody); + + return Object.assign({ + ok: Boolean(response && response.ok), + status: response && response.status ? response.status : (response && response.ok ? 200 : 502), + playerIdentifier: String(target.deviceId || '').trim() + }, response && typeof response === 'object' ? response : {}); + })); + + res.json({ + ok: true, + screenSlug: slug, + command: command, + connectionId: connectionId || null, + sent: results.filter(function (result) { return result && result.ok; }).length, + results: results + }); + } catch (error) { + next(error); + } + }); + + app.get('/api/onboarding/status', async function (req, res, next) { + try { + const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId(); + const status = await getOnboardingStatus(pool, deviceId); + res.json({ + deviceId: normalizeDeviceId(deviceId), + onboarded: Boolean(status && status.screen_id), + clientName: status ? status.client_name : null, + screenId: status ? status.screen_id : null, + screenSlug: status ? status.screen_slug : null, + screenName: status ? status.screen_name : null, + playerUrl: status && status.screen_slug ? `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null + }); + } catch (error) { + next(error); + } + }); + + app.get('/api/onboarding/screens', requirePageAuth(['onboarding', 'player']), async function (_req, res, next) { + try { + const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC'); + res.json({ screens: rows }); + } catch (error) { + next(error); + } + }); + + app.get('/api/onboarding/qr', async function (req, res, next) { + try { + const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId(); + if (!deviceId) { + return res.status(400).json({ error: 'Device ID is required' }); + } + const onboardingUrl = `${getPlayerPublicBaseUrl(req)}/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'); + res.send(svg); + } catch (error) { + next(error); + } + }); + + app.post('/api/onboarding', requirePageAuth(['onboarding', 'player']), express.json(), async function (req, res, next) { + try { + const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || getConnectedPlayerDeviceId(); + const clientName = String((req.body && req.body.clientName) || '').trim(); + const screenSlug = String((req.body && req.body.screenSlug) || '').trim(); + if (!deviceId) { + return res.status(503).json({ error: 'Player is not connected.' }); + } + if (!clientName) { + return res.status(400).json({ error: 'Client name is required' }); + } + if (!screenSlug) { + return res.status(400).json({ error: 'Screen is required' }); + } + + const status = await commitDeviceBinding(pool, deviceId, clientName, screenSlug, null, []); + await bindPlayerToScreen(pool, deviceId, screenSlug); + res.json({ + deviceId: deviceId, + clientName: status ? status.client_name : clientName, + screenId: status && status.screen_id ? status.screen_id : null, + screenSlug: status ? status.screen_slug : screenSlug, + screenName: status && status.screen_name ? status.screen_name : null, + playerUrl: status && status.screen_slug ? `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`, + queued: Boolean(status && status.queued) + }); + } catch (error) { + const statusCode = Number(error && error.statusCode || error && error.status || 500); + res.status(Number.isFinite(statusCode) && statusCode >= 400 ? statusCode : 500).json({ + error: error && error.message ? error.message : 'Onboarding failed.' + }); + } + }); + + app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) { + try { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug); + if (!data.screen) { + return res.status(404).json({ error: 'Screen not found' }); + } + const etag = '"' + String(data.revision || '') + '"'; + res.set('ETag', etag); + if (String(req.headers['if-none-match'] || '').split(',').map(function (value) { + return String(value || '').trim(); + }).includes(etag)) { + return res.status(304).end(); + } + res.json(data); + } catch (error) { + next(error); + } + }); + + app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) { + try { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + const announcement = typeof common.fetchActiveAnnouncement === 'function' + ? await common.fetchActiveAnnouncement(pool, req.params.slug) + : null; + const revision = announcement + ? [announcement.id, announcement.modified_at || '', announcement.expires_at || '', announcement.enabled ? '1' : '0'].join(':') + : 'none'; + const etag = '"' + String(revision || 'none') + '"'; + res.set('ETag', etag); + if (String(req.headers['if-none-match'] || '').split(',').map(function (value) { + return String(value || '').trim(); + }).includes(etag)) { + return res.status(304).end(); + } + res.json({ + announcement: announcement, + revision: revision + }); + } catch (error) { + next(error); + } + }); + + app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) { + try { + const slide = await common.fetchSlideById(pool, Number(req.params.id)); + if (!slide) { + return res.status(404).send('Slide not found'); + } + + const data = buildThumbnailPreviewData(slide); + + if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') { + const rssData = await common.fetchRssFeedsData(pool); + data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) { + const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id); + return Object.assign({}, feed, { + items: items.map(function (item) { + return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item; + }) + }); + })); + } + + if (typeof common.fetchApiSourcesData === 'function') { + const apiData = await common.fetchApiSourcesData(pool); + data.apiSources = (apiData.apiSources || []).map(function (source) { + return Object.assign({}, source, { + responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null + }); + }); + } + + if (typeof common.fetchTimetablesData === 'function') { + const timetableData = await common.fetchTimetablesData(pool); + data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : []; + } + + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data)); + } catch (error) { + next(error); + } + }); + + app.post('/api/players/:deviceId/commands', requireRequestAuth, async function (req, res, next) { + try { + const deviceId = normalizeDeviceId(req.params.deviceId); + const socket = playerSockets.get(deviceId); + const payload = req.body && typeof req.body === 'object' ? req.body : {}; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return res.status(404).json({ ok: false, error: 'Player is not connected.' }); + } + + socket.send(JSON.stringify(payload)); + res.json({ ok: true, deviceId: deviceId, sent: true }); + } catch (error) { + next(error); + } + }); + + async function handlePlayerMessage(socket, rawMessage) { + let payload = null; + try { + payload = JSON.parse(String(rawMessage || '{}')); + } catch (_error) { + socket.send(JSON.stringify({ type: 'error', error: 'Invalid JSON payload.' })); + return; + } + + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + socket.send(JSON.stringify({ type: 'error', error: 'Invalid player payload.' })); + return; + } + + if (String(payload.type || '').trim() === 'command-response') { + const requestId = String(payload.requestId || '').trim(); + if (requestId && pendingPlayerCommands.has(requestId)) { + pendingPlayerCommands.get(requestId).resolve(payload); + } + return; + } + + const messageType = String(payload.type || '').trim().toLowerCase(); + const deviceId = normalizeDeviceId(payload.deviceId || payload.playerIdentifier || socket.playerDeviceId || ''); + + if (!deviceId) { + socket.send(JSON.stringify({ type: 'error', error: 'Device ID is required.' })); + return; + } + + socket.playerDeviceId = deviceId; + + if (messageType === 'register') { + const player = await upsertPlayerRegistration(pool, { + deviceId: deviceId, + publicBaseUrl: payload.publicBaseUrl, + internalBaseUrl: payload.internalBaseUrl + }); + + playerSockets.set(deviceId, socket); + logBridge(`Player ${formatPlayerConnectionLabel(deviceId, socket.bridgeRemoteAddress)} has connected`); + socket.send(JSON.stringify({ type: 'registered', ok: true, player: player })); + return; + } + + if (messageType === 'heartbeat') { + const player = await recordPlayerHeartbeat(pool, { + deviceId: deviceId, + publicBaseUrl: payload.publicBaseUrl, + internalBaseUrl: payload.internalBaseUrl + }); + + socket.send(JSON.stringify({ type: 'heartbeat-ack', ok: true, player: player })); + return; + } + + socket.send(JSON.stringify({ type: 'error', error: 'Unsupported player message type.' })); + } + + server.on('upgrade', function (request, socket, head) { + let pathname = ''; + try { + pathname = new URL(request.url, 'http://localhost').pathname; + } catch (_error) { + socket.destroy(); + return; + } + + if (pathname !== '/ws/players') { + const screenMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/); + if (!screenMatch) { + socket.destroy(); + return; + } + + if (!verifyRequestAuth(request)) { + socket.destroy(); + return; + } + + screenSnapshotsWs.handleUpgrade(request, socket, head, function (ws) { + screenSnapshotsWs.emit('connection', ws, request, decodeURIComponent(screenMatch[1]), 'screen-snapshots'); + }); + return; + } + + if (!verifyRequestAuth(request)) { + const remoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress); + logBridge(remoteAddress ? `Player (ip ${remoteAddress}) denied with wrong shared secret` : 'Player denied with wrong shared secret'); + socket.destroy(); + return; + } + + playersWs.handleUpgrade(request, socket, head, function (ws) { + ws.bridgeRemoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress); + playersWs.emit('connection', ws, request); + }); + }); + + playersWs.on('connection', function (socket) { + socket.send(JSON.stringify({ type: 'ready', channel: 'player-bridge' })); + + socket.on('message', function (message) { + handlePlayerMessage(socket, message).catch(function (error) { + console.error(error); + socket.send(JSON.stringify({ type: 'error', error: 'Player bridge request failed.' })); + }); + }); + + socket.on('close', function () { + if (removeConnectedPlayerSocket(socket)) { + logPlayerDisconnect(socket); + } + }); + + socket.on('error', function () { + if (removeConnectedPlayerSocket(socket)) { + logPlayerDisconnect(socket); + } + }); + }); + + 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() + })); + } + + function closeUpstreamSockets() { + upstreamSockets.forEach(function (upstreamSocket) { + try { + upstreamSocket.close(); + } catch (_error) { + } + }); + upstreamSockets.clear(); + upstreamSnapshots.clear(); + } + + async function refreshUpstreams() { + if (closed || socket.readyState !== WebSocket.OPEN || !normalizedSlug) { + return; + } + + let players = []; + try { + players = await fetchPlayerSnapshotRegistrations(); + } catch (_error) { + return; + } + + const seenKeys = new Set(); + players.forEach(function (player) { + const baseUrl = normalizeProxyBaseUrl(player && player.public_base_url); + if (!baseUrl) { + return; + } + + const sourceKey = String(player && player.identifier || player && player.id || baseUrl); + seenKeys.add(sourceKey); + upstreamDeviceIds.add(sourceKey); + if (upstreamSockets.has(sourceKey)) { + return; + } + + const upstreamUrl = new URL(baseUrl.replace(/^http/, 'ws')); + upstreamUrl.pathname = `/ws/screens/${encodeURIComponent(normalizedSlug)}/events`; + upstreamUrl.search = ''; + const upstreamHeaders = createRequestAuthHeaders({ + method: 'GET', + pathname: `/ws/screens/${encodeURIComponent(normalizedSlug)}/events` + }); + const upstreamSocket = new WebSocket(upstreamUrl.toString(), { headers: upstreamHeaders }); + upstreamSockets.set(sourceKey, upstreamSocket); + + upstreamSocket.onmessage = function (event) { + try { + const payload = JSON.parse(String(event.data || '{}')); + if (!payload || payload.type !== 'snapshot' || String(payload.slug || '').trim() !== normalizedSlug) { + return; + } + upstreamSnapshots.set(sourceKey, { + slug: normalizedSlug, + connections: Array.isArray(payload.connections) ? payload.connections : [] + }); + sendMergedSnapshot(); + } catch (_error) { + } + }; + + upstreamSocket.onclose = function () { + upstreamSockets.delete(sourceKey); + upstreamSnapshots.delete(sourceKey); + if (!closed) { + sendMergedSnapshot(); + } + }; + + upstreamSocket.onerror = function () { + try { + upstreamSocket.close(); + } catch (_error) { + } + }; + }); + + Array.from(upstreamSockets.keys()).forEach(function (sourceKey) { + if (!seenKeys.has(sourceKey)) { + const upstreamSocket = upstreamSockets.get(sourceKey); + upstreamSockets.delete(sourceKey); + upstreamSnapshots.delete(sourceKey); + upstreamDeviceIds.delete(sourceKey); + try { + upstreamSocket.close(); + } catch (_error) { + } + } + }); + + sendMergedSnapshot(); + } + + refreshUpstreams(); + refreshTimer = setInterval(function () { + refreshUpstreams().catch(function (_error) { + }); + }, 5000); + + socket.on('close', function () { + closed = true; + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + closeUpstreamSockets(); + }); + + socket.on('error', function () { + closed = true; + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + closeUpstreamSockets(); + }); + }); + + app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) { + try { + const webBaseUrl = resolveWebBaseUrl(_req); + if (!webBaseUrl) { + return res.status(502).json({ error: 'Web base URL is not configured.' }); + } + + const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, { + method: 'POST', + headers: Object.assign({ + Accept: 'application/json' + }, createRequestAuthHeaders({ + method: 'POST', + pathname: '/api/internal/sync/player-media' + })) + }); + + res.status(response.status); + const contentType = response.headers.get('content-type'); + if (contentType) { + res.type(contentType); + } + res.send(await response.text()); + } catch (error) { + next(error); + } + }); + + fs.mkdirSync(config.mediaDir, { recursive: true }); + + server.listen(config.port, function () { + console.log(`Player bridge listening on port ${config.port}`); + }); +} + +module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets }; + +if (require.main === module) { + start().catch(function (error) { + console.error(error); + process.exit(1); + }); +} diff --git a/src/player.js b/src/player.js index 448192c..8a3c67d 100644 --- a/src/player.js +++ b/src/player.js @@ -2,6 +2,7 @@ const express = require('express'); const fs = require('fs'); const http = require('http'); const path = require('path'); +const { WebSocket } = require('ws'); const common = require('./common'); const { createPlayerRuntime } = require('./player/runtime'); const { createPlayerPlaylistService } = require('./player/playlist'); @@ -10,16 +11,20 @@ const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } const { createOnboardingStore } = require('./player/onboarding/store'); const { registerPlayerRoutes } = require('./player/routes'); const { ensureFontLibrary } = require('#src/web/lib/media/font-library'); +const { createRequestAuthHeaders } = require('#src/request-auth'); +const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/data/player-registry'); // Player runtime, media API, and websocket wiring. async function start() { const app = express(); - const pool = common.createPool(); + const pool = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '') ? null : common.createPool(); const PORT = Number(process.env.PLAYER_PORT || 8081); const PLAYER_PUBLIC_BASE_URL = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); - const PLAYER_INTERNAL_BASE_URL = String(process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); - const PLAYER_IDENTIFIER = '1'; + const THIN_CLIENT_BASE_URL = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); + const isRemotePlayer = Boolean(THIN_CLIENT_BASE_URL); + const PLAYER_INTERNAL_BASE_URL = String(isRemotePlayer ? THIN_CLIENT_BASE_URL : (process.env.PLAYER_INTERNAL_BASE_URL || PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, ''); + const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier(); const ASSET_DIR = path.join(__dirname, 'player', 'public'); const MEDIA_DIR = path.join(__dirname, '..', 'media'); const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json'); @@ -29,24 +34,170 @@ async function start() { pool: pool, normalizeDeviceId: normalizeDeviceId }); - const playerPlaylistService = createPlayerPlaylistService({ - pool: pool, - common: common, - snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists') - }); + const playerPlaylistService = isRemotePlayer + ? null + : createPlayerPlaylistService({ + pool: pool, + common: common, + snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists') + }); const rtmpStreamService = createRtmpStreamService({ mediaDir: MEDIA_DIR }); const server = http.createServer(app); playerRuntime.installWebsocket(server); app.use(express.json()); + + let hasLoggedPlayerStartup = false; + + function logPlayerStartup(connectionState) { + if (hasLoggedPlayerStartup) { + return; + } + + hasLoggedPlayerStartup = true; + console.info('[player] startup', { + mode: isRemotePlayer ? 'bridge client' : 'local', + connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false, + publicBaseUrl: PLAYER_PUBLIC_BASE_URL || null, + bridgeBaseUrl: PLAYER_INTERNAL_BASE_URL || null, + bridgeWebSocketUrl: THIN_CLIENT_BASE_URL ? createThinClientWebSocketUrl() : null + }); + } + + fs.mkdirSync(MEDIA_DIR, { recursive: true }); + + function resolveLocalMediaFilePath(fileName) { + const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, ''); + if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return null; + } + + const resolvedMediaDir = path.resolve(MEDIA_DIR); + const resolvedFilePath = path.resolve(MEDIA_DIR, relativePath); + if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) { + return null; + } + + return resolvedFilePath; + } + + async function triggerWebMediaSync() { + if (!isRemotePlayer || !THIN_CLIENT_BASE_URL) { + return false; + } + + try { + const authHeaders = createRequestAuthHeaders({ + method: 'POST', + pathname: '/api/internal/sync/player-media' + }); + const response = await fetch(`${THIN_CLIENT_BASE_URL}/api/internal/sync/player-media`, { + method: 'POST', + headers: Object.assign({ + Accept: 'application/json' + }, authHeaders) + }); + return Boolean(response && response.ok); + } catch (_error) { + return false; + } + } + + let webMediaSyncCompleted = false; + + async function handleThinClientCommand(socket, rawMessage) { + let payload = null; + try { + payload = JSON.parse(String(rawMessage || '')); + } catch (_error) { + return; + } + + if (!payload || typeof payload !== 'object' || Array.isArray(payload) || String(payload.type || '').trim() !== 'command') { + return; + } + + const requestId = String(payload.requestId || '').trim() || null; + const command = String(payload.command || '').trim().toLowerCase(); + const response = { + type: 'command-response', + requestId: requestId, + ok: false + }; + + try { + if (command === 'media-put') { + const relativePath = String(payload.relativePath || payload.filename || '').trim(); + const filePath = resolveLocalMediaFilePath(relativePath); + const bodyBase64 = String(payload.bodyBase64 || '').trim(); + if (!filePath || !bodyBase64) { + response.error = 'Invalid media payload.'; + } else { + const bodyBuffer = Buffer.from(bodyBase64, 'base64'); + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, bodyBuffer); + response.ok = true; + } + } else if (command === 'media-delete') { + const relativePath = String(payload.relativePath || payload.filename || '').trim(); + const filePath = resolveLocalMediaFilePath(relativePath); + if (!filePath) { + response.error = 'Invalid media path.'; + } else { + try { + await fs.promises.unlink(filePath); + } catch (error) { + if (!error || error.code !== 'ENOENT') { + throw error; + } + } + response.ok = true; + } + } else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) !== -1) { + const screenSlug = String(payload.screenSlug || payload.slug || '').trim(); + if (!screenSlug) { + response.error = 'Screen slug is required.'; + } else if (payload.connectionId) { + const sent = await playerRuntime.sendCommandToConnection(screenSlug, String(payload.connectionId || '').trim(), payload); + response.ok = sent > 0; + if (!response.ok) { + response.error = 'Player is not connected.'; + } + } else { + const sent = await playerRuntime.broadcastCommand(screenSlug, payload); + response.ok = sent > 0; + if (!response.ok) { + response.error = 'Player is not connected.'; + } + } + } else { + response.error = 'Unsupported command.'; + } + } catch (error) { + response.error = error && error.message ? error.message : 'Command failed.'; + } + + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(response)); + } + } + app.use(function (error, _req, res, _next) { + console.error(error); + res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error'); + }); + + await ensureFontLibrary(MEDIA_DIR); + registerPlayerOnboardingRoutes(app, { pool: pool, common: common, playerRuntime: playerRuntime, onboardingStore: onboardingStore, playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, - playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL + playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, + thinClientBaseUrl: THIN_CLIENT_BASE_URL, + playerDeviceId: PLAYER_DEVICE_ID }); registerPlayerRoutes(app, { pool: pool, @@ -58,24 +209,165 @@ async function start() { rtmpStreamService: rtmpStreamService, playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, - playerIdentifier: PLAYER_IDENTIFIER + thinClientBaseUrl: THIN_CLIENT_BASE_URL, + playerDeviceId: PLAYER_DEVICE_ID }); - app.use(function (error, _req, res, _next) { - console.error(error); - res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error'); - }); + function createThinClientWebSocketUrl() { + if (!THIN_CLIENT_BASE_URL) { + return null; + } - fs.mkdirSync(MEDIA_DIR, { recursive: true }); - await ensureFontLibrary(MEDIA_DIR); + return THIN_CLIENT_BASE_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players'; + } + + function startThinClientRegistration() { + const thinClientUrl = createThinClientWebSocketUrl(); + if (!thinClientUrl) { + return null; + } + + let socket = null; + let reconnectTimer = null; + let heartbeatTimer = null; + + function clearTimers() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + } + + function connect() { + clearTimers(); + const timestamp = String(Date.now()); + const authHeaders = createRequestAuthHeaders({ + method: 'GET', + pathname: '/ws/players', + timestamp: timestamp + }); + let webMediaSyncTriggered = false; + socket = new WebSocket(thinClientUrl, { + headers: Object.assign({ + 'x-pulse-request-timestamp': timestamp + }, authHeaders) + }); + + socket.on('open', function () { + logPlayerStartup({ + connected: true + }); + + socket.send(JSON.stringify({ + type: 'register', + deviceId: PLAYER_DEVICE_ID, + publicBaseUrl: PLAYER_PUBLIC_BASE_URL, + internalBaseUrl: PLAYER_INTERNAL_BASE_URL + })); + + if (!webMediaSyncCompleted) { + triggerWebMediaSync().then(function (success) { + webMediaSyncTriggered = Boolean(success); + webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted; + }).catch(function () { + webMediaSyncTriggered = false; + }); + } + + heartbeatTimer = setInterval(function () { + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + socket.send(JSON.stringify({ + type: 'heartbeat', + deviceId: PLAYER_DEVICE_ID, + publicBaseUrl: PLAYER_PUBLIC_BASE_URL, + internalBaseUrl: PLAYER_INTERNAL_BASE_URL + })); + + if (!webMediaSyncTriggered && !webMediaSyncCompleted) { + triggerWebMediaSync().then(function (success) { + webMediaSyncTriggered = Boolean(success); + webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted; + }).catch(function () { + webMediaSyncTriggered = false; + }); + } + }, DB_SYNC_INTERVAL_MS); + }); + + socket.on('message', function (rawMessage) { + handleThinClientCommand(socket, rawMessage).catch(function (error) { + try { + socket.send(JSON.stringify({ + type: 'command-response', + requestId: null, + ok: false, + error: error && error.message ? error.message : 'Command failed.' + })); + } catch (_sendError) { + // ignore send errors + } + }); + }); + + socket.on('close', function () { + clearTimers(); + reconnectTimer = setTimeout(connect, 5000); + }); + + socket.on('error', function () { + try { + socket.close(); + } catch (_error) { + // ignore reconnect noise + } + }); + } + + connect(); + return function stop() { + clearTimers(); + if (socket) { + try { + socket.close(); + } catch (_error) { + // ignore close errors + } + socket = null; + } + }; + } + + if (!isRemotePlayer) { + logPlayerStartup({ + connected: false + }); + } + + const stopThinClientRegistration = startThinClientRegistration(); server.listen(PORT, function () { console.log(`Pulse Signage app listening on port ${PORT}`); }); async function syncDatabaseState() { + if (isRemotePlayer) { + return; + } + try { - await common.ensureSchema(pool, { mediaDir: MEDIA_DIR }); + await recordPlayerHeartbeat(pool, { + deviceId: PLAYER_DEVICE_ID, + publicBaseUrl: PLAYER_PUBLIC_BASE_URL, + internalBaseUrl: PLAYER_INTERNAL_BASE_URL + }).catch(function (error) { + console.error(error); + }); if (playerRuntime.snapshotAllConnections().length > 0) { await common.pruneStaleOnboardingDevices(pool); @@ -98,16 +390,27 @@ async function start() { await syncDatabaseState(); - if (PLAYER_IDENTIFIER) { + if (PLAYER_DEVICE_ID && !isRemotePlayer) { const { upsertPlayerRegistration } = require('./player/onboarding'); - await upsertPlayerRegistration(pool, PLAYER_IDENTIFIER, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL, null); + await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL).catch(function (error) { + console.error(error); + }); } setInterval(function () { + if (isRemotePlayer) { + return; + } syncDatabaseState().catch(function (error) { console.error(error); }); }, DB_SYNC_INTERVAL_MS); + + process.on('exit', function () { + if (typeof stopThinClientRegistration === 'function') { + stopThinClientRegistration(); + } + }); } module.exports = { start }; diff --git a/src/player/onboarding/player-onboarding-landing.script.html b/src/player/onboarding/player-onboarding-landing.script.html index 65c19c3..85f8f73 100644 --- a/src/player/onboarding/player-onboarding-landing.script.html +++ b/src/player/onboarding/player-onboarding-landing.script.html @@ -11,6 +11,7 @@ var localForm = document.getElementById("onboarding-local-form"); var localMessage = document.getElementById("onboarding-message"); var localScreenSelect = document.getElementById("onboarding-screen-select"); + var qrPlaceholderSrc = "data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E"; function parseResponseError(response) { return response.text().then(function (text) { var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding."; @@ -60,7 +61,11 @@ .catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; }); } function loadQr(deviceId) { - if (qr) { qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); } + if (!qr) { return; } + qr.onerror = function () { + qr.src = qrPlaceholderSrc; + }; + qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); } function submitOnboarding(deviceId, clientName, screenSlug) { return fetch("/api/onboarding", { @@ -133,6 +138,9 @@ }); redirectIfOnboarded(deviceId).then(function (redirected) { if (redirected) { return; } + if (qr && !qr.getAttribute("src")) { + qr.src = qrPlaceholderSrc; + } loadQr(deviceId); setStatus("Waiting for onboarding to finish."); window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000); diff --git a/src/player/playlist.js b/src/player/playlist.js index 3a58a47..a27e123 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -144,11 +144,14 @@ function createPlayerPlaylistService(options) { return null; } - const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) { + const videoRegions = Object.keys(parsed).map(function (key) { return parsed[key]; }).filter(function (region) { return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0; }); - const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000; + const duration = videoRegions.reduce(function (longest, region) { + const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000; + return regionDuration > longest ? regionDuration : longest; + }, 0); return Number.isFinite(duration) && duration > 0 ? duration : null; } catch (_error) { return null; diff --git a/src/player/render.js b/src/player/render.js index f15807b..771b017 100644 --- a/src/player/render.js +++ b/src/player/render.js @@ -46,7 +46,7 @@ function renderOnboardingLandingBody() { '
', '
', '
', - ' Onboarding QR code', + ' Onboarding QR code', '
', '
Preparing onboarding link...
', '
', diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js index 36b1057..ee43bb8 100644 --- a/src/web/bootstrap.js +++ b/src/web/bootstrap.js @@ -21,8 +21,8 @@ function createWebBootstrap(options) { const dashboardWs = new WebSocketServer({ noServer: true }); const dashboardClients = new Set(); - const playerSnapshotCache = new Map(); - const playerSnapshotSockets = new Map(); + const playerSnapshotCache = options && options.playerSnapshotCache ? options.playerSnapshotCache : new Map(); + const playerSnapshotSockets = options && options.playerSnapshotSockets ? options.playerSnapshotSockets : new Map(); let dashboardRefreshInFlight = null; let broadcastDashboardState = null; let playerInternalBaseUrl = null; @@ -94,6 +94,10 @@ function createWebBootstrap(options) { } function ensurePlayerSnapshotSubscription(slug) { + if (options && typeof options.ensurePlayerSnapshotSubscription === 'function' && options.ensurePlayerSnapshotSubscription !== ensurePlayerSnapshotSubscription) { + return options.ensurePlayerSnapshotSubscription(slug); + } + const key = String(slug || '').trim(); if (!key || playerSnapshotSockets.has(key)) { return; @@ -175,12 +179,44 @@ function createWebBootstrap(options) { const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory; const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange; const runMediaSyncTask = uploadSyncService.runMediaSyncTask; + let lastDashboardState = null; + + function getFallbackDashboardState() { + return lastDashboardState || { + playlists: [], + screens: [], + clients: [], + kioskPlayers: [], + slides: [], + playerServiceConnected: false, + connectedPlayersCount: 0, + connectedClientsCount: 0 + }; + } + + async function resolveDashboardState() { + try { + const state = await buildDashboardState(); + lastDashboardState = state; + return state; + } catch (error) { + if (lastDashboardState) { + console.error(error); + return lastDashboardState; + } + + throw error; + } + } async function sendDashboardStateToSocket(socket) { if (!socket || socket.readyState !== WebSocket.OPEN) { return; } - const state = await buildDashboardState(); + const state = await resolveDashboardState().catch(function (error) { + console.error(error); + return getFallbackDashboardState(); + }); socket.send(JSON.stringify({ type: 'dashboard-state', state: state })); } @@ -190,7 +226,10 @@ function createWebBootstrap(options) { } dashboardRefreshInFlight = (async function () { - const state = await buildDashboardState(); + const state = await resolveDashboardState().catch(function (error) { + console.error(error); + return getFallbackDashboardState(); + }); const payload = JSON.stringify({ type: 'dashboard-state', state: state }); for (const socket of dashboardClients) { if (socket && socket.readyState === WebSocket.OPEN) { diff --git a/src/web/lib/dashboard-state.js b/src/web/lib/dashboard-state.js index 400f6e8..9da9dbf 100644 --- a/src/web/lib/dashboard-state.js +++ b/src/web/lib/dashboard-state.js @@ -67,7 +67,11 @@ function createDashboardStateService(options) { ? await common.fetchScreenPlayerUrls(pool) : {}; screensData.forEach(function (screen) { - ensurePlayerSnapshotSubscription(screen.slug); + try { + ensurePlayerSnapshotSubscription(screen.slug); + } catch (_error) { + // Keep building dashboard state when one player snapshot subscription fails. + } }); const [onboardingRows] = await pool.query( diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index d11773f..66077dd 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -6,11 +6,48 @@ const crypto = require('crypto'); const multer = require('multer'); const { createRequestAuthHeaders } = require('#src/request-auth'); const { collectFontLibrarySyncOperations } = require('./font-library'); +const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry'); function normalizeUploadRoot(uploadDir) { return path.resolve(String(uploadDir || '').trim()); } +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 === 'web' + || host === 'player-bridge' + || host.endsWith('.local') + || host.endsWith('.internal') + || host.endsWith('.docker.internal'); +} + +function normalizeBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); +} + +function normalizePlayerRowBaseUrl(player) { + return normalizeBaseUrl(player && player.internal_base_url); +} + +function isRecentPlayerRegistration(player, staleSeconds) { + const lastSeenAt = player && player.last_seen_at; + const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime(); + const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000; + + return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; +} + function createUploadSyncService(options) { const pool = options && options.pool; const common = options && options.common; @@ -20,55 +57,144 @@ function createUploadSyncService(options) { const backgroundTaskQueue = options && options.backgroundTaskQueue; const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024; const MAX_FIELD_BYTES = 10 * 1024 * 1024; + const PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS = 60000; const pendingPlayerUploadSyncs = new Map(); let pendingPlayerUploadSyncFlushTimer = null; let pendingPlayerUploadSyncFlushInFlight = null; + let pendingPlayerUploadSyncRetryLogAt = 0; const pendingPlaylistUploadSyncs = new Map(); let pendingPlaylistUploadSyncFlushTimer = null; let pendingPlaylistUploadSyncFlushInFlight = null; let playerInternalBaseUrl = null; let playerInternalBaseUrlPromise = null; + let playerTaskMetadata = null; + let playerTaskMetadataPromise = null; if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') { throw new Error('createUploadSyncService requires the upload dependencies.'); } async function getPlayerInternalBaseUrl() { - if (playerInternalBaseUrl) { - return playerInternalBaseUrl; + const metadata = await getPlayerTaskMetadata(); + if (!metadata || metadata.playerActive === false) { + return null; } - if (playerInternalBaseUrlPromise) { - return playerInternalBaseUrlPromise; + return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null; + } + + async function getPlayerTaskMetadata() { + if (playerTaskMetadata) { + return playerTaskMetadata; } - playerInternalBaseUrlPromise = (async function () { - if (!pool) { - return configuredPlayerInternalBaseUrl || null; - } + if (playerTaskMetadataPromise) { + return playerTaskMetadataPromise; + } + playerTaskMetadataPromise = (async function () { try { - const [rows] = await pool.query( - `SELECT internal_base_url - FROM d_players - WHERE device_id = '1' - LIMIT 1` - ); - const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, ''); - return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null; + if (pool && typeof fetchPlayerRegistrations === 'function') { + const configuredPlayerIdentifier = getConfiguredPlayerIdentifier(); + const players = await fetchPlayerRegistrations(pool); + const registeredPlayers = Array.isArray(players) ? players : []; + const recentPlayers = registeredPlayers.filter(function (player) { + return isRecentPlayerRegistration(player, 60); + }); + const preferredPlayer = recentPlayers.find(function (player) { + const internalBaseUrl = normalizePlayerRowBaseUrl(player); + return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl); + }) || recentPlayers.find(function (player) { + return String(player && player.identifier || '').trim() === configuredPlayerIdentifier; + }) || recentPlayers[0] || null; + if (preferredPlayer) { + const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(preferredPlayer); + const resolvedPublicBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.public_base_url); + const resolvedIdentifier = String(preferredPlayer && preferredPlayer.identifier || '').trim(); + playerInternalBaseUrl = resolvedInternalBaseUrl || null; + playerTaskMetadata = { + playerIdentifier: resolvedIdentifier || null, + playerPublicBaseUrl: resolvedPublicBaseUrl || null, + playerInternalBaseUrl: resolvedInternalBaseUrl || null, + playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null, + playerActive: true + }; + return playerTaskMetadata; + } + + if (registeredPlayers.length) { + const stalePlayer = registeredPlayers.find(function (player) { + return String(player && player.identifier || '').trim() === configuredPlayerIdentifier; + }) || registeredPlayers[0] || null; + const staleInternalBaseUrl = normalizePlayerRowBaseUrl(stalePlayer); + const stalePublicBaseUrl = normalizeBaseUrl(stalePlayer && stalePlayer.public_base_url); + const staleIdentifier = String(stalePlayer && stalePlayer.identifier || '').trim(); + playerInternalBaseUrl = staleInternalBaseUrl || null; + playerTaskMetadata = { + playerIdentifier: staleIdentifier || null, + playerPublicBaseUrl: stalePublicBaseUrl || null, + playerInternalBaseUrl: staleInternalBaseUrl || null, + playerLabel: staleIdentifier || stalePublicBaseUrl || staleInternalBaseUrl || null, + playerActive: false + }; + return playerTaskMetadata; + } + } } catch (_error) { - return configuredPlayerInternalBaseUrl || null; } - })().then(function (baseUrl) { - playerInternalBaseUrl = baseUrl || null; - playerInternalBaseUrlPromise = null; - return playerInternalBaseUrl; + + playerInternalBaseUrl = configuredPlayerInternalBaseUrl || null; + playerTaskMetadata = { + playerIdentifier: getConfiguredPlayerIdentifier() || null, + playerPublicBaseUrl: null, + playerInternalBaseUrl: playerInternalBaseUrl, + playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null, + playerActive: true + }; + return playerTaskMetadata; + })().then(function (metadata) { + playerTaskMetadataPromise = null; + return metadata || null; }, function () { - playerInternalBaseUrlPromise = null; - return configuredPlayerInternalBaseUrl || null; + playerTaskMetadataPromise = null; + return { + playerIdentifier: getConfiguredPlayerIdentifier() || null, + playerPublicBaseUrl: null, + playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null, + playerLabel: getConfiguredPlayerIdentifier() || configuredPlayerInternalBaseUrl || null + }; }); - return playerInternalBaseUrlPromise; + return playerTaskMetadataPromise; + } + + function formatPlayerTaskLabel(metadata) { + const playerLabel = String(metadata && metadata.playerLabel || '').trim(); + if (playerLabel) { + return playerLabel; + } + + const playerIdentifier = String(metadata && metadata.playerIdentifier || '').trim(); + if (playerIdentifier) { + return playerIdentifier; + } + + const playerPublicBaseUrl = String(metadata && metadata.playerPublicBaseUrl || '').trim(); + if (playerPublicBaseUrl) { + return playerPublicBaseUrl; + } + + return ''; + } + + function logMediaSyncSummary(level, message, metadata, details) { + const suffix = formatPlayerTaskLabel(metadata); + const logger = level === 'warn' ? console.warn : console.info; + if (details !== undefined) { + logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`, details); + return; + } + logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`); } function createUploadMiddleware(uploadDir) { @@ -278,6 +404,15 @@ function createUploadSyncService(options) { return Boolean(localUploadDir); } + function isPlayerUnavailableError(error) { + const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase(); + return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT'; + } + + function isPlayerUnavailableResponse(response) { + return Boolean(response) && Number(response.status) === 503; + } + function queuePlayerUploadSync(operation) { if (!operation || !operation.uploadPath) { return; @@ -305,13 +440,13 @@ function createUploadSyncService(options) { }, 5000); } - async function pushUploadFileToPlayer(uploadPath, localUploadDir) { + async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { return false; } - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); - if (!resolvedPlayerInternalBaseUrl) { + const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl(); + if (!targetBaseUrl) { return false; } @@ -336,7 +471,7 @@ function createUploadSyncService(options) { pathname: `/api/media/${encodeURIComponent(relativePath)}`, body: fileBuffer }); - const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { + const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', @@ -345,23 +480,27 @@ function createUploadSyncService(options) { body: fileBuffer }); if (!response.ok) { - console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText); + if (!isPlayerUnavailableResponse(response)) { + console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText); + } return false; } return true; } catch (error) { - console.warn('Unable to sync upload to player:', relativePath, error); + if (!isPlayerUnavailableError(error)) { + console.warn('Unable to sync upload to player:', relativePath, error); + } return false; } } - async function removeUploadFileFromPlayer(uploadPath, localUploadDir) { + async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { return false; } - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); - if (!resolvedPlayerInternalBaseUrl) { + const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl(); + if (!targetBaseUrl) { return false; } @@ -374,7 +513,7 @@ function createUploadSyncService(options) { method: 'DELETE', pathname: `/api/media/${encodeURIComponent(relativePath)}` }); - const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { + const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { method: 'DELETE', headers: { Accept: 'application/json', @@ -382,12 +521,16 @@ function createUploadSyncService(options) { } }); if (!response.ok && response.status !== 404) { - console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText); + if (!isPlayerUnavailableResponse(response)) { + console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText); + } return false; } return true; } catch (error) { - console.warn('Unable to remove upload from player:', relativePath, error); + if (!isPlayerUnavailableError(error)) { + console.warn('Unable to remove upload from player:', relativePath, error); + } return false; } } @@ -397,9 +540,10 @@ function createUploadSyncService(options) { return; } + const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean))); for (let i = 0; i < uniqueRefs.length; i += 1) { - const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir); + const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl); if (!success) { queuePlayerUploadSync({ type: 'put', @@ -552,6 +696,10 @@ function createUploadSyncService(options) { pendingPlaylistUploadSyncs.delete(operation.key); } + + if (pendingEntries.length) { + logMediaSyncSummary('info', `Playlist sync flushed ${pendingEntries.length} task${pendingEntries.length === 1 ? '' : 's'}`, pendingEntries[0] && pendingEntries[0].metadata); + } })().finally(function () { pendingPlaylistUploadSyncFlushInFlight = null; if (pendingPlaylistUploadSyncs.size) { @@ -573,18 +721,47 @@ function createUploadSyncService(options) { pendingPlayerUploadSyncFlushInFlight = (async function () { const pendingEntries = Array.from(pendingPlayerUploadSyncs.values()); + const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata + ? pendingEntries[0].metadata + : await getPlayerTaskMetadata(); + if (playerMetadata && playerMetadata.playerActive === false) { + pendingPlayerUploadSyncs.clear(); + pendingPlayerUploadSyncRetryLogAt = 0; + return; + } + const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl + ? playerMetadata.playerInternalBaseUrl + : await getPlayerInternalBaseUrl(); + let successCount = 0; + let failureCount = 0; for (let i = 0; i < pendingEntries.length; i += 1) { const operation = pendingEntries[i]; let success = false; if (operation.type === 'delete') { - success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir); + success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl); } else { - success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir); + success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl); } if (success) { + successCount += 1; pendingPlayerUploadSyncs.delete(operation.uploadPath); + } else { + failureCount += 1; } } + + if (successCount) { + logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, playerMetadata); + } + if (failureCount) { + const now = Date.now(); + if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) { + pendingPlayerUploadSyncRetryLogAt = now; + logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, playerMetadata); + } + } else if (!pendingPlayerUploadSyncs.size) { + pendingPlayerUploadSyncRetryLogAt = 0; + } })().finally(function () { pendingPlayerUploadSyncFlushInFlight = null; if (pendingPlayerUploadSyncs.size) { @@ -680,12 +857,14 @@ function createUploadSyncService(options) { safePayload.operation = Object.assign({}, safePayload.operation); delete safePayload.operation.pool; } + const playerMetadata = await getPlayerTaskMetadata(); const definition = { key: taskKey, title: title, category: 'media-sync', taskType: 'media-sync', + metadata: Object.assign({}, playerMetadata || {}), payload: safePayload, persist: true }; @@ -723,7 +902,8 @@ function createUploadSyncService(options) { flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs, flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs, runMediaSyncTask: runMediaSyncTask, - queueMediaSyncTask: queueMediaSyncTask + queueMediaSyncTask: queueMediaSyncTask, + getPlayerTaskMetadata: getPlayerTaskMetadata }; } diff --git a/src/web/lib/player-actions.js b/src/web/lib/player-actions.js index 2e913a6..a990ff9 100644 --- a/src/web/lib/player-actions.js +++ b/src/web/lib/player-actions.js @@ -1,4 +1,52 @@ const { createRequestAuthHeaders } = require('#src/request-auth'); +const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry'); + +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 === 'web' + || host === 'player-bridge' + || host.endsWith('.local') + || host.endsWith('.internal') + || host.endsWith('.docker.internal'); +} + +function normalizeBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); +} + +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 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; @@ -22,26 +70,38 @@ function createPlayerActionService(options) { } playerInternalBaseUrlPromise = (async function () { - if (!pool) { - return configuredPlayerInternalBaseUrl || null; + try { + if (pool && typeof fetchPlayerRegistrations === 'function') { + const configuredPlayerIdentifier = getConfiguredPlayerIdentifier(); + const players = await fetchPlayerRegistrations(pool); + const exactPlayer = Array.isArray(players) + ? players.find(function (player) { + return String(player && player.identifier || '').trim() === configuredPlayerIdentifier; + }) + : null; + const registeredPlayers = Array.isArray(players) ? players : []; + const preferredPlayer = exactPlayer || registeredPlayers.find(function (player) { + const internalBaseUrl = normalizeBaseUrl(player && player.internal_base_url); + return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl); + }) || registeredPlayers[0] || null; + const resolvedBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.internal_base_url); + if (resolvedBaseUrl) { + playerInternalBaseUrl = resolvedBaseUrl; + return resolvedBaseUrl; + } + } + } catch (_error) { } - try { - const [rows] = await pool.query( - `SELECT internal_base_url - FROM d_players - WHERE device_id = '1' - LIMIT 1` - ); - const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, ''); - return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null; - } catch (_error) { - return configuredPlayerInternalBaseUrl || null; + if (configuredPlayerInternalBaseUrl) { + playerInternalBaseUrl = configuredPlayerInternalBaseUrl; + return configuredPlayerInternalBaseUrl; } + + return null; })().then(function (baseUrl) { - playerInternalBaseUrl = baseUrl || null; playerInternalBaseUrlPromise = null; - return playerInternalBaseUrl; + return baseUrl || null; }, function () { playerInternalBaseUrlPromise = null; return configuredPlayerInternalBaseUrl || null; @@ -50,24 +110,26 @@ function createPlayerActionService(options) { return playerInternalBaseUrlPromise; } - async function forwardPlayerCommand(slug, commandOrPayload, connectionId) { + async function forwardPlayerCommandToBaseUrl(baseUrl, slug, commandOrPayload, connectionId) { + const targetBaseUrl = normalizeBaseUrl(baseUrl); + if (!targetBaseUrl) { + throw new Error('Unable to resolve the player internal base URL.'); + } + const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null ? Object.assign({}, commandOrPayload) : { command: commandOrPayload }; if (connectionId) { payload.connectionId = connectionId; } + const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: `/api/screens/${encodeURIComponent(slug)}/commands`, body: payload }); - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); - if (!resolvedPlayerInternalBaseUrl) { - throw new Error('Unable to resolve the player internal base URL.'); - } - const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, { + const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -89,6 +151,11 @@ function createPlayerActionService(options) { }); } + async function forwardPlayerCommand(slug, commandOrPayload, connectionId) { + const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); + return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId); + } + async function forwardAnnouncementRefresh(slug) { const authHeaders = createRequestAuthHeaders({ method: 'POST', @@ -127,29 +194,66 @@ function createPlayerActionService(options) { method: 'GET', pathname: `/api/screens/${encodeURIComponent(slug)}/connections` }); - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); - if (!resolvedPlayerInternalBaseUrl) { + 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); + } + } + + if (!targetBaseUrls.length) { throw new Error('Unable to resolve the player internal base URL.'); } - const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...authHeaders + 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 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; } }); - if (!response.ok) { - const errorText = await response.text().catch(function () { return ''; }); - const error = new Error(errorText || `Unable to load screen connections for ${slug}.`); - error.statusCode = response.status; - throw error; - } - - return response.json().catch(function () { - return { connections: [] }; - }); + return { + screen: screen, + screenSlug: slug, + count: mergedConnections.length, + connections: mergedConnections, + degraded: degraded + }; } async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) { @@ -197,6 +301,7 @@ function createPlayerActionService(options) { forwardPlayerCommand: forwardPlayerCommand, forwardAnnouncementRefresh: forwardAnnouncementRefresh, getScreenConnections: getScreenConnections, + forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl, getScreenDeleteBlockMessage: getScreenDeleteBlockMessage, getSlideDeleteBlockMessage: getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage, diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index e74f3ed..5ad675c 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -694,7 +694,8 @@ .table-pagination-page .app-content { height: 100%; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; } .table-pagination-page .app-content .container-fluid { @@ -730,7 +731,7 @@ display: flex; flex: 0 1 auto; flex-direction: column; - min-height: 0; + min-height: var(--table-pagination-card-min-height, 0); max-height: var(--table-pagination-card-max-height, var(--background-tasks-task-card-max-height, calc(100dvh - 12rem))); overflow: hidden; } diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js index ff78c84..9eb6e4b 100644 --- a/src/web/public/js/dashboard/dashboard-page.js +++ b/src/web/public/js/dashboard/dashboard-page.js @@ -6,7 +6,169 @@ var getClientDisplayName = webUiHelpers.getClientDisplayName; var setButtonVariant = webUiHelpers.setButtonVariant; 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'); + var container = table && typeof table.closest === 'function' + ? (table.closest('[data-table-search-container]') || table.closest('.card') || null) + : null; + + return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]'); + } + + function getClientListQueryState() { + var searchParams = new URLSearchParams(String(window.location && window.location.search || '')); + var searchInput = getClientSearchInput(); + var searchValue = searchInput ? String(searchInput.value || '').trim() : String(searchParams.get('search') || '').trim(); + + return { + search: searchValue, + sort: String(searchParams.get('sort') || '').trim(), + direction: String(searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc', + page: Math.max(1, Math.floor(Number(searchParams.get('page') || 1) || 1)) + }; + } + + function isClientSearchLoading() { + return Boolean(document.querySelector('[data-table-search-loading="true"]')); + } + + function getComparableClientSortValue(rawValue) { + var value = String(rawValue || '').trim(); + + if (!value) { + return { type: 'empty', value: '' }; + } + + var numericValue = Number(value.replace(/,/g, '')); + if (!Number.isNaN(numericValue)) { + return { type: 'number', value: numericValue }; + } + + var dateValue = Date.parse(value); + if (!Number.isNaN(dateValue)) { + return { type: 'date', value: dateValue }; + } + + return { type: 'string', value: value.toLowerCase() }; + } + + function compareClientSortValues(leftValue, rightValue) { + var left = getComparableClientSortValue(leftValue); + var right = getComparableClientSortValue(rightValue); + + if (left.type === 'empty' && right.type === 'empty') { + return 0; + } + if (left.type === 'empty') { + return 1; + } + if (right.type === 'empty') { + return -1; + } + if (left.type === right.type) { + if (left.value < right.value) { + return -1; + } + if (left.value > right.value) { + return 1; + } + return 0; + } + + return String(left.value).localeCompare(String(right.value), undefined, { numeric: true, sensitivity: 'base' }); + } + + function createClientSearchMatcher(searchTerm) { + var query = String(searchTerm || '').trim().toLowerCase(); + + if (!query) { + return function () { + return true; + }; + } + + return function (client) { + var searchableValues = [ + client && client.name, + client && client.client_name, + client && client.clientId, + client && client.deviceId, + client && client.slug, + client && client.screen_slug, + client && client.screen_name, + client && client.ipAddress, + client && client.clientIp, + client && client.status, + client && client.currentSlideTitle + ]; + + return searchableValues.map(function (value) { + return String(value || '').trim().toLowerCase(); + }).join(' ').indexOf(query) !== -1; + }; + } + + function sortClientsForTable(clients, sortKey, sortDirection) { + var normalizedSortKey = String(sortKey || '').trim(); + var normalizedDirection = String(sortDirection || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; + var accessors = { + client: function (client) { return String(client && (getClientDisplayName(client) || client.client_name || client.name || client.clientId) || '').trim(); }, + screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); }, + slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); }, + ip: function (client) { return String(client && client.clientIp || '').trim(); }, + viewport: function (client) { + var viewport = client && client.viewport; + if (!viewport || !viewport.width || !viewport.height) { + return ''; + } + return String(Number(viewport.width) || 0) + 'x' + String(Number(viewport.height) || 0); + }, + connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); } + }; + + function compareValues(leftValue, rightValue) { + return compareClientSortValues(leftValue, rightValue); + } + + var sortKeys = accessors[normalizedSortKey] + ? [normalizedSortKey] + : ['client']; + + if (sortKeys[0] === 'client') { + sortKeys.push('ip'); + } else if (sortKeys[0] === 'ip') { + sortKeys.push('client'); + } + + return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) { + for (var index = 0; index < sortKeys.length; index += 1) { + var sortKeyName = sortKeys[index]; + var comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient)); + + if (comparison !== 0) { + return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison; + } + } + + return 0; + }); + } + + function getVisibleClients(state) { + var query = getClientListQueryState(); + var clients = Array.isArray(state && state.clients) ? state.clients.slice() : []; + var searchMatcher = createClientSearchMatcher(query.search); + + clients = clients.filter(searchMatcher); + clients = sortClientsForTable(clients, query.sort, query.direction); + + return clients.slice((query.page - 1) * LIST_PAGE_SIZE, ((query.page - 1) * LIST_PAGE_SIZE) + LIST_PAGE_SIZE); + } function getClientMoveModalElements() { return { @@ -15,7 +177,8 @@ 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]') + clientNameInput: document.querySelector('[data-client-move-client-name]'), + playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]') }; } @@ -48,6 +211,7 @@ 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 || []); @@ -69,6 +233,9 @@ 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; @@ -89,12 +256,12 @@ return [ '
', - '
', + '
', '', - '
', - '
', - '
', - '
', + '
', + '
', + '
', + '
', '
' ].join(''); } @@ -126,6 +293,10 @@ if (connectionInput) { connectionInput.value = client.id || ''; } + var playerBaseUrlInput = pauseForm.querySelector('input[name="playerBaseUrl"]'); + if (playerBaseUrlInput) { + playerBaseUrlInput.value = client.player_url || ''; + } pauseForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands'; } @@ -141,6 +312,10 @@ if (reloadInput) { reloadInput.value = client.id || ''; } + var reloadPlayerBaseUrlInput = reloadForm.querySelector('input[name="playerBaseUrl"]'); + if (reloadPlayerBaseUrlInput) { + reloadPlayerBaseUrlInput.value = client.player_url || ''; + } reloadForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands'; reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?'); } @@ -173,6 +348,10 @@ if (blackoutConnectionInput) { blackoutConnectionInput.value = client.id || ''; } + var blackoutPlayerBaseUrlInput = blackoutForm.querySelector('input[name="playerBaseUrl"]'); + if (blackoutPlayerBaseUrlInput) { + blackoutPlayerBaseUrlInput.value = client.player_url || ''; + } blackoutForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands'; } @@ -194,6 +373,10 @@ if (previousConnectionInput) { previousConnectionInput.value = client.id || ''; } + var previousPlayerBaseUrlInput = previousForm.querySelector('input[name="playerBaseUrl"]'); + if (previousPlayerBaseUrlInput) { + previousPlayerBaseUrlInput.value = client.player_url || ''; + } previousForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands'; } @@ -215,6 +398,10 @@ if (nextConnectionInput) { nextConnectionInput.value = client.id || ''; } + var nextPlayerBaseUrlInput = nextForm.querySelector('input[name="playerBaseUrl"]'); + if (nextPlayerBaseUrlInput) { + nextPlayerBaseUrlInput.value = client.player_url || ''; + } nextForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands'; } } @@ -253,7 +440,7 @@ var actionCell = hasActionsColumn ? '' + renderClientActionCell(client) + '' : ''; return [ - '', + '', '
' + clientName + '
', '
' + screenName + '
', '' + currentSlide + '', @@ -265,26 +452,67 @@ ].join(''); } + function updateClientRowCells(row, client, hasActionsColumn) { + if (!row || !row.cells || row.cells.length < 6) { + return; + } + + var connectedAt = client.connectedAt ? '
' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '
' + (client.lastSeenAt ? '
' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '
' : '') : 'Unknown'; + var clientIpValue = normalizeDisplayIp(client.clientIp); + var clientIp = clientIpValue ? escapeHtml(clientIpValue) : 'Unknown'; + var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : 'Unknown'; + var clientNameValue = getClientDisplayName(client); + var clientName = clientNameValue ? escapeHtml(clientNameValue) : 'Unknown'; + var screenName = client.screen_name ? escapeHtml(client.screen_name) : 'Unknown'; + 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-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 || '')); + + setCellHtml(row.cells[0], '
' + clientName + '
'); + setCellHtml(row.cells[1], '
' + screenName + '
'); + setCellHtml(row.cells[2], currentSlide); + setCellHtml(row.cells[3], clientIp); + setCellHtml(row.cells[4], viewport); + setCellHtml(row.cells[5], connectedAt); + + syncClientActionCell(row, client, hasActionsColumn); + } + + function createClientRowFromTemplate(client, hasActionsColumn) { + var template = document.createElement('tbody'); + template.innerHTML = renderClientRow(client, hasActionsColumn); + return template.firstElementChild || null; + } + + function setCellHtml(cell, html) { + if (!cell || String(cell.innerHTML || '') === String(html || '')) { + return false; + } + + cell.innerHTML = html; + return true; + } + function renderScreenTile(screen) { var clientCount = Number(screen.player_connection_count || 0); - var playerUrl = String(screen.player_url || '').trim(); var connectionLabel = clientCount ? clientCount + ' live' : 'No clients'; var connectionStateClass = clientCount ? 'is-live' : 'is-idle'; var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned'; - var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected'; return [ '
', '
', '
', '

' + escapeHtml(screen.name || '') + '

', - '' + escapeHtml(playerUrl) + '', '
', '' + escapeHtml(connectionLabel) + '', '
', '
', '
Playlist
' + playlistLabel + '
', - '
Connections
' + escapeHtml(connectionsLabel) + '
', '
', '
' ].join(''); @@ -310,7 +538,7 @@ } function updateStats(state) { - var clientCount = document.getElementById('dashboard-client-count'); + var playerCount = document.getElementById('dashboard-player-count'); var screenCount = document.getElementById('dashboard-screen-count'); var slideCount = document.getElementById('dashboard-slide-count'); var playlistCount = document.getElementById('dashboard-playlist-count'); @@ -324,81 +552,137 @@ if (screenCount && Array.isArray(state.screens)) { screenCount.textContent = String(state.screens.length); } - if (clientCount) { - clientCount.textContent = String(Number(state.connectedClientsCount || 0)); + if (playerCount) { + playerCount.textContent = String(Number(state.connectedPlayersCount || 0)); } } - function updateClientTable(state) { + function updateClientTable(state, forceRender) { var tbody = document.getElementById('dashboard-clients-table-body'); if (!tbody || !Array.isArray(state.clients)) { return; } + if (!forceRender && isClientSearchLoading()) { + return; + } var table = document.getElementById('dashboard-clients-table'); var hasActionsColumn = Boolean(table && String(table.getAttribute('data-has-actions-column') || '').toLowerCase() === 'true'); - if (!state.clients.length) { + var visibleClients = getVisibleClients(state); + + var canPatchRows = typeof tbody.querySelectorAll === 'function' + && typeof tbody.insertBefore === 'function' + && typeof tbody.removeChild === 'function' + && typeof document.createElement === 'function'; + + if (!visibleClients.length) { tbody.innerHTML = 'No connected clients yet.'; return; } + + if (!canPatchRows) { + tbody.innerHTML = visibleClients.map(function (client) { + return renderClientRow(client, hasActionsColumn); + }).join(''); + return; + } + var existingRows = {}; Array.prototype.slice.call(tbody.querySelectorAll('tr[data-client-key]')).forEach(function (row) { - existingRows[row.getAttribute('data-client-key')] = row; + existingRows[String(row.getAttribute('data-client-key') || '').trim()] = row; }); - Array.prototype.slice.call(tbody.querySelectorAll('tr')).forEach(function (row) { - if (!row.hasAttribute('data-client-key')) { - row.parentNode.removeChild(row); + var nextRows = visibleClients.map(function (client) { + var rowKey = String(getClientRowKey(client) || '').trim(); + var row = existingRows[rowKey] || null; + + if (!row) { + row = createClientRowFromTemplate(client, hasActionsColumn); + } else { + updateClientRowCells(row, client, hasActionsColumn); } + + return row; + }).filter(function (row) { + return Boolean(row); }); - state.clients.forEach(function (client, index) { - var rowKey = getClientRowKey(client); - var row = existingRows[rowKey]; - if (!row) { - var tempBody = document.createElement('tbody'); - tempBody.innerHTML = renderClientRow(client, hasActionsColumn); - row = tempBody.firstElementChild; - } - - if (!row) { - return; - } - - row.setAttribute('data-client-key', rowKey); - row.setAttribute('data-client-id', client.clientId || ''); - row.setAttribute('data-client-device-id', client.deviceId || ''); - row.setAttribute('data-client-screen-slug', client.screen_slug || ''); - if (row.cells && row.cells.length >= 6) { - var connectedAt = client.connectedAt ? '
' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '
' + (client.lastSeenAt ? '
' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '
' : '') : 'Unknown'; - var clientIpValue = normalizeDisplayIp(client.clientIp); - var clientIp = clientIpValue ? escapeHtml(clientIpValue) : 'Unknown'; - var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : 'Unknown'; - var clientNameValue = getClientDisplayName(client); - var clientName = clientNameValue ? escapeHtml(clientNameValue) : 'Unknown'; - var screenName = client.screen_name ? escapeHtml(client.screen_name) : 'Unknown'; - var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : 'No slide currently showing'; - - row.cells[0].innerHTML = '
' + clientName + '
'; - row.cells[1].innerHTML = '
' + screenName + '
'; - row.cells[2].innerHTML = currentSlide; - row.cells[3].innerHTML = clientIp; - row.cells[4].innerHTML = viewport; - row.cells[5].innerHTML = connectedAt; - syncClientActionCell(row, client, hasActionsColumn); - } - + nextRows.forEach(function (row, index) { var referenceNode = tbody.children[index] || null; if (referenceNode !== row) { tbody.insertBefore(row, referenceNode); } }); - while (tbody.children.length > state.clients.length) { + while (tbody.children.length > nextRows.length) { tbody.removeChild(tbody.lastElementChild); } } + function refreshClientTableFromLatestState() { + if (!latestDashboardState) { + return; + } + + updateClientTable(latestDashboardState, true); + } + + function updateKioskLauncherModal(state) { + var modal = document.getElementById('dashboard-kiosk-launcher-modal'); + if (!modal) { + return; + } + + var checkbox = modal.querySelector('[data-kiosk-launcher-confirm]'); + var select = modal.querySelector('[data-kiosk-launcher-player-select]'); + var downloadLinks = Array.prototype.slice.call(modal.querySelectorAll('[data-kiosk-launcher-download]')); + var playersState = state && Array.isArray(state.kioskPlayers) + ? state.kioskPlayers + : (state && Array.isArray(state.clients) ? state.clients : []); + var players = playersState.filter(function (client) { + return Boolean(client && String(client.player_url || '').trim()); + }); + + if (select && playersState.length) { + var currentValue = String(select.value || '').trim(); + var options = ['']; + + players.forEach(function (player) { + var playerUrl = String(player.player_url || '').trim(); + var playerIdentifier = String(player.player_identifier || 'Connected player').trim(); + if (!playerUrl) { + return; + } + options.push(''); + }); + + select.innerHTML = options.join(''); + if (currentValue) { + select.value = currentValue; + } + } + + var selectedUrl = select ? String(select.value || '').trim() : ''; + var canEnableDownloads = Boolean(checkbox && checkbox.checked && selectedUrl); + downloadLinks.forEach(function (link) { + if (!link) { + return; + } + var baseHref = String(link.getAttribute('data-kiosk-launcher-download-base') || link.getAttribute('href') || '').trim(); + if (canEnableDownloads) { + link.setAttribute('href', baseHref + '?playerUrl=' + encodeURIComponent(selectedUrl)); + link.classList.remove('disabled'); + link.setAttribute('aria-disabled', 'false'); + link.removeAttribute('tabindex'); + } else { + link.removeAttribute('href'); + link.classList.add('disabled'); + link.setAttribute('aria-disabled', 'true'); + link.setAttribute('tabindex', '-1'); + } + }); + } + function updateScreenGrid(state) { var grid = document.getElementById('dashboard-screens-grid'); if (!grid || !Array.isArray(state.screens)) { @@ -455,13 +739,22 @@ } select.disabled = false; - if (!select.value || !screenBySlug[select.value]) { - select.value = screens[0].slug || ''; - } - var selectedScreen = screenBySlug[select.value] || screens[0]; - var selectedSlug = String(selectedScreen && selectedScreen.slug || '').trim(); + 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; @@ -480,12 +773,20 @@ pill.textContent = connectionLabel; } if (nameNode) { - nameNode.textContent = String(selectedScreen && selectedScreen.name || 'Selected screen'); + nameNode.textContent = selectedScreen + ? String(selectedScreen.name || 'Selected screen') + : 'Select a target screen group'; } if (metaNode) { - metaNode.textContent = 'Commands sent here target every client currently using this screen.'; + 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"]'); @@ -498,10 +799,12 @@ pauseStateInput.value = allPaused ? 'false' : 'true'; } if (button) { - button.innerHTML = '' + (allPaused ? 'Resume screen' : 'Pause screen'); + 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 ? 'Resume all connected clients on this screen?' : 'Pause all connected clients on this screen?'); + 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"]'); @@ -509,23 +812,25 @@ blackoutStateInput.value = allBlackout ? 'false' : 'true'; } if (button) { - button.innerHTML = '' + (allBlackout ? 'Restore screen' : 'Blackout screen'); + 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 ? 'Restore all connected clients on this screen?' : 'Blackout all connected clients on this screen?'); + 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 = selectedSlug ? '/clients/' + encodeURIComponent(selectedSlug) + '/commands' : '#'; + form.action = commandTargetSlug ? '/clients/' + encodeURIComponent(commandTargetSlug) + '/commands' : '#'; if (command === 'reload') { - form.setAttribute('data-confirm-message', 'Reload selected screen?'); + form.setAttribute('data-confirm-message', isAllSelected ? 'Reload all screens?' : 'Reload selected screen?'); if (button) { - button.innerHTML = 'Reload screen'; + button.innerHTML = '' + (isAllSelected ? 'Reload all screens' : 'Reload screen'); } } Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) { - control.disabled = !selectedSlug; + control.disabled = !commandTargetSlug; }); }); } @@ -599,10 +904,12 @@ return; } latestDashboardState = state; + window.webLatestDashboardState = latestDashboardState; updateStats(state); updateScreenGrid(state); updateScreenCommandControls(state); updateClientTable(state); + updateKioskLauncherModal(state); updateDashboardQuickActions(state); } @@ -780,34 +1087,28 @@ } var checkbox = modal.querySelector('[data-kiosk-launcher-confirm]'); + var select = modal.querySelector('[data-kiosk-launcher-player-select]'); var downloadLinks = Array.prototype.slice.call(modal.querySelectorAll('[data-kiosk-launcher-download]')); - function setDownloadsEnabled(enabled) { - downloadLinks.forEach(function (link) { - if (!link) { - return; - } - - link.classList.toggle('disabled', !enabled); - link.setAttribute('aria-disabled', enabled ? 'false' : 'true'); - if (enabled) { - link.removeAttribute('tabindex'); - } else { - link.setAttribute('tabindex', '-1'); - } - }); - } - function resetModalState() { if (checkbox) { checkbox.checked = false; } - setDownloadsEnabled(false); + if (select) { + select.value = ''; + } + updateKioskLauncherModal(latestDashboardState); } if (checkbox) { checkbox.addEventListener('change', function () { - setDownloadsEnabled(Boolean(checkbox.checked)); + updateKioskLauncherModal(latestDashboardState || readScreenCommandStateFromDom()); + }); + } + + if (select) { + select.addEventListener('change', function () { + updateKioskLauncherModal(latestDashboardState || readScreenCommandStateFromDom()); }); } @@ -825,6 +1126,7 @@ } window.webHandleDashboardState = handleDashboardState; + window.webRefreshClientTableFromLatestState = refreshClientTableFromLatestState; initClientRenameHandler(); initClientMoveHandler(); diff --git a/src/web/public/js/regions/type/time-date.js b/src/web/public/js/regions/type/time-date.js index c0219bd..dd936b0 100644 --- a/src/web/public/js/regions/type/time-date.js +++ b/src/web/public/js/regions/type/time-date.js @@ -313,6 +313,7 @@ '
' + '
Available placeholders
' + '
' + renderPlaceholderChips() + '
' + + '
Placeholder values support transforms, for example {{title.upper()}}, {{title.title()}}, or {{title.lower()}}.
' + '
' + '
' + ''; diff --git a/src/web/public/js/table/table-search.js b/src/web/public/js/table/table-search.js index b19bd1d..689f791 100644 --- a/src/web/public/js/table/table-search.js +++ b/src/web/public/js/table/table-search.js @@ -1,4 +1,6 @@ (function () { + var minimumPaginationCardHeight = 20 * 16; + function rebindTableContainer(container) { if (!container) { return; @@ -33,8 +35,18 @@ var cardRect = card.getBoundingClientRect(); var bottomInset = 16; var availableHeight = Math.max(0, window.innerHeight - cardRect.top - bottomInset); + var contentHeight = Math.max(0, card.scrollHeight || 0); + var shouldApplyMinimum = contentHeight > minimumPaginationCardHeight; + var cardHeight = Math.max(minimumPaginationCardHeight, availableHeight); + + if (shouldApplyMinimum) { + card.style.setProperty('--table-pagination-card-min-height', minimumPaginationCardHeight + 'px'); + } else { + card.style.removeProperty('--table-pagination-card-min-height'); + } + card.style.removeProperty('--table-pagination-card-height'); - card.style.setProperty('--table-pagination-card-max-height', availableHeight + 'px'); + card.style.setProperty('--table-pagination-card-max-height', cardHeight + 'px'); }); } @@ -112,6 +124,7 @@ var currentUrl = new URL(window.location.href); var pendingSearchTimer = null; var requestSequence = 0; + var pendingSearchRequests = 0; var container = input.closest('[data-table-search-container]') || input.closest('.card') || null; input.value = String(currentUrl.searchParams.get(searchParam) || '').trim(); @@ -133,6 +146,10 @@ requestSequence += 1; var sequenceId = requestSequence; + pendingSearchRequests += 1; + if (container) { + container.setAttribute('data-table-search-loading', 'true'); + } fetch(nextUrl.toString(), { method: 'GET', @@ -165,6 +182,11 @@ } }).catch(function () { window.location.assign(nextUrl.toString()); + }).finally(function () { + pendingSearchRequests = Math.max(0, pendingSearchRequests - 1); + if (container && pendingSearchRequests === 0) { + container.removeAttribute('data-table-search-loading'); + } }); } diff --git a/src/web/routes/admin/client-commands.js b/src/web/routes/admin/client-commands.js index aa311b9..682c44a 100644 --- a/src/web/routes/admin/client-commands.js +++ b/src/web/routes/admin/client-commands.js @@ -6,17 +6,82 @@ module.exports = function registerScreenCommandRoutes(app, deps) { const pool = deps.pool; const common = deps.common; const forwardPlayerCommand = deps.forwardPlayerCommand; + const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl; const getScreenConnections = deps.getScreenConnections; const isClientNameAvailable = deps.isClientNameAvailable; 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) { + 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))); + } catch (_error) { + return []; + } + } + + function normalizeExplicitPlayerBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); + } + + 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 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 blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') ? req.body.blackout : req.query.blackout; @@ -28,11 +93,82 @@ 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') { + 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 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; + } + + 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); + })); + } + + return forwardPlayerCommand(screenSlug, commandPayload); + }); + })); + + 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 + }); + } + const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [slug]); if (!screenRows.length) { 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(); @@ -208,20 +344,32 @@ module.exports = function registerScreenCommandRoutes(app, deps) { } const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections); - const targetPlayerRecord = typeof common.fetchScreenPlayerRecord === 'function' - ? await common.fetchScreenPlayerRecord(pool, targetScreenSlug) + 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; + }) : null; - const targetBaseUrl = String( - targetPlayerRecord && targetPlayerRecord.public_base_url || + const sourcePlayerBaseUrl = String( + explicitPlayerBaseUrl || + (liveConnection && liveConnection.playerPublicBaseUrl) || (typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') || '' ).trim().replace(/\/$/, ''); - const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`; + const targetPlayerUrl = sourcePlayerBaseUrl ? `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`; - await forwardPlayerCommand(slug, { - command: 'redirect', - url: targetPlayerUrl - }, connectionId || deviceId || undefined); + if (sourcePlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') { + await forwardPlayerCommandToBaseUrl(sourcePlayerBaseUrl, slug, { + command: 'redirect', + url: targetPlayerUrl + }, connectionId || deviceId || undefined); + } else { + await forwardPlayerCommand(slug, { + command: 'redirect', + url: targetPlayerUrl + }, connectionId || deviceId || undefined); + } if (typeof broadcastDashboardState === 'function') { await broadcastDashboardState(); @@ -247,9 +395,16 @@ module.exports = function registerScreenCommandRoutes(app, deps) { commandPayload.blackout = blackoutValue; } - const result = connectionId - ? await forwardPlayerCommand(slug, commandPayload, connectionId) - : await forwardPlayerCommand(slug, commandPayload); + 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)); if (typeof broadcastDashboardState === 'function') { await broadcastDashboardState(); diff --git a/src/web/routes/register.js b/src/web/routes/register.js index 9c87837..6a39c3e 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -15,6 +15,8 @@ const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes'); const registerTimetableRoutes = require('./data-sources/timetables/routes'); const registerSettingsRoutes = require('./settings/background-tasks'); const registerFontRoutes = require('./settings/fonts'); +const registerInternalSyncRoutes = require('./internal/sync'); +const registerScreensRoutes = require('./signage/screens/routes'); const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac'); function registerRoutes(app, deps) { @@ -22,6 +24,22 @@ function registerRoutes(app, deps) { registerAuthAndAccountRoutes(app, deps); registerSignageRoutes(app, deps); registerSettingsAndContentRoutes(app, deps); + registerInternalSyncRoutes(app, { + uploadSyncService: deps.uploadSyncService, + mediaDir: deps.mediaDir + }); + registerScreensRoutes(app, { + pool: deps.pool, + common: deps.common, + pages: deps.pages, + mediaDir: deps.mediaDir, + formatDashboardDate: deps.formatDashboardDate, + buildDashboardState: deps.buildDashboardState, + fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId, + requirePermission: deps.requirePermission, + getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage, + getScreenConnections: deps.playerActionService.getScreenConnections, + }); } function registerAuthAndAccountRoutes(app, deps) { @@ -124,6 +142,7 @@ function registerSignageRoutes(app, deps) { pool: deps.pool, common: deps.common, forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand, + forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl, getScreenConnections: deps.playerActionService.getScreenConnections, isClientNameAvailable: deps.isClientNameAvailable, withClientNameReservation: deps.withClientNameReservation, diff --git a/src/web/routes/signage/clients/routes.js b/src/web/routes/signage/clients/routes.js index 2162834..057b345 100644 --- a/src/web/routes/signage/clients/routes.js +++ b/src/web/routes/signage/clients/routes.js @@ -6,7 +6,7 @@ module.exports = function registerClientsRoutes(app, deps) { const pages = deps.pages; const buildDashboardState = deps.buildDashboardState; const { buildPagination } = require('../../../lib/pagination'); - const { sortRows, createSearchMatcher } = require('../../../lib/list-query'); + const { compareSortValues, createSearchMatcher, getComparableSortValue } = require('../../../lib/list-query'); const requirePermission = deps.requirePermission; const LIST_PAGE_SIZE = 25; @@ -29,13 +29,32 @@ module.exports = function registerClientsRoutes(app, deps) { connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); } }; - if (!accessors[normalizedSortKey]) { - return Array.isArray(clients) ? clients.slice() : []; + function compareValues(leftValue, rightValue) { + return compareSortValues(getComparableSortValue(leftValue), getComparableSortValue(rightValue)); } - return sortRows(clients, function (client) { - return accessors[normalizedSortKey](client); - }, normalizedDirection); + const sortKeys = accessors[normalizedSortKey] + ? [normalizedSortKey] + : ['client']; + + if (sortKeys[0] === 'client') { + sortKeys.push('ip'); + } else if (sortKeys[0] === 'ip') { + sortKeys.push('client'); + } + + return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) { + for (let index = 0; index < sortKeys.length; index += 1) { + const sortKeyName = sortKeys[index]; + const comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient)); + + if (comparison !== 0) { + return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison; + } + } + + return 0; + }); } app.get('/clients', requirePermission('clients.read'), async function (req, res, next) { diff --git a/src/web/routes/signage/playlists/form-view-model.js b/src/web/routes/signage/playlists/form-view-model.js index bc4236d..0356eac 100644 --- a/src/web/routes/signage/playlists/form-view-model.js +++ b/src/web/routes/signage/playlists/form-view-model.js @@ -142,11 +142,14 @@ function getVideoDurationSeconds(slide) { return null; } - const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => { + const videoRegions = Object.keys(parsed).map((key) => parsed[key]).filter((region) => { return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0; }); - const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000; + const duration = videoRegions.reduce((longest, region) => { + const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000; + return regionDuration > longest ? regionDuration : longest; + }, 0); return Number.isFinite(duration) && duration > 0 ? duration : null; } catch (_error) { return null; diff --git a/src/web/routes/signage/screens/routes.js b/src/web/routes/signage/screens/routes.js index 7ff797f..9503888 100644 --- a/src/web/routes/signage/screens/routes.js +++ b/src/web/routes/signage/screens/routes.js @@ -1,6 +1,7 @@ // Screen route registration and dashboard wiring. const fs = require('fs'); +const { screenPlayerUrl } = require('../../../routes/common'); module.exports = function registerScreensRoutes(app, deps) { const pool = deps.pool; @@ -11,6 +12,14 @@ module.exports = function registerScreensRoutes(app, deps) { const { buildPagination } = require('../../../lib/pagination'); const requirePermission = deps.requirePermission; + 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; + } + const LIST_PAGE_SIZE = 25; const batTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.bat'); const shTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.sh'); @@ -19,6 +28,17 @@ module.exports = function registerScreensRoutes(app, deps) { linux: '/downloads/kiosk/pulse-signage-kiosk.sh' }; + function normalizeTargetPlayerUrl(value) { + const normalized = String(value || '').trim().replace(/\/$/, ''); + if (!normalized) { + return null; + } + if (!/^https?:\/\//i.test(normalized)) { + return null; + } + return normalized; + } + function requireQueryPermission(readPermissionKey, editPermissionKey) { return function (req, res, next) { const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey; @@ -58,7 +78,6 @@ module.exports = function registerScreensRoutes(app, deps) { return Object.assign({}, screen, { player_connection_count: playerConnectionCount, - player_url: dashboardScreen && dashboardScreen.player_url ? dashboardScreen.player_url : screen.player_url || null }); }); } @@ -71,6 +90,26 @@ module.exports = function registerScreensRoutes(app, deps) { return common.fetchPlayerPublicBaseUrl(pool); } + async function buildScreenPlayerUrls(screen) { + const playerRegistrations = typeof common.fetchPlayerRegistrations === 'function' + ? await common.fetchPlayerRegistrations(pool) + : []; + + return (Array.isArray(playerRegistrations) ? playerRegistrations : []).filter(function (player) { + return isRecentPlayerRegistration(player, 60); + }).map(function (player) { + const baseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, ''); + const playerUrl = screenPlayerUrl(screen && screen.slug ? screen.slug : '', baseUrl); + return { + identifier: String(player && player.identifier || '').trim(), + public_base_url: baseUrl || null, + player_url: playerUrl || null + }; + }).sort(function (left, right) { + return String(left && left.identifier || '').localeCompare(String(right && right.identifier || ''), undefined, { sensitivity: 'base', numeric: true }); + }); + } + function buildLauncherContent(templatePath, playerUrl) { const template = fs.readFileSync(templatePath, 'utf8'); const normalizedPlayerUrl = String(playerUrl || '').trim(); @@ -100,8 +139,8 @@ module.exports = function registerScreensRoutes(app, deps) { if (!screen) { return res.status(404).send('Screen not found'); } - screen.player_url = await buildPlayerUrl(); - if (screen.player_url) { + screen.player_urls = await buildScreenPlayerUrls(screen); + if (screen.player_urls.length) { screen.launcher_downloads = launcherDownloadPaths; } screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections)); @@ -113,18 +152,9 @@ module.exports = function registerScreensRoutes(app, deps) { const sort = common.getSortQuery(req); const direction = common.getSortDirectionQuery(req); const dashboardState = await buildDashboardState(pool); - const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function' - ? await common.fetchScreenPlayerUrls(pool) - : {}; const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderScreensPage({ - screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []).map(function (screen) { - const slug = String(screen && screen.slug || '').trim(); - const registryUrl = slug && playerUrlsBySlug[slug] ? String(playerUrlsBySlug[slug]).trim() : ''; - return Object.assign({}, screen, { - player_url: screen.player_url || registryUrl || null - }); - }), + screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []), pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { @@ -138,11 +168,8 @@ module.exports = function registerScreensRoutes(app, deps) { if (!screen) { return res.status(404).send('Screen not found'); } - const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function' - ? await common.fetchScreenPlayerUrls(pool) - : {}; - screen.player_url = String(playerUrlsBySlug[screen.slug] || '').trim() || null; - if (screen.player_url) { + screen.player_urls = await buildScreenPlayerUrls(screen); + if (screen.player_urls.length) { screen.launcher_downloads = launcherDownloadPaths; } screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections)); @@ -155,7 +182,8 @@ module.exports = function registerScreensRoutes(app, deps) { app.get('/downloads/kiosk/pulse-signage-kiosk.bat', requirePermission('screens.update'), async function (_req, res, next) { try { - const playerUrl = await buildPlayerUrl(); + const playerUrl = normalizeTargetPlayerUrl(_req.query && _req.query.playerUrl) + || await buildPlayerUrl(); if (!playerUrl) { return res.status(404).send('Player URL is not available yet.'); } @@ -173,7 +201,8 @@ module.exports = function registerScreensRoutes(app, deps) { app.get('/downloads/kiosk/pulse-signage-kiosk.sh', requirePermission('screens.update'), async function (_req, res, next) { try { - const playerUrl = await buildPlayerUrl(); + const playerUrl = normalizeTargetPlayerUrl(_req.query && _req.query.playerUrl) + || await buildPlayerUrl(); if (!playerUrl) { return res.status(404).send('Player URL is not available yet.'); } diff --git a/src/web/views/signage/clients/list.hbs b/src/web/views/signage/clients/list.hbs index 32b7084..5960eb8 100644 --- a/src/web/views/signage/clients/list.hbs +++ b/src/web/views/signage/clients/list.hbs @@ -95,7 +95,7 @@ {{#if clients.length}} {{#each clients}} - +
{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}
@@ -127,33 +127,38 @@ Unknown {{/if}} - {{#if (hasPermission currentUser 'clients.allow')}} + {{#if (hasPermission ../currentUser 'clients.allow')}}
+
+
+
+
+
@@ -183,6 +188,7 @@ +