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() { '
{{title.upper()}}, {{title.title()}}, or {{title.lower()}}.