diff --git a/.gitignore b/.gitignore index fb61e35..6093592 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ media/ !src/web/lib/media/ !src/web/lib/media/** docker-compose.dev.yml +/docker-compose/*.dev.yml +/docker-compose/*.env +/docker-compose/*.env.local +/docker-compose/*.env.remote /dev-demo-seed.js .vscode/ .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ea946..437354c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,49 @@ 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. + +### 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. + +## 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 + +- Player registrations now rely on the device ID only, and the unused player identifier column is removed from the player registry schema. + +### 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 +52,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/README.md b/README.md index aeec578..f0f4aeb 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,38 @@ # Pulse Signage -Pulse Signage is a self-hosted digital signage platform built for teams that want clear, flexible control over what appears on every screen. +Pulse Signage is a self-hosted digital signage platform for teams that want clear, reliable control over the content on every screen. -It gives you one place to manage playlists, slides, templates, screen assignments, announcements, data-driven content, and live player control without handing that workflow to a third-party service. +It gives you one place to publish playlists, slides, announcements, and live updates without handing the workflow to a third-party service. -## Why It Stands Out +## What It Can Do -- Keep signage under your own control. -- Build polished screen experiences with reusable templates and playlists. -- Push announcements, schedules, RSS feeds, API content, and other dynamic content to screens. -- See what is live, what is queued, and what needs attention from a single admin dashboard. -- Use the same system for everyday signage, timed messages, and more structured information displays. +- Run polished screen experiences with playlists, slides, and reusable templates. +- Keep announcements, schedules, RSS feeds, API content, and other live data in sync. +- Manage many screens from a single dashboard. +- Support everyday signage, event messages, lobbies, dashboards, and other always-on displays. +- Keep the player, dashboard, and bridge connected so updates move quickly and consistently. -## What It Handles +## Why It Fits -- Screens and playlist assignments. -- Slides, templates, and canvas sizes. -- Announcements and screen-specific targeting. -- RSS feeds, API sources, and schedule-based content. -- Roles and permissions for admin access. -- Background tasks and scheduled refresh jobs. -- Player onboarding and live playback control. +- It keeps signage under your own control. +- It is built for teams that need screens to stay current without extra manual work. +- It works well for offices, venues, campuses, and operations teams. +- It stays focused on display management instead of trying to be a general-purpose CMS. -## Simple Deployment +## Deploying -Pulse Signage ships with a Docker Compose stack for the published image: +Docker Compose is the recommended way to deploy Pulse Signage. It keeps the web app, player, bridge, and database together in a predictable setup. -- Published-image stack: `docker compose -f docker-compose.yml up -d` +If you want the details, start with the [Compose guide](docker-compose/README.md). -Use this stack when you want to run the tagged image instead of building from source. +## Docs -## Environment File +- [Documentation home](docs/README.md) - the technical reference index. +- [API reference](docs/api.md) - the player HTTP surface and onboarding endpoints. +- [Database schema](docs/schema.md) - the tables and data model the app maintains. +- [WebSocket reference](docs/websocket.md) - the live player and snapshot channels. +- [Compose guide](docker-compose/README.md) - deployment options and service layout. -The compose files read from a root `.env` file. Start by copying `.env.example` to `.env`, then adjust the values for your setup. +## Explore The Docs -The variables are split by who uses them: - -Shared across the stack: - -- `PULSE_SIGNAGE_IMAGE` -- `PULSE_SIGNAGE_SHARED_SECRET` - -Web container: - -- `SESSION_MAX_AGE_DAYS` -- `DEFAULT_ADMIN_USERNAME` -- `DEFAULT_ADMIN_NAME` -- `DEFAULT_ADMIN_PASSWORD` -- `PASSWORD_HASH_ITERATIONS` - -Player container: - -- `PLAYER_PUBLIC_BASE_URL` -- `PLAYER_INTERNAL_BASE_URL` - -MySQL container: - -- `DB_HOST` -- `DB_PORT` -- `DB_NAME` -- `DB_USER` -- `DB_PASSWORD` -- `MYSQL_ROOT_PASSWORD` - -`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD` are shared in practice because both app containers talk to the same database service. - -`PLAYER_INTERNAL_BASE_URL` is used by the web side for server-to-player calls, while `PLAYER_PUBLIC_BASE_URL` is used for browser-facing player links. - -## First-Time Use - -The first startup seeds a default admin account if the database is empty. - -- Username: `admin` -- Password: `admin` - -## In Practice - -Pulse Signage is designed to feel focused and reliable rather than heavy or noisy. It works well when you want a clean internal signage system for offices, venues, dashboards, or any environment where screens need to stay up to date without a lot of manual effort. - -The player and admin pieces stay linked, so changes made in the dashboard can flow out to screens quickly and consistently. \ No newline at end of file +The project includes a dashboard for managing content, a player runtime for rendering screens, an onboarding flow for connecting devices, and a bridge layer for remote screens. If you want to understand how the pieces fit together, the docs above cover the technical details without repeating the project overview. \ No newline at end of file diff --git a/docker-compose/README.md b/docker-compose/README.md new file mode 100644 index 0000000..2cadee4 --- /dev/null +++ b/docker-compose/README.md @@ -0,0 +1,204 @@ +# Docker Compose Setup + +This folder contains the public Docker Compose definitions for Pulse Signage and the remote player stack. + +## Files + +- [local.yml](local.yml) - full public stack with web, player, player bridge, and MySQL. +- [remote.yml](remote.yml) - remote player-only stack for machines that sit behind the player bridge. +- [local.env.example](local.env.example) - sample environment values for the public stack. +- [remote.env.example](remote.env.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` +- `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 + +### `local.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` - shared secret used for request authentication +- `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 + +### `remote.env.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 +- `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 + +## Main Configuration Variables + +| Variable | Used By | Purpose | +| --- | --- | --- | +| `PULSE_SIGNAGE_IMAGE` | all services | Docker image to run for the app services. | +| `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge | 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 | Database name. | +| `DB_USER` | web, player, bridge | Database user. | +| `DB_PASSWORD` | web, player, bridge | Database password. | +| `MYSQL_ROOT_PASSWORD` | mysql | Root password for the local MySQL container. | +| `PLAYER_PUBLIC_BASE_URL` | player | Public URL advertised by the player. | +| `PLAYER_INTERNAL_BASE_URL` | web, player | Internal player URL used by the dashboard and player runtime. | +| `PLAYER_IDENTIFIER` | player | Stable player identifier. | +| `THIN_CLIENT_BASE_URL` | player, remote player | URL of the bridge service. | +| `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_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`. +- 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 `local.env.example` to a local `.env` file for the public stack. +2. Copy `remote.env.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/.env.example b/docker-compose/local.env.example similarity index 86% rename from .env.example rename to docker-compose/local.env.example index 18b2fb4..5d27e8c 100644 --- a/.env.example +++ b/docker-compose/local.env.example @@ -1,10 +1,11 @@ PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest PULSE_SIGNAGE_SHARED_SECRET= +PLAYER_IDENTIFIER=player-local DB_HOST=mysql DB_PORT=3306 -DB_NAME=pulse-signage -DB_USER=pulse-signage +DB_NAME=signage +DB_USER=signage_user DB_PASSWORD=signage_password MYSQL_ROOT_PASSWORD=root_password diff --git a/docker-compose.yml b/docker-compose/local.yml similarity index 73% rename from docker-compose.yml rename to docker-compose/local.yml index 6a533e2..c16e853 100644 --- a/docker-compose.yml +++ b/docker-compose/local.yml @@ -1,3 +1,5 @@ +name: pulse-signage + services: web: image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} @@ -12,13 +14,12 @@ services: DB_PASSWORD: ${DB_PASSWORD:-signage_password} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14} - DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000} DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin} DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin} DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin} PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000} volumes: - - media:/app/media + - pulse-signage:/app/media command: ["node", "src/web.js"] depends_on: mysql: @@ -34,6 +35,7 @@ services: environment: PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081} + PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} DB_HOST: ${DB_HOST:-mysql} DB_PORT: ${DB_PORT:-3306} @@ -41,7 +43,7 @@ services: DB_USER: ${DB_USER:-signage_user} DB_PASSWORD: ${DB_PASSWORD:-signage_password} volumes: - - media:/app/media + - pulse-signage:/app/media command: ["node", "src/player.js"] depends_on: mysql: @@ -49,9 +51,30 @@ services: networks: - pulse_signage + player-bridge: + image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} + restart: unless-stopped + ports: + - "8090:8090" + environment: + PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} + DB_HOST: ${DB_HOST:-mysql} + DB_PORT: ${DB_PORT:-3306} + DB_NAME: ${DB_NAME:-signage} + DB_USER: ${DB_USER:-signage_user} + DB_PASSWORD: ${DB_PASSWORD:-signage_password} + command: ["node", "src/player-bridge/index.js"] + depends_on: + mysql: + condition: service_healthy + networks: + - pulse_signage + mysql: image: mysql:8.4 restart: unless-stopped + ports: + - "3306:3306" environment: MYSQL_DATABASE: ${DB_NAME:-signage} MYSQL_USER: ${DB_USER:-signage_user} @@ -72,9 +95,9 @@ services: volumes: mysql_data: - media: + pulse-signage: networks: pulse_signage: - name: pulse_signage - external: false + name: pulse-signage + external: false \ No newline at end of file diff --git a/docker-compose/remote.env.example b/docker-compose/remote.env.example new file mode 100644 index 0000000..542cd6d --- /dev/null +++ b/docker-compose/remote.env.example @@ -0,0 +1,8 @@ +PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest +PULSE_SIGNAGE_SHARED_SECRET= +PLAYER_IDENTIFIER=player-remote + +PLAYER_PUBLIC_BASE_URL=http://localhost:8081 +THIN_CLIENT_BASE_URL=http://192.168.0.80:8090 + +PLAYER_AGENT_RECONNECT_DELAY_MS=2000 \ No newline at end of file diff --git a/docker-compose/remote.yml b/docker-compose/remote.yml new file mode 100644 index 0000000..457e78c --- /dev/null +++ b/docker-compose/remote.yml @@ -0,0 +1,28 @@ +name: pulse-signage-remote + +services: + + player: + image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} + restart: unless-stopped + ports: + - "8081:8081" + environment: + PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} + THIN_CLIENT_BASE_URL: ${THIN_CLIENT_BASE_URL:-} + PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} + volumes: + - pulse-signage:/app/media + command: ["node", "src/player.js"] + networks: + - pulse_signage + + + +volumes: + pulse-signage: + +networks: + pulse_signage: + name: pulse-signage-remote + external: false \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..4fb0d23 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,13 @@ +# Documentation + +This folder contains the technical reference material for Pulse Signage. + +## What’s Here + +- [API reference](api.md) - the player HTTP surface and onboarding endpoints. +- [Database schema](schema.md) - the tables and data model used by the app. +- [WebSocket reference](websocket.md) - the live player and snapshot channels. + +## How To Read It + +If you want the big picture first, start with the main [project README](../README.md). It gives a plain overview of what Pulse Signage does, while the pages in this folder explain how the pieces work. \ No newline at end of file diff --git a/docs/schema.md b/docs/schema.md index df18fa4..51589fc 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -112,7 +112,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ## Devices - `d_players` - player registry and connection metadata. -- `d_screens` - screen records and playlist/player assignment. +- `d_screens` - screen records and playlist assignment. - `d_onboarding_devices` - device-to-screen bindings and onboarded client names. ## Announcements @@ -122,18 +122,18 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ### `d_players` -- `device_id`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at` -- `device_id` is the primary key. +- `id`, `identifier`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at` +- `id` is the primary key. +- `identifier` is unique and is the stable player identity used by the app. ### `d_screens` -- `id`, `name`, `slug`, `playlist_id`, `player_id`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `id`, `name`, `slug`, `playlist_id`, `created_at`, `created_by`, `modified_at`, `modified_by` - `slug` is unique. - Foreign keys: - `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL` - - `player_id` -> `d_players.id` with `ON DELETE RESTRICT` -- `player_id` is required and defaults to `'1'` for the current singleton-player model. +- Screens are no longer tied to a player foreign key directly; player registration and live connection metadata are tracked separately in `d_players`. ### `d_onboarding_devices` @@ -275,7 +275,6 @@ erDiagram C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules - D_PLAYERS ||--o{ D_SCREENS : assigned_to C_PLAYLISTS ||--o{ D_SCREENS : uses D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds D_ANNOUNCEMENTS ||--o{ D_ANNOUNCEMENT_SCREENS : targets diff --git a/package.json b/package.json index 48dfc24..cb2960b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.5.14", + "version": "2.6.5", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/common.js b/src/common.js index 41e29d9..382db83 100644 --- a/src/common.js +++ b/src/common.js @@ -89,6 +89,7 @@ module.exports = { fetchPlayerPublicBaseUrl: data.fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord: data.fetchScreenPlayerRecord, fetchTemplateById: data.fetchTemplateById, + fetchPlayerRegistrations: data.fetchPlayerRegistrations, fetchSlideById: data.fetchSlideById, fetchTemplatesData: data.fetchTemplatesData, fetchCanvasSizesData: data.fetchCanvasSizesData, diff --git a/src/data/admin.js b/src/data/admin.js index 61bb47e..2aa0b4b 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -22,10 +22,9 @@ async function fetchAdminData(pool) { ORDER BY s.id DESC `); const [screens] = await pool.query(` - SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name, pl.public_base_url + SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name FROM d_screens s LEFT JOIN c_playlists p ON p.id = s.playlist_id - LEFT JOIN d_players pl ON pl.device_id = s.player_id ORDER BY s.id DESC `); const [playlistSlides] = await pool.query(` diff --git a/src/data/index.js b/src/data/index.js index c6b066c..fa0a0ab 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -7,7 +7,8 @@ const { fetchPlaylistById } = require('./playlists'); const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules'); const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); -const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord } = require('./screens'); +const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens'); +const { fetchPlayerRegistrations } = require('./player-registry'); const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates'); const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes'); const { fetchSlideById, buildSlidePayload } = require('./slides'); @@ -63,6 +64,8 @@ module.exports = { fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, + fetchPlayerRecordByIdentifier, + fetchPlayerRegistrations, fetchTemplateById, fetchSlideById, fetchTemplatesData, diff --git a/src/data/player-registry.js b/src/data/player-registry.js new file mode 100644 index 0000000..3a33d87 --- /dev/null +++ b/src/data/player-registry.js @@ -0,0 +1,140 @@ +function normalizeDeviceId(value) { + return String(value || '') + .trim() + .replace(/[^a-zA-Z0-9_-]/g, '') + .slice(0, 128); +} + +function normalizeBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); +} + +function normalizeIdentifier(value) { + return String(value || '').trim().slice(0, 255); +} + +async function columnExists(pool, tableName, columnName) { + const [rows] = await pool.query( + `SELECT COUNT(*) AS column_count + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ?`, + [tableName, columnName] + ); + + return Number(rows && rows[0] && rows[0].column_count) > 0; +} + +async function fetchPlayerRegistrations(pool) { + if (!pool) { + return []; + } + + 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 getConfiguredPlayerIdentifier() { + return normalizeDeviceId(process.env.PLAYER_IDENTIFIER || process.env.PLAYER_DEVICE_ID || ''); +} + +async function resolvePlayerRegistration(pool, identifier) { + const normalizedIdentifier = normalizeDeviceId(identifier); + if (!pool || !normalizedIdentifier) { + return null; + } + + const hasIdentifierColumn = await columnExists(pool, 'd_players', 'identifier'); + const hasDeviceIdColumn = await columnExists(pool, 'd_players', 'device_id'); + const identifierColumn = hasIdentifierColumn ? 'identifier' : (hasDeviceIdColumn ? 'device_id' : ''); + if (!identifierColumn) { + return null; + } + + const selectIdExpression = hasIdentifierColumn ? 'id' : 'NULL AS id'; + const selectIdentifierExpression = hasIdentifierColumn ? 'identifier' : 'device_id AS identifier'; + const orderByExpression = hasIdentifierColumn ? 'modified_at DESC, id DESC' : 'modified_at DESC'; + + const [rows] = await pool.query( + `SELECT ${selectIdExpression}, ${selectIdentifierExpression}, public_base_url, internal_base_url, last_seen_at + FROM d_players + WHERE ${identifierColumn} = ? + LIMIT 1`, + [normalizedIdentifier] + ); + + if (rows[0]) { + return rows[0]; + } + + const [fallbackRows] = await pool.query( + `SELECT ${selectIdExpression}, ${selectIdentifierExpression}, public_base_url, internal_base_url, last_seen_at + FROM d_players + ORDER BY ${orderByExpression} + LIMIT 1` + ); + + return fallbackRows[0] || null; +} + +async function upsertPlayerRegistration(pool, options) { + const identifier = normalizeDeviceId(options && (options.identifier || options.deviceId)); + const publicBaseUrl = normalizeBaseUrl(options && options.publicBaseUrl); + const internalBaseUrl = normalizeBaseUrl(options && options.internalBaseUrl); + + if (!pool || !identifier) { + return null; + } + + await pool.query( + `INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE + public_base_url = VALUES(public_base_url), + internal_base_url = VALUES(internal_base_url), + last_seen_at = CURRENT_TIMESTAMP, + modified_at = CURRENT_TIMESTAMP`, + [identifier, publicBaseUrl || null, internalBaseUrl || null] + ); + + return resolvePlayerRegistration(pool, identifier); +} + +async function recordPlayerHeartbeat(pool, options) { + const identifier = normalizeDeviceId(options && (options.identifier || options.deviceId)); + const publicBaseUrl = normalizeBaseUrl(options && options.publicBaseUrl); + const internalBaseUrl = normalizeBaseUrl(options && options.internalBaseUrl); + + if (!pool || !identifier) { + return null; + } + + await pool.query( + `INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE + public_base_url = COALESCE(VALUES(public_base_url), public_base_url), + internal_base_url = COALESCE(VALUES(internal_base_url), internal_base_url), + last_seen_at = CURRENT_TIMESTAMP, + modified_at = CURRENT_TIMESTAMP`, + [identifier, publicBaseUrl || null, internalBaseUrl || null] + ); + + return resolvePlayerRegistration(pool, identifier); +} + +module.exports = { + normalizeDeviceId: normalizeDeviceId, + normalizeIdentifier: normalizeIdentifier, + getConfiguredPlayerIdentifier: getConfiguredPlayerIdentifier, + fetchPlayerRegistrations: fetchPlayerRegistrations, + resolvePlayerRegistration: resolvePlayerRegistration, + upsertPlayerRegistration: upsertPlayerRegistration, + recordPlayerHeartbeat: recordPlayerHeartbeat +}; \ No newline at end of file diff --git a/src/data/screens.js b/src/data/screens.js index cff9207..69c25a0 100644 --- a/src/data/screens.js +++ b/src/data/screens.js @@ -13,6 +13,27 @@ function normalizePlayerBaseUrl(value) { return String(value || '').trim().replace(/\/$/, ''); } +function getConfiguredPlayerIdentifier() { + return String(process.env.PLAYER_IDENTIFIER || process.env.PLAYER_DEVICE_ID || '').trim() || null; +} + +async function fetchPlayerRecordByIdentifier(pool, identifier) { + const normalizedIdentifier = String(identifier || '').trim(); + if (!normalizedIdentifier) { + return null; + } + + const [rows] = await pool.query( + `SELECT id, identifier, public_base_url, internal_base_url, last_seen_at + FROM d_players + WHERE identifier = ? + LIMIT 1`, + [normalizedIdentifier] + ); + + return rows[0] || null; +} + function buildScreenPlayerUrl(screen, fallbackBaseUrl) { const slug = String(screen && screen.slug || '').trim(); if (!slug) { @@ -28,20 +49,21 @@ function buildScreenPlayerUrl(screen, fallbackBaseUrl) { } async function fetchScreenPlayerUrls(pool) { + const playerBaseUrl = await fetchPlayerPublicBaseUrl(pool); + if (!playerBaseUrl) { + return {}; + } + const [rows] = await pool.query(` - SELECT s.slug, p.public_base_url - FROM d_screens s - LEFT JOIN d_players p ON p.device_id = s.player_id - WHERE p.public_base_url IS NOT NULL - AND TRIM(p.public_base_url) <> '' - ORDER BY p.modified_at DESC, s.slug ASC + SELECT slug + FROM d_screens + ORDER BY slug ASC `); const playerUrls = {}; rows.forEach(function (row) { const slug = String(row && row.slug || '').trim(); - const publicBaseUrl = normalizePlayerBaseUrl(row && row.public_base_url); - const playerUrl = buildScreenPlayerUrl({ slug: slug }, publicBaseUrl); + const playerUrl = buildScreenPlayerUrl({ slug: slug }, playerBaseUrl); if (slug && playerUrl && !playerUrls[slug]) { playerUrls[slug] = playerUrl; } @@ -51,27 +73,32 @@ async function fetchScreenPlayerUrls(pool) { } async function fetchPlayerPublicBaseUrl(pool) { + const configuredPlayerIdentifier = getConfiguredPlayerIdentifier(); const [rows] = await pool.query(` SELECT public_base_url FROM d_players - WHERE device_id = '1' + WHERE identifier = ? LIMIT 1 - `); + `, [configuredPlayerIdentifier]); return normalizePlayerBaseUrl(rows[0] && rows[0].public_base_url) || null; } async function fetchScreenPlayerRecord(pool, slug) { - const [rows] = await pool.query(` - SELECT p.device_id, p.public_base_url, p.internal_base_url, p.last_seen_at - FROM d_screens s - JOIN d_players p ON p.device_id = s.player_id - WHERE s.slug = ? - ORDER BY p.modified_at DESC, p.device_id ASC - LIMIT 1 - `, [slug]); + if (slug) { + const [rows] = await pool.query( + `SELECT slug + FROM d_screens + WHERE slug = ? + LIMIT 1`, + [slug] + ); + if (!rows.length) { + return null; + } + } - return rows[0] || null; + return fetchPlayerRecordByIdentifier(pool, getConfiguredPlayerIdentifier()); } async function uniqueScreenSlug(pool, baseSlug, excludeId) { @@ -112,6 +139,8 @@ async function fetchScreenEditData(pool) { module.exports = { slugify, normalizePlayerBaseUrl, + getConfiguredPlayerIdentifier, + fetchPlayerRecordByIdentifier, buildScreenPlayerUrl, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, diff --git a/src/db/index.js b/src/db/index.js index 44d84a9..2ff57a4 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -1,6 +1,18 @@ // Snapshot only: keep this file aligned with the current schema state. async function ensureSchema(pool, options) { - await pool.query(` + const schemaLockName = 'pulse_signage_schema_lock'; + const schemaConnection = await pool.getConnection(); + + try { + pool = schemaConnection; + + const [lockRows] = await pool.query('SELECT GET_LOCK(?, 120) AS lock_acquired', [schemaLockName]); + if (!Number(lockRows && lockRows[0] && lockRows[0].lock_acquired)) { + throw new Error('Unable to acquire the schema migration lock.'); + } + + try { + await pool.query(` CREATE TABLE IF NOT EXISTS c_canvas_sizes ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, @@ -116,13 +128,24 @@ async function ensureSchema(pool, options) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool.query(` + CREATE TABLE IF NOT EXISTS d_players ( + id INT AUTO_INCREMENT PRIMARY KEY, + identifier VARCHAR(128) NOT NULL UNIQUE, + public_base_url VARCHAR(512) NULL, + internal_base_url VARCHAR(512) NULL, + last_seen_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` CREATE TABLE IF NOT EXISTS d_screens ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL UNIQUE, playlist_id INT NULL, - player_id INT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -360,8 +383,15 @@ async function ensureSchema(pool, options) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - const { runMigrations } = require('./migrations'); - await runMigrations(pool, options); + const { runMigrations } = require('./migrations'); + await runMigrations(pool, options); + } finally { + await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () { + }); + } + } finally { + schemaConnection.release(); + } } module.exports = { diff --git a/src/db/migrations.js b/src/db/migrations.js index 9a2c262..82b12d4 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -19,10 +19,6 @@ const VERSIONED_MIGRATIONS = [ `); } - // Seed the singleton player row used by the current one-player model. - // For multi-player support, this seed and the hardcoded screen_id/player_id mapping will need to be replaced. - await pool.query(`INSERT IGNORE INTO d_players (device_id) VALUES ('1')`); - // Store the player pointer on screens so we can resolve the player without needing a player-side screen_id. // This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'. if (!(await columnExists(pool, 'd_screens', 'player_id'))) { @@ -212,6 +208,93 @@ const VERSIONED_MIGRATIONS = [ run: async function (pool) { await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio'); } + }, + { + version: '2.6.2', + label: 'v2.6.2 player identity schema', + run: async function (pool) { + if (!(await columnExists(pool, 'd_players', 'device_id'))) { + await dropForeignKeyIfExists(pool, 'd_screens', 'player_id'); + + if (!(await columnExists(pool, 'd_players', 'identifier'))) { + await ensureColumn(pool, 'd_players', 'identifier', 'VARCHAR(128) NOT NULL UNIQUE', 'id'); + } + if (!(await columnExists(pool, 'd_players', 'id'))) { + await ensureColumn(pool, 'd_players', 'id', 'INT NOT NULL AUTO_INCREMENT', null); + await pool.query('ALTER TABLE d_players ADD PRIMARY KEY (id)'); + } else if (!(await columnIsAutoIncrement(pool, 'd_players', 'id'))) { + await pool.query('ALTER TABLE d_players MODIFY COLUMN id INT NOT NULL AUTO_INCREMENT'); + } + await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id'); + return; + } + + await dropForeignKeyIfExists(pool, 'd_screens', 'player_id'); + + await pool.query('DROP TABLE IF EXISTS d_players_rebuild'); + await pool.query(` + CREATE TABLE d_players_rebuild ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + identifier VARCHAR(128) NOT NULL UNIQUE, + public_base_url VARCHAR(512) NULL, + internal_base_url VARCHAR(512) NULL, + last_seen_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + await pool.query(` + INSERT INTO d_players_rebuild (identifier, public_base_url, internal_base_url, last_seen_at, created_at, modified_at) + SELECT DISTINCT + device_id AS identifier, + public_base_url, + internal_base_url, + last_seen_at, + created_at, + modified_at + FROM d_players + ORDER BY COALESCE(created_at, modified_at, device_id), device_id + `); + + await pool.query('DROP TEMPORARY TABLE IF EXISTS d_player_id_map'); + await pool.query(` + CREATE TEMPORARY TABLE d_player_id_map AS + SELECT old_players.device_id AS old_device_id, rebuilt_players.id AS new_player_id + FROM d_players old_players + JOIN d_players_rebuild rebuilt_players ON rebuilt_players.identifier = old_players.device_id + `); + + await pool.query( + `UPDATE d_screens s + JOIN d_player_id_map m ON m.old_device_id = CAST(s.player_id AS CHAR) + SET s.player_id = m.new_player_id + WHERE s.player_id IS NOT NULL` + ); + + await pool.query('DROP TABLE d_players'); + await pool.query('RENAME TABLE d_players_rebuild TO d_players'); + + await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id'); + } + }, + { + version: '2.6.3', + label: 'v2.6.3 screen-player fk removal', + run: async function (pool) { + await dropForeignKeyIfExists(pool, 'd_screens', 'player_id'); + if (await columnExists(pool, 'd_screens', 'player_id')) { + await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id'); + } + } + }, + { + version: '2.6.4', + label: 'v2.6.4 drop screen player id', + run: async function (pool) { + await dropForeignKeyIfExists(pool, 'd_screens', 'player_id'); + await dropColumnIfExists(pool, 'd_screens', 'player_id'); + } } ]; @@ -228,6 +311,20 @@ async function columnExists(pool, tableName, columnName) { return Number(rows && rows[0] && rows[0].column_count) > 0; } +async function columnIsAutoIncrement(pool, tableName, columnName) { + const [rows] = await pool.query( + `SELECT COUNT(*) AS auto_increment_count + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ? + AND EXTRA LIKE '%auto_increment%'`, + [tableName, columnName] + ); + + return Number(rows && rows[0] && rows[0].auto_increment_count) > 0; +} + async function ensureColumn(pool, tableName, columnName, columnDefinition, afterColumn) { if (await columnExists(pool, tableName, columnName)) { return; @@ -289,6 +386,32 @@ async function ensureForeignKey(pool, tableName, constraintName, columnName, ref } } +async function dropForeignKeyIfExists(pool, tableName, columnName) { + const [rows] = await pool.query( + `SELECT CONSTRAINT_NAME AS constraint_name + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ? + AND REFERENCED_TABLE_NAME IS NOT NULL + LIMIT 1`, + [tableName, columnName] + ); + + const constraintName = String(rows && rows[0] && rows[0].constraint_name || '').trim(); + if (!constraintName) { + return; + } + + try { + await pool.query('ALTER TABLE ' + tableName + ' DROP FOREIGN KEY ' + constraintName); + } catch (error) { + if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) { + throw error; + } + } +} + async function dropColumnIfExists(pool, tableName, columnName) { if (await columnExists(pool, tableName, columnName)) { try { @@ -443,9 +566,20 @@ async function runMigrations(pool, options) { // Only run migrations that are newer than the installed schema version and not beyond the app version. const targetVersion = String(appVersion || '0.0.0').trim(); const currentVersion = String(options && options.currentVersion || '0.0.0').trim(); + const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id'); + const screenPlayerColumnPresent = await columnExists(pool, 'd_screens', 'player_id'); + let effectiveCurrentVersion = currentVersion; + + if (!legacyPlayerSchemaPresent && compareVersions(effectiveCurrentVersion, '2.1.0') < 0) { + effectiveCurrentVersion = '2.1.0'; + } + + if (!screenPlayerColumnPresent && compareVersions(effectiveCurrentVersion, '2.6.3') < 0) { + effectiveCurrentVersion = '2.6.3'; + } for (const migration of VERSIONED_MIGRATIONS) { - if (compareVersions(migration.version, currentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) { + if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) { await migration.run(pool); } } diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js new file mode 100644 index 0000000..5c84df9 --- /dev/null +++ b/src/player-bridge/index.js @@ -0,0 +1,878 @@ +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 resolveWebBaseUrl(req) { + 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) { + 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 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 [registeredPlayers] = await pool.query( + `SELECT identifier, public_base_url + FROM d_players + ORDER BY modified_at DESC, identifier ASC` + ); + const targetPlayers = Array.isArray(registeredPlayers) ? registeredPlayers : []; + + 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 (player) { + const baseUrl = normalizeProxyBaseUrl(player && player.public_base_url); + if (!baseUrl) { + return { ok: false, status: 502, error: 'Player base URL is not configured.' }; + } + + const requestBody = Object.assign({}, payload, connectionId ? { connectionId: connectionId } : {}); + const response = await fetch(new URL(`/api/screens/${encodeURIComponent(slug)}/commands`, baseUrl).toString(), { + method: 'POST', + headers: Object.assign({ + 'content-type': 'application/json', + Accept: 'application/json' + }, createRequestAuthHeaders({ + method: 'POST', + pathname: `/api/screens/${encodeURIComponent(slug)}/commands`, + body: requestBody + })), + body: JSON.stringify(requestBody) + }); + + const text = await response.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch (_error) { + parsed = { ok: response.ok, raw: text }; + } + + return Object.assign({ + ok: response.ok, + status: response.status, + playerIdentifier: String(player && player.identifier || '').trim(), + playerUrl: baseUrl + }, parsed && typeof parsed === 'object' ? parsed : {}); + })); + + 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); + 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)) { + socket.destroy(); + return; + } + + playersWs.handleUpgrade(request, socket, head, function (ws) { + 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 (socket.playerDeviceId) { + const current = playerSockets.get(socket.playerDeviceId); + if (current === socket) { + playerSockets.delete(socket.playerDeviceId); + } + } + }); + + socket.on('error', function () { + if (socket.playerDeviceId) { + const current = playerSockets.get(socket.playerDeviceId); + if (current === socket) { + playerSockets.delete(socket.playerDeviceId); + } + } + }); + }); + + 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 }; + +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..c7949b8 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,151 @@ 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()); + + 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; + } + } + + 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 +190,151 @@ 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 () { + socket.send(JSON.stringify({ + type: 'register', + deviceId: PLAYER_DEVICE_ID, + publicBaseUrl: PLAYER_PUBLIC_BASE_URL, + internalBaseUrl: PLAYER_INTERNAL_BASE_URL + })); + + triggerWebMediaSync().then(function (success) { + webMediaSyncTriggered = Boolean(success); + }).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) { + triggerWebMediaSync().then(function (success) { + webMediaSyncTriggered = Boolean(success); + }).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; + } + }; + } + + 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 +357,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/index.js b/src/player/onboarding/index.js index df4256b..6f38572 100644 --- a/src/player/onboarding/index.js +++ b/src/player/onboarding/index.js @@ -1,8 +1,10 @@ // Player onboarding routes and signup flow helpers. +const express = require('express'); const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check'); const { createStyledQrCodeSvg } = require('#src/data/qr-code'); -const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth'); +const { getSharedSecret, verifyPageAuthToken, createRequestAuthHeaders } = require('#src/request-auth'); +const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry'); const { isTransientDbError } = require('./store'); const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000; @@ -133,7 +135,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam }); } -async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl, screenSlug) { +async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl) { const normalizedDeviceId = normalizeDeviceId(deviceId); const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, ''); const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, ''); @@ -141,18 +143,11 @@ async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalB return null; } - await pool.query( - `INSERT INTO d_players (device_id, public_base_url, internal_base_url, last_seen_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ON DUPLICATE KEY UPDATE public_base_url = VALUES(public_base_url), internal_base_url = VALUES(internal_base_url), last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP`, - [normalizedDeviceId, normalizedPublicBaseUrl || null, normalizedInternalBaseUrl || null] - ); - - return { - device_id: normalizedDeviceId, - public_base_url: normalizedPublicBaseUrl || null, - internal_base_url: normalizedInternalBaseUrl || null - }; + return upsertPlayerRegistrationRecord(pool, { + identifier: normalizedDeviceId, + publicBaseUrl: normalizedPublicBaseUrl || null, + internalBaseUrl: normalizedInternalBaseUrl || null + }); } async function bindPlayerToScreen(pool, deviceId, screenSlug) { @@ -167,25 +162,9 @@ async function bindPlayerToScreen(pool, deviceId, screenSlug) { return null; } - const screenId = Number(screenRows[0].id); - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - // Current model: every screen binds to the shared player row '1'. - // If we introduce multiple players, resolve the correct player row here instead of hardcoding it. - await connection.query("UPDATE d_screens SET player_id = '1', modified_at = CURRENT_TIMESTAMP WHERE id = ?", [screenId]); - await connection.commit(); - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - return { - screen_id: screenId, - screen_slug: normalizedScreenSlug, - player_id: '1' + screen_id: Number(screenRows[0].id), + screen_slug: normalizedScreenSlug }; } @@ -224,13 +203,66 @@ function registerPlayerOnboardingRoutes(app, options) { const common = options && options.common ? options.common : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null; + const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); + const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId); - if (!app || !pool || !common || !playerRuntime) { - throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, and playerRuntime.'); + if (!app || !common) { + throw new Error('registerPlayerOnboardingRoutes requires app and common.'); + } + + if (!thinClientBaseUrl && (!pool || !playerRuntime)) { + throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless thinClientBaseUrl is configured.'); } const sharedSecret = getSharedSecret(); + async function fetchThinClient(req, pathname, options) { + if (!thinClientBaseUrl) { + return null; + } + + const requestOptions = options && typeof options === 'object' ? options : {}; + const method = String(requestOptions.method || req.method || 'GET').trim().toUpperCase(); + const body = Object.prototype.hasOwnProperty.call(requestOptions, 'body') ? requestOptions.body : undefined; + const requestPathname = String(pathname || '').split('?')[0]; + const headers = Object.assign({}, requestOptions.headers || {}, createRequestAuthHeaders({ + method: method, + pathname: requestPathname, + body: body + })); + + if (req.headers['x-pulse-page-auth']) { + headers['x-pulse-page-auth'] = String(req.headers['x-pulse-page-auth']).trim(); + } + if (requestOptions.contentType) { + headers['content-type'] = requestOptions.contentType; + } + + return fetch(new URL(pathname, thinClientBaseUrl).toString(), { + method: method, + headers: headers, + body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body + }); + } + + async function readJsonResponse(response) { + if (!response) { + return null; + } + + const contentType = String(response.headers && typeof response.headers.get === 'function' ? response.headers.get('content-type') : '').toLowerCase(); + if (contentType.indexOf('application/json') === -1 && contentType.indexOf('+json') === -1) { + return null; + } + + try { + return await response.json(); + } catch (_error) { + return null; + } + } + function requireOnboardingPageAuth(req, res, next) { if (!sharedSecret) { return next(); @@ -254,7 +286,7 @@ function registerPlayerOnboardingRoutes(app, options) { app.get('/onboard', async function (req, res, next) { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); try { - res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim())); + res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || playerDeviceId || '').trim())); } catch (error) { next(error); } @@ -272,15 +304,32 @@ function registerPlayerOnboardingRoutes(app, options) { next(); }, async function (req, res, next) { try { - const status = await getOnboardingStatus(pool, req.query.deviceId); + const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId; + if (thinClientBaseUrl) { + const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), { + method: 'GET' + }); + if (!response) { + return res.status(502).json({ error: 'Player bridge unavailable.' }); + } + res.status(response.status); + const payload = await readJsonResponse(response); + if (!payload) { + return res.status(502).json({ error: 'Player bridge returned an invalid response.' }); + } + payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null; + return res.json(payload); + } + + const status = await getOnboardingStatus(pool, deviceId); res.json({ - deviceId: normalizeDeviceId(req.query.deviceId), + 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 ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null + playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null }); } catch (error) { next(error); @@ -289,6 +338,16 @@ function registerPlayerOnboardingRoutes(app, options) { app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) { try { + if (thinClientBaseUrl) { + const response = await fetchThinClient(_req, '/api/onboarding/screens', { + method: 'GET' + }); + res.status(response.status); + const text = await response.text(); + res.type(response.headers.get('content-type') || 'application/json'); + return res.send(text); + } + const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC'); res.json({ screens: rows }); } catch (error) { @@ -298,11 +357,11 @@ function registerPlayerOnboardingRoutes(app, options) { app.get('/api/onboarding/qr', async function (req, res, next) { try { - const deviceId = normalizeDeviceId(req.query.deviceId); + const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId; if (!deviceId) { return res.status(400).json({ error: 'Device ID is required' }); } - const onboardingUrl = `${getPublicBaseUrl(req, options && options.playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; + const onboardingUrl = `${getPublicBaseUrl(req, playerPublicBaseUrl)}/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'); @@ -312,9 +371,9 @@ function registerPlayerOnboardingRoutes(app, options) { } }); - app.post('/api/onboarding', requireOnboardingPageAuth, async function (req, res, next) { + app.post('/api/onboarding', requireOnboardingPageAuth, express.json(), async function (req, res, next) { try { - const deviceId = normalizeDeviceId(req.body && req.body.deviceId); + const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || playerDeviceId; const clientName = String((req.body && req.body.clientName) || '').trim(); const screenSlug = String((req.body && req.body.screenSlug) || '').trim(); const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId); @@ -322,6 +381,30 @@ function registerPlayerOnboardingRoutes(app, options) { res.set('Retry-After', String(retryAfterSeconds)); return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' }); } + + if (thinClientBaseUrl) { + const forwardedBody = Object.assign({}, req.body || {}, { + deviceId: deviceId + }); + const response = await fetch(new URL('/api/onboarding', thinClientBaseUrl).toString(), { + method: 'POST', + headers: Object.assign({ + 'content-type': 'application/json' + }, createRequestAuthHeaders({ + method: 'POST', + pathname: '/api/onboarding', + body: forwardedBody + }), req.headers['x-pulse-page-auth'] ? { 'x-pulse-page-auth': String(req.headers['x-pulse-page-auth']).trim() } : {}), + body: JSON.stringify(forwardedBody) + }); + res.status(response.status); + const payload = await readJsonResponse(response); + if (!payload) { + return res.status(502).json({ error: 'Player bridge returned an invalid response.' }); + } + payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`; + return res.json(payload); + } if (!deviceId) { return res.status(400).json({ error: 'Device ID is required' }); } @@ -340,11 +423,14 @@ function registerPlayerOnboardingRoutes(app, options) { screenId: status && status.screen_id ? status.screen_id : null, screenSlug: status ? status.screen_slug : screenSlug, screenName: status && status.screen_name ? status.screen_name : null, - playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`, + playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`, queued: Boolean(status && status.queued) }); } catch (error) { - next(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.' + }); } }); } diff --git a/src/player/onboarding/player-onboarding-form.script.html b/src/player/onboarding/player-onboarding-form.script.html index 3c1a8db..e82e2e7 100644 --- a/src/player/onboarding/player-onboarding-form.script.html +++ b/src/player/onboarding/player-onboarding-form.script.html @@ -39,8 +39,14 @@ return screens; }); } - if (!deviceId) { setMessage("Missing device id. Scan the QR code from the player screen again."); return; } - try { window.localStorage.setItem(deviceKey, deviceId); } catch (_error) {} + try { + if (!deviceId) { + deviceId = window.localStorage.getItem(deviceKey) || ""; + } + if (deviceId) { + window.localStorage.setItem(deviceKey, deviceId); + } + } catch (_error) {} loadScreens().then(function () { try { var storedClientName = window.localStorage.getItem(clientNameKey) || ""; @@ -58,10 +64,14 @@ if (!clientName) { setMessage("Client name is required."); return; } if (!screenSlug) { setMessage("Screen is required."); return; } setMessage("Saving client..."); + var payload = { clientName: clientName, screenSlug: screenSlug }; + if (deviceId) { + payload.deviceId = deviceId; + } fetch("/api/onboarding", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug }) + body: JSON.stringify(payload) }) .then(function (response) { if (response.ok) { diff --git a/src/player/onboarding/player-onboarding-landing.script.html b/src/player/onboarding/player-onboarding-landing.script.html index 65c19c3..a446aa0 100644 --- a/src/player/onboarding/player-onboarding-landing.script.html +++ b/src/player/onboarding/player-onboarding-landing.script.html @@ -126,7 +126,7 @@ if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; } if (storedClientName && localForm) { var clientNameInput = localForm.querySelector("input[name=\"clientName\"]"); - if (clientNameInput && !clientNameInput.value) { clientNameInput.value = storedClientName; } + if (clientNameInput) { clientNameInput.value = storedClientName; } } if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; } } catch (_error) {} diff --git a/src/player/routes.js b/src/player/routes.js index 17fd1dd..8b3d986 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -3,7 +3,7 @@ const fs = require('fs'); const express = require('express'); const path = require('path'); -const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth'); +const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth'); const { buildThumbnailPreviewData } = require('./thumbnail-preview'); const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED']; @@ -12,6 +12,17 @@ function isTransientDbError(error) { return Boolean(error && TRANSIENT_DB_ERROR_CODES.indexOf(String(error.code || '').trim()) !== -1); } +function isBridgeFetchError(error) { + const message = String(error && error.message || '').toLowerCase(); + return Boolean(error && ( + message.indexOf('fetch failed') !== -1 || + message.indexOf('network error') !== -1 || + message.indexOf('econnreset') !== -1 || + message.indexOf('econnrefused') !== -1 || + message.indexOf('enotfound') !== -1 + )); +} + function registerPlayerRoutes(app, options) { const pool = options && options.pool ? options.pool : null; const common = options && options.common ? options.common : null; @@ -22,14 +33,68 @@ function registerPlayerRoutes(app, options) { const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null; const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); - const playerIdentifier = String(options && options.playerIdentifier || '1').trim() || '1'; + const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); + const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null; - if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) { - throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and rtmpStreamService.'); + if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) { + throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.'); + } + + if (!thinClientBaseUrl && (!pool || !playerPlaylistService)) { + throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless thinClientBaseUrl is configured.'); } const sharedSecret = getSharedSecret(); + async function fetchThinClient(req, pathname, options) { + if (!thinClientBaseUrl) { + return null; + } + + const requestOptions = options && typeof options === 'object' ? options : {}; + const method = String(requestOptions.method || req.method || 'GET').trim().toUpperCase(); + const body = Object.prototype.hasOwnProperty.call(requestOptions, 'body') ? requestOptions.body : undefined; + const requestPathname = String(pathname || '').split('?')[0]; + const headers = Object.assign({}, requestOptions.headers || {}, createRequestAuthHeaders({ + method: method, + pathname: requestPathname, + body: body + })); + + if (req.headers['x-pulse-page-auth']) { + headers['x-pulse-page-auth'] = String(req.headers['x-pulse-page-auth']).trim(); + } + if (req.headers['if-none-match']) { + headers['if-none-match'] = String(req.headers['if-none-match']).trim(); + } + if (requestOptions.contentType) { + headers['content-type'] = requestOptions.contentType; + } + + return fetch(new URL(pathname, thinClientBaseUrl).toString(), { + method: method, + headers: headers, + body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body + }); + } + + async function readJsonResponse(response) { + if (!response) { + return null; + } + + const contentType = String(response.headers && typeof response.headers.get === 'function' ? response.headers.get('content-type') : '').toLowerCase(); + if (contentType.indexOf('application/json') === -1 && contentType.indexOf('+json') === -1) { + return null; + } + + try { + return await response.json(); + } catch (_error) { + return null; + } + } + function requirePageAuth(allowedScopes) { return function (req, res, next) { if (!sharedSecret) { @@ -114,6 +179,26 @@ function registerPlayerRoutes(app, options) { }); app.get('/api/media/config', requireRequestAuth, function (_req, res) { + if (thinClientBaseUrl) { + void fetch(new URL('/api/media/config', thinClientBaseUrl).toString(), { + method: 'GET', + headers: createRequestAuthHeaders({ + method: 'GET', + pathname: '/api/media/config' + }) + }).then(async function (response) { + res.status(response.status); + const contentType = response.headers.get('content-type'); + if (contentType) { + res.type(contentType); + } + res.send(await response.text()); + }).catch(function (_error) { + res.status(502).json({ error: 'Thin client unavailable.' }); + }); + return; + } + res.json({ mediaDir: mediaDir, uploadDir: path.join(mediaDir, 'uploads') @@ -213,9 +298,35 @@ function registerPlayerRoutes(app, options) { app.get('/screen/:slug', function (req, res) { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Pragma', 'no-cache'); + if (thinClientBaseUrl) { + const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(req.params.slug || '').trim() }).token; + void fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), { + method: 'GET', + headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {} + }).then(async function (response) { + if (!response || response.status >= 400) { + res.set('X-Player-Offline', '1'); + return res.send(common.renderPlayerPage(req.params.slug, null)); + } + const data = await readJsonResponse(response); + if (!data) { + res.set('X-Player-Offline', '1'); + return res.send(common.renderPlayerPage(req.params.slug, null)); + } + res.send(common.renderPlayerPage(req.params.slug, data)); + }).catch(function (error) { + if (!isBridgeFetchError(error)) { + console.error(error); + } + res.set('X-Player-Offline', '1'); + res.send(common.renderPlayerPage(req.params.slug, null)); + }); + return; + } + const { bindPlayerToScreen } = require('./onboarding'); - if (playerIdentifier) { - void bindPlayerToScreen(pool, playerIdentifier, req.params.slug) + if (playerDeviceId) { + void bindPlayerToScreen(pool, playerDeviceId, req.params.slug) .catch(function (error) { console.error(error); }); @@ -231,6 +342,19 @@ function registerPlayerRoutes(app, options) { app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) { try { + if (thinClientBaseUrl) { + const response = await fetchThinClient(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', { + method: 'GET' + }); + if (!response) { + return res.status(502).send('Thin client unavailable'); + } + res.status(response.status); + res.set('Cache-Control', response.headers.get('cache-control') || 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.type(response.headers.get('content-type') || 'text/html; charset=utf-8'); + return res.send(await response.text()); + } + const slide = await common.fetchSlideById(pool, Number(req.params.id)); if (!slide) { return res.status(404).send('Slide not found'); @@ -273,6 +397,29 @@ function registerPlayerRoutes(app, options) { app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) { try { + if (thinClientBaseUrl) { + const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', { + method: 'GET' + }); + if (!response) { + return res.status(502).json({ error: 'Thin client unavailable.' }); + } + res.status(response.status); + const etag = response.headers.get('etag'); + const cacheControl = response.headers.get('cache-control'); + if (etag) { + res.set('ETag', etag); + } + if (cacheControl) { + res.set('Cache-Control', cacheControl); + } + if (response.status === 304) { + return res.end(); + } + res.type(response.headers.get('content-type') || 'application/json'); + return res.send(await response.text()); + } + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug); if (!data.screen) { @@ -293,6 +440,29 @@ function registerPlayerRoutes(app, options) { app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) { try { + if (thinClientBaseUrl) { + const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', { + method: 'GET' + }); + if (!response) { + return res.status(502).json({ error: 'Thin client unavailable.' }); + } + res.status(response.status); + const etag = response.headers.get('etag'); + const cacheControl = response.headers.get('cache-control'); + if (etag) { + res.set('ETag', etag); + } + if (cacheControl) { + res.set('Cache-Control', cacheControl); + } + if (response.status === 304) { + return res.end(); + } + res.type(response.headers.get('content-type') || 'application/json'); + return res.send(await response.text()); + } + 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) @@ -335,13 +505,15 @@ function registerPlayerRoutes(app, options) { const connections = playerRuntime.snapshotConnections(req.params.slug); let screen = null; let screenLookupFailed = false; - try { - const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]); - screen = screenRows[0] || null; - } catch (error) { - screenLookupFailed = isTransientDbError(error); - if (!screenLookupFailed) { - throw error; + if (pool && typeof pool.query === 'function') { + try { + const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]); + screen = screenRows[0] || null; + } catch (error) { + screenLookupFailed = isTransientDbError(error); + if (!screenLookupFailed) { + throw error; + } } } res.json({ @@ -374,7 +546,7 @@ function registerPlayerRoutes(app, options) { const isRedirectCommand = command === 'redirect'; let screen = null; let screenLookupFailed = false; - if (!isRedirectCommand) { + if (!isRedirectCommand && pool && typeof pool.query === 'function') { try { const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]); screen = screenRows[0] || null; diff --git a/src/web.js b/src/web.js index 4fc433f..255a963 100644 --- a/src/web.js +++ b/src/web.js @@ -62,7 +62,7 @@ async function start() { const playerActionService = createPlayerActionService({ pool: pool, common: common, - playerInternalBaseUrl: webConfig.playerInternalBaseUrl + playerInternalBaseUrl: webConfig.thinClientBaseUrl }); const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand); @@ -71,6 +71,7 @@ async function start() { pool: pool, common: common, playerInternalBaseUrl: webConfig.playerInternalBaseUrl, + thinClientBaseUrl: webConfig.thinClientBaseUrl, uploadDir: webConfig.uploadsDir, formatDashboardDate: formatDashboardDate, notifyPlayerScreens: notifyPlayerScreens, @@ -155,6 +156,8 @@ async function start() { }, hasAnyPermission: hasAnyPermission, backgroundTaskQueue: backgroundTaskQueue, + mediaDir: webConfig.mediaDir, + uploadSyncService: webBootstrap.uploadSyncService, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, @@ -207,6 +210,7 @@ async function start() { initializeBackgroundTasks: initializeBackgroundTasks, captureSlideThumbnail: captureSlideThumbnail, server: server, + webBaseUrl: webConfig.webBaseUrl, dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs }); diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js index 36b1057..39084b9 100644 --- a/src/web/bootstrap.js +++ b/src/web/bootstrap.js @@ -9,6 +9,7 @@ function createWebBootstrap(options) { const pool = options && options.pool; const common = options && options.common; const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); + const configuredThinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); const uploadDir = String(options && options.uploadDir || '').trim(); const dashboardRefreshIntervalMs = 5000; const formatDashboardDate = options && options.formatDashboardDate; @@ -25,49 +26,8 @@ function createWebBootstrap(options) { const playerSnapshotSockets = new Map(); let dashboardRefreshInFlight = null; let broadcastDashboardState = null; - let playerInternalBaseUrl = null; - let playerInternalBaseUrlPromise = null; - - async function getPlayerInternalBaseUrl() { - if (playerInternalBaseUrl) { - return playerInternalBaseUrl; - } - - if (playerInternalBaseUrlPromise) { - return playerInternalBaseUrlPromise; - } - - playerInternalBaseUrlPromise = (async function () { - if (!pool) { - return configuredPlayerInternalBaseUrl || null; - } - - 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; - } - })().then(function (baseUrl) { - playerInternalBaseUrl = baseUrl || null; - playerInternalBaseUrlPromise = null; - return playerInternalBaseUrl; - }, function () { - playerInternalBaseUrlPromise = null; - return configuredPlayerInternalBaseUrl || null; - }); - - return playerInternalBaseUrlPromise; - } - - async function getPlayerSnapshotSocketUrl(slug) { - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); + function getPlayerSnapshotSocketUrl(slug) { + const resolvedPlayerInternalBaseUrl = configuredThinClientBaseUrl || configuredPlayerInternalBaseUrl; if (!resolvedPlayerInternalBaseUrl) { throw new Error('Unable to resolve the player internal base URL.'); } @@ -78,16 +38,6 @@ function createWebBootstrap(options) { return url.toString(); } - function storePlayerSnapshot(slug, connections) { - const normalizedSlug = String(slug || '').trim(); - const normalizedConnections = Array.isArray(connections) ? connections : []; - playerSnapshotCache.set(normalizedSlug, { - slug: normalizedSlug, - count: normalizedConnections.length, - connections: normalizedConnections - }); - } - function clearPlayerSnapshotSocket(slug) { const key = String(slug || '').trim(); playerSnapshotSockets.delete(key); @@ -100,12 +50,12 @@ function createWebBootstrap(options) { } playerSnapshotSockets.set(key, null); - const socketUrlPromise = getPlayerSnapshotSocketUrl(key); const authHeaders = createRequestAuthHeaders({ method: 'GET', pathname: `/ws/screens/${encodeURIComponent(key)}/events` }); - socketUrlPromise.then(function (socketUrl) { + + Promise.resolve(getPlayerSnapshotSocketUrl(key)).then(function (socketUrl) { const socket = new WebSocket(socketUrl, { headers: authHeaders }); @@ -117,7 +67,11 @@ function createWebBootstrap(options) { if (!payload || payload.type !== 'snapshot' || payload.slug !== key) { return; } - storePlayerSnapshot(key, payload.connections || []); + playerSnapshotCache.set(key, { + slug: key, + count: Array.isArray(payload.connections) ? payload.connections.length : 0, + connections: Array.isArray(payload.connections) ? payload.connections : [] + }); if (broadcastDashboardState) { broadcastDashboardState().catch(function (error) { console.error(error); @@ -151,6 +105,7 @@ function createWebBootstrap(options) { const dashboardStateService = createDashboardStateService({ pool: pool, common: common, + thinClientBaseUrl: configuredThinClientBaseUrl, playerSnapshotCache: playerSnapshotCache, playerSnapshotSockets: playerSnapshotSockets, ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription, @@ -259,6 +214,7 @@ function createWebBootstrap(options) { return { upload: upload, uploadSyncService: uploadSyncService, + playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null, buildDashboardState: buildDashboardState, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, diff --git a/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-slide.js b/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-slide.js index ddab50a..f55ffbb 100644 --- a/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-slide.js +++ b/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-slide.js @@ -1,16 +1,18 @@ +const { resolvePlayerRegistration } = require('#src/data/player-registry'); + const TASK = { taskType: 'slide-thumbnail-refresh' }; -async function fetchPlayerInternalBaseUrl(pool) { - const [rows] = await pool.query( - `SELECT internal_base_url - FROM d_players - WHERE device_id = '1' - LIMIT 1` - ); +async function fetchPlayerInternalBaseUrl(pool, configuredPlayerInternalBaseUrl) { + const configured = String(configuredPlayerInternalBaseUrl || '').trim().replace(/\/$/, ''); + if (configured) { + return configured; + } - return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null; + const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry'); + const player = await resolvePlayerRegistration(pool, getConfiguredPlayerIdentifier()); + return String(player && player.internal_base_url || '').trim().replace(/\/$/, '') || null; } function registerSlideThumbnailRefreshTask(options) { @@ -19,6 +21,7 @@ function registerSlideThumbnailRefreshTask(options) { const pool = options && options.pool; const common = options && options.common; const mediaDir = String(options && options.mediaDir || '').trim(); + const configuredWebBaseUrl = String(options && options.webBaseUrl || '').trim().replace(/\/$/, ''); if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) { throw new Error('registerSlideThumbnailRefreshTask requires the slide thumbnail dependencies.'); @@ -32,18 +35,19 @@ function registerSlideThumbnailRefreshTask(options) { throw new Error('Slide id is required.'); } - const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool); - if (!playerInternalBaseUrl) { - throw new Error('Player internal base URL is required.'); + const webBaseUrl = configuredWebBaseUrl || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`; + if (!webBaseUrl) { + throw new Error('Web base URL is required.'); } return captureSlideThumbnail({ pool: pool, common: common, mediaDir: mediaDir, - baseUrl: playerInternalBaseUrl, + baseUrl: webBaseUrl, slideId: slideId, - previousThumbnailPath: payload.previousThumbnailPath || null + previousThumbnailPath: payload.previousThumbnailPath || null, + fontStylesheetHref: payload.fontStylesheetHref || '' }); }); } diff --git a/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-template.js b/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-template.js index 42d8053..a747ac5 100644 --- a/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-template.js +++ b/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-template.js @@ -1,16 +1,18 @@ +const { resolvePlayerRegistration } = require('#src/data/player-registry'); + const TASK = { taskType: 'template-slide-thumbnail-refresh' }; -async function fetchPlayerInternalBaseUrl(pool) { - const [rows] = await pool.query( - `SELECT internal_base_url - FROM d_players - WHERE device_id = '1' - LIMIT 1` - ); +async function fetchPlayerInternalBaseUrl(pool, configuredPlayerInternalBaseUrl) { + const configured = String(configuredPlayerInternalBaseUrl || '').trim().replace(/\/$/, ''); + if (configured) { + return configured; + } - return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null; + const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry'); + const player = await resolvePlayerRegistration(pool, getConfiguredPlayerIdentifier()); + return String(player && player.internal_base_url || '').trim().replace(/\/$/, '') || null; } function registerTemplateSlideThumbnailRefreshTask(options) { @@ -19,6 +21,7 @@ function registerTemplateSlideThumbnailRefreshTask(options) { const pool = options && options.pool; const common = options && options.common; const mediaDir = String(options && options.mediaDir || '').trim(); + const configuredWebBaseUrl = String(options && options.webBaseUrl || '').trim().replace(/\/$/, ''); if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) { throw new Error('registerTemplateSlideThumbnailRefreshTask requires the template thumbnail dependencies.'); @@ -32,9 +35,9 @@ function registerTemplateSlideThumbnailRefreshTask(options) { throw new Error('Template id is required.'); } - const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool); - if (!playerInternalBaseUrl) { - throw new Error('Player internal base URL is required.'); + const webBaseUrl = configuredWebBaseUrl || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`; + if (!webBaseUrl) { + throw new Error('Web base URL is required.'); } const [slides] = await pool.query( @@ -52,7 +55,7 @@ function registerTemplateSlideThumbnailRefreshTask(options) { pool: pool, common: common, mediaDir: mediaDir, - baseUrl: playerInternalBaseUrl, + baseUrl: webBaseUrl, slideId: slideId, previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null }); diff --git a/src/web/lib/config.js b/src/web/lib/config.js index 516c718..27da7b5 100644 --- a/src/web/lib/config.js +++ b/src/web/lib/config.js @@ -6,6 +6,8 @@ function createWebConfig() { const thumbnailsDir = path.join(mediaDir, 'thumbnails'); const assetDir = path.join(__dirname, '..', 'public'); const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, ''); + const thinClientBaseUrl = (process.env.THIN_CLIENT_BASE_URL || 'http://player-bridge:8090').replace(/\/$/, ''); + const webBaseUrl = (process.env.WEB_BASE_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, ''); const sessionCookieName = 'digital_signage_session'; const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14); const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000; @@ -19,6 +21,8 @@ function createWebConfig() { thumbnailsDir: thumbnailsDir, assetDir: assetDir, playerInternalBaseUrl: playerInternalBaseUrl, + thinClientBaseUrl: thinClientBaseUrl, + webBaseUrl: webBaseUrl, sessionCookieName: sessionCookieName, sessionMaxAgeMs: sessionMaxAgeMs, dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs diff --git a/src/web/lib/dashboard-state.js b/src/web/lib/dashboard-state.js index 400f6e8..c4f52d0 100644 --- a/src/web/lib/dashboard-state.js +++ b/src/web/lib/dashboard-state.js @@ -6,6 +6,10 @@ function normalizeClientName(value) { return String(value || '').trim(); } +function normalizePlayerBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); +} + function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) { return (screens || []).map(function (screen) { const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; @@ -18,11 +22,12 @@ function enrichScreensWithConnections(screens, connectionsBySlug, onboardingName }); } -function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate) { +function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerIdentifierByBaseUrl, formatDashboardDate) { return (screens || []).flatMap(function (screen) { const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; return (connectionState.connections || []).map(function (connection) { const deviceId = String(connection.deviceId || '').trim(); + const playerBaseUrl = normalizePlayerBaseUrl(connection.playerPublicBaseUrl); return Object.assign({}, connection, { screen_slug: screen.slug, screen_name: screen.name, @@ -30,12 +35,55 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa playlist_name: screen.playlist_name || null, connectedAtLabel: formatDashboardDate(connection.connectedAt), lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt), - player_url: screen.player_url || String(connection.page || '').trim() || null + player_identifier: playerIdentifierByBaseUrl && playerBaseUrl ? (playerIdentifierByBaseUrl[playerBaseUrl] || null) : null, + player_url: playerBaseUrl || null }); }); }); } +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 buildKioskLauncherPlayers(playerRegistrations, staleSeconds) { + return (Array.isArray(playerRegistrations) ? playerRegistrations : []) + .filter(function (player) { + return Boolean(player && String(player.identifier || '').trim() && String(player.public_base_url || '').trim() && isRecentPlayerRegistration(player, staleSeconds)); + }) + .map(function (player) { + return { + player_identifier: String(player.identifier || '').trim(), + player_url: String(player.public_base_url || '').trim().replace(/\/$/, '') + }; + }); +} + +async function fetchConnectedPlayerCount(pool, connectedPlayerCountStaleSeconds) { + const staleSeconds = Math.max(30, Number(connectedPlayerCountStaleSeconds || 60)); + if (!pool || typeof pool.query !== 'function' || !Number.isFinite(staleSeconds)) { + return null; + } + + try { + const [rows] = await pool.query( + `SELECT COUNT(*) AS connected_count + FROM d_players + WHERE last_seen_at IS NOT NULL + AND last_seen_at >= DATE_SUB(NOW(), INTERVAL ${staleSeconds} SECOND)` + ); + + const count = Number(rows && rows[0] && rows[0].connected_count); + return Number.isFinite(count) && count >= 0 ? count : null; + } catch (_error) { + return null; + } +} + function compareScreenNames(left, right) { const leftName = String(left && left.name || '').trim(); const rightName = String(right && right.name || '').trim(); @@ -51,6 +99,7 @@ function compareScreenNames(left, right) { function createDashboardStateService(options) { const pool = options && options.pool; const common = options && options.common; + const connectedPlayerCountStaleSeconds = options && options.connectedPlayerCountStaleSeconds; const playerSnapshotCache = options && options.playerSnapshotCache; const playerSnapshotSockets = options && options.playerSnapshotSockets; const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription; @@ -63,9 +112,13 @@ function createDashboardStateService(options) { async function buildDashboardState() { const data = await common.fetchAdminData(pool); const screensData = data.screens || []; + const connectedPlayersCount = await fetchConnectedPlayerCount(pool, connectedPlayerCountStaleSeconds); const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function' ? await common.fetchScreenPlayerUrls(pool) : {}; + const playerRegistrations = typeof common.fetchPlayerRegistrations === 'function' + ? await common.fetchPlayerRegistrations(pool) + : []; screensData.forEach(function (screen) { ensurePlayerSnapshotSubscription(screen.slug); }); @@ -91,6 +144,15 @@ function createDashboardStateService(options) { } }); + const playerIdentifierByBaseUrl = {}; + (Array.isArray(playerRegistrations) ? playerRegistrations : []).forEach(function (player) { + const baseUrl = normalizePlayerBaseUrl(player && player.public_base_url); + const identifier = normalizeClientName(player && player.identifier); + if (baseUrl && identifier) { + playerIdentifierByBaseUrl[baseUrl] = identifier; + } + }); + const connectionsBySlug = {}; screensData.forEach(function (screen) { const cached = playerSnapshotCache.get(String(screen.slug || '').trim()); @@ -106,7 +168,8 @@ function createDashboardStateService(options) { }); }) .sort(compareScreenNames); - const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate); + const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerIdentifierByBaseUrl, formatDashboardDate); + const kioskPlayers = buildKioskLauncherPlayers(playerRegistrations, connectedPlayerCountStaleSeconds); const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) { return socket && socket.readyState === WebSocket.OPEN; }); @@ -115,8 +178,10 @@ function createDashboardStateService(options) { playlists: data.playlists || [], screens: screens, clients: clients, + kioskPlayers: kioskPlayers, slides: data.slides || [], playerServiceConnected: playerServiceConnected, + connectedPlayersCount: connectedPlayersCount !== null ? connectedPlayersCount : 0, connectedClientsCount: screens.reduce(function (total, screen) { return total + Number(screen.player_connection_count || 0); }, 0) diff --git a/src/web/lib/media/index.js b/src/web/lib/media/index.js index a5e522f..9c6fc46 100644 --- a/src/web/lib/media/index.js +++ b/src/web/lib/media/index.js @@ -2,6 +2,8 @@ module.exports = { createUploadSyncService: require('./upload-sync').createUploadSyncService, - captureSlideThumbnail: require('./slide-thumbnails').captureSlideThumbnail, + captureSlideThumbnail: function captureSlideThumbnail(options) { + return require('./slide-thumbnails').captureSlideThumbnail(options); + }, fontLibrary: require('./font-library') }; \ No newline at end of file diff --git a/src/web/lib/media/slide-thumbnail-preview.js b/src/web/lib/media/slide-thumbnail-preview.js new file mode 100644 index 0000000..4436a26 --- /dev/null +++ b/src/web/lib/media/slide-thumbnail-preview.js @@ -0,0 +1,156 @@ +// Pure thumbnail preview payload helpers shared by routes and screenshot capture. + +const { + escapeHtml, + renderEditorJsContent, + sanitizeFontFamily, + sanitizeFontSize, + sanitizeTextColor +} = require('#src/player/render-helpers'); + +function normalizeBaseUrl(baseUrl) { + return String(baseUrl || '').trim().replace(/\/$/, ''); +} + +function resolveAssetUrl(baseUrl, value) { + const raw = String(value || '').trim(); + if (!raw) { + return ''; + } + if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) { + return raw; + } + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + if (!normalizedBaseUrl) { + return raw; + } + if (raw.startsWith('/')) { + return normalizedBaseUrl + raw; + } + return normalizedBaseUrl + '/' + raw.replace(/^\/+/, ''); +} + +function getThumbnailCanvasSize(slide) { + const template = slide && slide.template ? slide.template : null; + return { + width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)), + height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080)) + }; +} + +function getRegionContent(slide, region) { + const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {}; + return content && typeof content === 'object' ? content : { value: content }; +} + +function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) { + const left = Number.isFinite(Number(region && region.x)) && canvasWidth > 0 ? (Number(region.x) / canvasWidth) * 100 : 0; + const top = Number.isFinite(Number(region && region.y)) && canvasHeight > 0 ? (Number(region.y) / canvasHeight) * 100 : 0; + const width = Number.isFinite(Number(region && region.width)) && canvasWidth > 0 ? (Number(region.width) / canvasWidth) * 100 : 0; + const height = Number.isFinite(Number(region && region.height)) && canvasHeight > 0 ? (Number(region.height) / canvasHeight) * 100 : 0; + + return 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region && region.z_index || 0) + ';'; +} + +function hasVisibleContent(html) { + return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim()); +} + +function buildTextRegionMarkup(region, regionContent) { + const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family); + const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size); + const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color); + const renderedBody = renderEditorJsContent(regionContent.value || ''); + if (!hasVisibleContent(renderedBody)) { + return ''; + } + + return '
' + renderedBody + '
'; +} + +function buildRegionInnerHtml(region, regionContent, baseUrl) { + const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase(); + const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : ''; + + if (regionType === 'image') { + const src = resolveAssetUrl(baseUrl, rawValue); + return src + ? '
' + escapeHtml(region.label || region.region_key || 'image') + '
' + : ''; + } + + if (regionType === 'video') { + const src = resolveAssetUrl(baseUrl, rawValue); + return src + ? '
' + : ''; + } + + if (regionType === 'webpage') { + const src = resolveAssetUrl(baseUrl, rawValue); + return src + ? '
' + : ''; + } + + if (regionType === 'qr-code') { + const src = String(regionContent.qr_preview || '').trim(); + const borderRadius = Math.max(0, Math.round(Number(regionContent.qr_border_radius || 0))); + const radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : ''; + return src + ? '
' + escapeHtml(region.label || region.region_key || 'qr code') + '
' + : ''; + } + + if (regionType === 'html') { + const html = String(rawValue || '').trim(); + return html + ? '
' + : ''; + } + + if (regionType === 'rtmp') { + const label = String(rawValue || '').trim() || 'RTMP source'; + return '
' + escapeHtml(label) + '
'; + } + + return buildTextRegionMarkup(region, regionContent); +} + +function buildThumbnailPreviewMarkup(slide, baseUrl) { + const template = slide && slide.template ? slide.template : null; + if (!template) { + return ''; + } + + const canvasSize = getThumbnailCanvasSize(slide); + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + return (Array.isArray(template.regions) ? template.regions : []).map(function (region) { + const regionContent = getRegionContent(slide, region); + const previewRegion = Object.assign({}, region, { + baseStyle: buildThumbnailRegionStyle(region, canvasSize.width, canvasSize.height), + pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)), + pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1)) + }); + return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl); + }).join(''); +} + +function buildThumbnailPreviewPayload(slide, options) { + const template = slide && slide.template ? slide.template : null; + const canvasSize = getThumbnailCanvasSize(slide); + return { + thumbnailPreview: true, + canvasWidth: canvasSize.width, + canvasHeight: canvasSize.height, + backgroundColor: template && template.background_color ? String(template.background_color) : '#111111', + backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '', + fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(), + html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl) + }; +} + +module.exports = { + buildThumbnailPreviewPayload: buildThumbnailPreviewPayload, + buildThumbnailPreviewMarkup: buildThumbnailPreviewMarkup +}; \ No newline at end of file diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js index e8580f9..b19f67e 100644 --- a/src/web/lib/media/slide-thumbnails.js +++ b/src/web/lib/media/slide-thumbnails.js @@ -2,14 +2,6 @@ const fs = require('fs'); const path = require('path'); -const puppeteer = require('puppeteer-core'); -const chromiumModule = require('@sparticuz/chromium'); -const sharp = require('sharp'); -const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function' - ? chromiumModule - : chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function' - ? chromiumModule.default - : chromiumModule; const { escapeHtml, mediaKind, @@ -73,6 +65,23 @@ function getRegionContent(slide, region) { return content && typeof content === 'object' ? content : { value: content }; } +function getThumbnailCanvasSize(slide) { + const template = slide && slide.template ? slide.template : null; + return { + width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)), + height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080)) + }; +} + +function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) { + const left = Number.isFinite(Number(region && region.x)) && canvasWidth > 0 ? (Number(region.x) / canvasWidth) * 100 : 0; + const top = Number.isFinite(Number(region && region.y)) && canvasHeight > 0 ? (Number(region.y) / canvasHeight) * 100 : 0; + const width = Number.isFinite(Number(region && region.width)) && canvasWidth > 0 ? (Number(region.width) / canvasWidth) * 100 : 0; + const height = Number.isFinite(Number(region && region.height)) && canvasHeight > 0 ? (Number(region.height) / canvasHeight) * 100 : 0; + + return 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region && region.z_index || 0) + ';'; +} + function hasVisibleContent(html) { return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim()); } @@ -86,7 +95,7 @@ function buildTextRegionMarkup(region, regionContent) { return ''; } - return '
' + renderedBody + '
'; + return '
' + renderedBody + '
'; } function buildRegionInnerHtml(region, regionContent, baseUrl) { @@ -96,21 +105,21 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) { if (regionType === 'image') { const src = resolveAssetUrl(baseUrl, rawValue); return src - ? '' + escapeHtml(region.label || region.region_key || 'image') + '' + ? '
' + escapeHtml(region.label || region.region_key || 'image') + '
' : ''; } if (regionType === 'video') { const src = resolveAssetUrl(baseUrl, rawValue); return src - ? '' + ? '
' : ''; } if (regionType === 'webpage') { const src = resolveAssetUrl(baseUrl, rawValue); return src - ? '' + ? '
' : ''; } @@ -119,26 +128,75 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) { const borderRadius = Math.max(0, Math.round(Number(regionContent.qr_border_radius || 0))); const radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : ''; return src - ? '' + escapeHtml(region.label || region.region_key || 'qr code') + '' + ? '
' + escapeHtml(region.label || region.region_key || 'qr code') + '
' : ''; } if (regionType === 'html') { const html = String(rawValue || '').trim(); return html - ? '' + ? '
' : ''; } if (regionType === 'rtmp') { const label = String(rawValue || '').trim() || 'RTMP source'; - return '
' + escapeHtml(label) + '
'; + return '
' + escapeHtml(label) + '
'; } return buildTextRegionMarkup(region, regionContent); } +async function loadChromium() { + const chromiumModule = await import('@sparticuz/chromium'); + const resolved = chromiumModule && chromiumModule.default ? chromiumModule.default : chromiumModule; + return resolved; +} + +async function loadPuppeteer() { + return require('puppeteer-core'); +} + +function loadSharp() { + return require('sharp'); +} + +function buildThumbnailPreviewMarkup(slide, baseUrl) { + const template = slide && slide.template ? slide.template : null; + if (!template) { + return ''; + } + + const canvasSize = getThumbnailCanvasSize(slide); + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + return (Array.isArray(template.regions) ? template.regions : []).map(function (region) { + const regionContent = getRegionContent(slide, region); + const previewRegion = Object.assign({}, region, { + baseStyle: buildThumbnailRegionStyle(region, canvasSize.width, canvasSize.height), + pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)), + pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1)) + }); + return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl); + }).join(''); +} + +function buildThumbnailPreviewPayload(slide, options) { + const template = slide && slide.template ? slide.template : null; + const canvasSize = getThumbnailCanvasSize(slide); + return { + thumbnailPreview: true, + canvasWidth: canvasSize.width, + canvasHeight: canvasSize.height, + backgroundColor: template && template.background_color ? String(template.background_color) : '#111111', + backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '', + fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(), + html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl) + }; +} + async function launchBrowser() { + const puppeteer = await loadPuppeteer(); + const chromium = await loadChromium(); let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) { return fs.existsSync(candidate); }) || ''; @@ -178,6 +236,7 @@ async function launchBrowser() { } async function captureSlideThumbnail(options) { + const sharp = loadSharp(); const pool = options && options.pool; const common = options && options.common; const mediaDir = String(options && options.mediaDir || '').trim(); @@ -194,7 +253,6 @@ async function captureSlideThumbnail(options) { throw new Error('Slide not found.'); } - const canvasSize = getCanvasSize(slide); const thumbnailDir = path.join(mediaDir, 'thumbnails'); const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png'; const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, '')); @@ -206,11 +264,11 @@ async function captureSlideThumbnail(options) { async function waitForThumbnailRender(page) { await page.waitForFunction(function () { - return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas')); + return document.readyState === 'complete' && Boolean(document.querySelector('#popup-preview-canvas')); }, { timeout: 30000 }); await page.waitForFunction(function () { - var canvas = document.querySelector('.slide-canvas'); + var canvas = document.querySelector('#popup-preview-canvas'); if (!canvas) { return false; } @@ -244,18 +302,26 @@ async function captureSlideThumbnail(options) { try { const page = await browser.newPage(); try { - const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview'; + const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/popup-preview'; const previewUrl = baseUrl + previewPath; + const previewPayload = buildThumbnailPreviewPayload(slide, { + baseUrl: baseUrl, + fontStylesheetHref: options && options.fontStylesheetHref ? options.fontStylesheetHref : '' + }); + await page.setViewport({ + width: Math.max(1, Number(previewPayload.canvasWidth || PLAYER_VIEWPORT.width)), + height: Math.max(1, Number(previewPayload.canvasHeight || PLAYER_VIEWPORT.height)), + deviceScaleFactor: 1 + }); await page.setExtraHTTPHeaders(createRequestAuthHeaders({ method: 'GET', pathname: previewPath })); - await page.setViewport(PLAYER_VIEWPORT); await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); await waitForThumbnailRender(page); - const canvas = await page.$('.slide-canvas'); + const canvas = await page.$('#popup-preview-canvas'); if (!canvas) { - throw new Error('Player render did not produce a slide canvas.'); + throw new Error('Popup preview did not produce a slide canvas.'); } await canvas.screenshot({ path: fullSizePath }); } finally { @@ -298,5 +364,7 @@ async function captureSlideThumbnail(options) { } module.exports = { - captureSlideThumbnail: captureSlideThumbnail + captureSlideThumbnail: captureSlideThumbnail, + buildThumbnailPreviewPayload: buildThumbnailPreviewPayload, + buildThumbnailPreviewMarkup: buildThumbnailPreviewMarkup }; \ No newline at end of file diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index d11773f..8108be5 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -6,6 +6,7 @@ const crypto = require('crypto'); const multer = require('multer'); const { createRequestAuthHeaders } = require('#src/request-auth'); const { collectFontLibrarySyncOperations } = require('./font-library'); +const { getConfiguredPlayerIdentifier, resolvePlayerRegistration } = require('#src/data/player-registry'); function normalizeUploadRoot(uploadDir) { return path.resolve(String(uploadDir || '').trim()); @@ -48,21 +49,20 @@ function createUploadSyncService(options) { } 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; + const configuredPlayerIdentifier = getConfiguredPlayerIdentifier(); + const player = await resolvePlayerRegistration(pool, configuredPlayerIdentifier); + const resolvedBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, ''); + if (resolvedBaseUrl) { + playerInternalBaseUrl = resolvedBaseUrl; + return resolvedBaseUrl; + } } catch (_error) { - return configuredPlayerInternalBaseUrl || null; } + + return configuredPlayerInternalBaseUrl || null; })().then(function (baseUrl) { - playerInternalBaseUrl = baseUrl || null; playerInternalBaseUrlPromise = null; - return playerInternalBaseUrl; + return baseUrl || null; }, function () { playerInternalBaseUrlPromise = null; return configuredPlayerInternalBaseUrl || null; @@ -278,6 +278,11 @@ 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 queuePlayerUploadSync(operation) { if (!operation || !operation.uploadPath) { return; @@ -350,7 +355,9 @@ function createUploadSyncService(options) { } 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; } } @@ -387,7 +394,9 @@ function createUploadSyncService(options) { } 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; } } diff --git a/src/web/lib/player-actions.js b/src/web/lib/player-actions.js index 2e913a6..1048354 100644 --- a/src/web/lib/player-actions.js +++ b/src/web/lib/player-actions.js @@ -1,4 +1,5 @@ const { createRequestAuthHeaders } = require('#src/request-auth'); +const { getConfiguredPlayerIdentifier, resolvePlayerRegistration } = require('#src/data/player-registry'); function createPlayerActionService(options) { const pool = options && options.pool; @@ -13,6 +14,10 @@ function createPlayerActionService(options) { let playerInternalBaseUrlPromise = null; async function getPlayerInternalBaseUrl() { + if (configuredPlayerInternalBaseUrl) { + return configuredPlayerInternalBaseUrl; + } + if (playerInternalBaseUrl) { return playerInternalBaseUrl; } @@ -27,21 +32,20 @@ function createPlayerActionService(options) { } 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; + const configuredPlayerIdentifier = getConfiguredPlayerIdentifier(); + const player = await resolvePlayerRegistration(pool, configuredPlayerIdentifier); + const resolvedBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, ''); + if (resolvedBaseUrl) { + playerInternalBaseUrl = resolvedBaseUrl; + return resolvedBaseUrl; + } } catch (_error) { - return configuredPlayerInternalBaseUrl || null; } + + return configuredPlayerInternalBaseUrl || null; })().then(function (baseUrl) { - playerInternalBaseUrl = baseUrl || null; playerInternalBaseUrlPromise = null; - return playerInternalBaseUrl; + return baseUrl || null; }, function () { playerInternalBaseUrlPromise = null; return configuredPlayerInternalBaseUrl || null; diff --git a/src/web/lib/web-startup.js b/src/web/lib/web-startup.js index 9650b64..c5b6e7f 100644 --- a/src/web/lib/web-startup.js +++ b/src/web/lib/web-startup.js @@ -25,6 +25,7 @@ async function initializeWebServer(options) { uploadSyncService: webBootstrap.uploadSyncService, captureSlideThumbnail: captureSlideThumbnail, mediaDir: mediaDir, + webBaseUrl: options && options.webBaseUrl ? options.webBaseUrl : null, dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs }); diff --git a/src/web/middleware.js b/src/web/middleware.js index eff0b12..05faa22 100644 --- a/src/web/middleware.js +++ b/src/web/middleware.js @@ -29,7 +29,7 @@ module.exports = function registerMiddleware(app, deps) { }); app.use(function (req, res, next) { - if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) { + if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media') { return next(); } diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js index ff78c84..ca81eb5 100644 --- a/src/web/public/js/dashboard/dashboard-page.js +++ b/src/web/public/js/dashboard/dashboard-page.js @@ -267,24 +267,20 @@ 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 +306,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,8 +320,8 @@ 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)); } } @@ -399,6 +395,62 @@ } + 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)) { @@ -603,6 +655,7 @@ updateScreenGrid(state); updateScreenCommandControls(state); updateClientTable(state); + updateKioskLauncherModal(state); updateDashboardQuickActions(state); } @@ -780,34 +833,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()); }); } diff --git a/src/web/public/js/system-status.js b/src/web/public/js/system-status.js index 3a350cc..1330a12 100644 --- a/src/web/public/js/system-status.js +++ b/src/web/public/js/system-status.js @@ -1,4 +1,13 @@ (function () { + function buildLivePlayerFeedLabel(connectedPlayersCount) { + var count = Number(connectedPlayersCount || 0); + if (count <= 0) { + return 'Player feed online - no players connected'; + } + + return 'Live player feed - ' + count + ' player' + (count === 1 ? '' : 's') + ' connected'; + } + function updateSidebarStatus(status, label) { var dot = document.getElementById('sidebar-status-dot'); var text = document.getElementById('sidebar-status-text'); @@ -72,7 +81,7 @@ if (!screenCount) { updateSidebarStatus('unknown', 'No screens configured'); } else { - updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed'); + updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), buildLivePlayerFeedLabel(payload.state && payload.state.connectedPlayersCount)); } } } catch (_error) { diff --git a/src/web/routes/admin/client-commands.js b/src/web/routes/admin/client-commands.js index aa311b9..ead8b23 100644 --- a/src/web/routes/admin/client-commands.js +++ b/src/web/routes/admin/client-commands.js @@ -208,11 +208,7 @@ 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) - : null; const targetBaseUrl = String( - targetPlayerRecord && targetPlayerRecord.public_base_url || (typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') || '' ).trim().replace(/\/$/, ''); diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js index 4341f08..500b267 100644 --- a/src/web/routes/admin/manage.js +++ b/src/web/routes/admin/manage.js @@ -111,7 +111,7 @@ module.exports = function registerManageRoutes(app, deps) { const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null; const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name)); const actorId = getAuditUserId(req); - const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, player_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, slug, playlistId, '1', actorId, actorId]); + const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]); redirectAfterSave(req, res, '/screens?edit=' + result.insertId, { closeUrl: '/screens', newUrl: '/screens/new', @@ -144,19 +144,14 @@ module.exports = function registerManageRoutes(app, deps) { const previousPlaylistId = screen.playlist_id; const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id); const previousSlug = String(screen.slug || '').trim(); - await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, '1', getAuditUserId(req), screen.id]); + await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]); if (previousPlaylistId !== playlistId && previousSlug) { await notifyPlayerScreens([previousSlug], 'refresh'); } if (previousSlug && previousSlug !== slug) { - const playerRecord = typeof common.fetchScreenPlayerRecord === 'function' - ? await common.fetchScreenPlayerRecord(pool, previousSlug) - : null; - const playerBaseUrl = playerRecord && playerRecord.public_base_url - ? String(playerRecord.public_base_url).replace(/\/$/, '') - : typeof common.fetchPlayerPublicBaseUrl === 'function' - ? await common.fetchPlayerPublicBaseUrl(pool) - : ''; + const playerBaseUrl = typeof common.fetchPlayerPublicBaseUrl === 'function' + ? await common.fetchPlayerPublicBaseUrl(pool) + : ''; await forwardPlayerCommand(previousSlug, { command: 'redirect', url: playerBaseUrl ? `${playerBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}` diff --git a/src/web/routes/internal/sync.js b/src/web/routes/internal/sync.js new file mode 100644 index 0000000..470ca10 --- /dev/null +++ b/src/web/routes/internal/sync.js @@ -0,0 +1,33 @@ +// Internal sync trigger routes for player-driven queue flushes. + +const { verifyRequestAuth } = require('#src/request-auth'); + +function requireRequestAuth(req, res, next) { + if (!verifyRequestAuth(req)) { + return res.status(401).json({ error: 'Request authentication required.' }); + } + + next(); +} + +module.exports = function registerInternalSyncRoutes(app, deps) { + const uploadSyncService = deps && deps.uploadSyncService; + const mediaDir = String(deps && deps.mediaDir || '').trim(); + + if (!uploadSyncService || typeof uploadSyncService.flushPendingPlayerUploadSyncs !== 'function' || typeof uploadSyncService.runMediaSyncTask !== 'function' || !mediaDir) { + throw new Error('registerInternalSyncRoutes requires the sync dependencies.'); + } + + app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) { + try { + await uploadSyncService.runMediaSyncTask({ + mode: 'initial', + uploadDir: mediaDir + }); + await uploadSyncService.flushPendingPlayerUploadSyncs(); + res.json({ ok: true }); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/register.js b/src/web/routes/register.js index 9c87837..c00e35b 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,21 @@ 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, + buildDashboardState: deps.buildDashboardState, + fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId, + requirePermission: deps.requirePermission, + getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage, + getScreenConnections: deps.playerActionService.getScreenConnections, + }); } function registerAuthAndAccountRoutes(app, deps) { diff --git a/src/web/routes/signage/dashboard/index.js b/src/web/routes/signage/dashboard/index.js index 8c77819..de9ce6d 100644 --- a/src/web/routes/signage/dashboard/index.js +++ b/src/web/routes/signage/dashboard/index.js @@ -21,8 +21,10 @@ module.exports = function renderDashboardPage(data, message, currentUser) { playlists: data.playlists || [], screens: data.screens || [], clients: data.clients || [], + kioskPlayers: data.kioskPlayers || [], slides: data.slides || [], connectedClientsCount: Number(data.connectedClientsCount || 0), + connectedPlayersCount: Number(data.connectedPlayersCount || data.connectedClientsCount || 0), primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null, scripts: ['js/dashboard/dashboard-page.js'] }); diff --git a/src/web/routes/signage/screens/form-view-model.js b/src/web/routes/signage/screens/form-view-model.js index af42af4..6ea4172 100644 --- a/src/web/routes/signage/screens/form-view-model.js +++ b/src/web/routes/signage/screens/form-view-model.js @@ -4,7 +4,8 @@ function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) { const viewScreen = Object.assign({ name: '', slug: '', - playlist_id: null + playlist_id: null, + player_urls: [] }, screen || {}); return { diff --git a/src/web/routes/signage/screens/routes.js b/src/web/routes/signage/screens/routes.js index 7ff797f..be815a8 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; @@ -19,6 +20,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 +70,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 +82,21 @@ 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 : []).map(function (player) { + const baseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, ''); + const playerUrl = screenPlayerUrl(screen && screen.slug ? screen.slug : '', baseUrl); + return Object.assign({}, player, { + public_base_url: baseUrl || null, + player_url: playerUrl || null + }); + }); + } + function buildLauncherContent(templatePath, playerUrl) { const template = fs.readFileSync(templatePath, 'utf8'); const normalizedPlayerUrl = String(playerUrl || '').trim(); @@ -100,8 +126,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 +139,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 +155,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 +169,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 +188,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/routes/signage/slides/routes.js b/src/web/routes/signage/slides/routes.js index 0f2ad10..10da3a1 100644 --- a/src/web/routes/signage/slides/routes.js +++ b/src/web/routes/signage/slides/routes.js @@ -1,14 +1,32 @@ // Slide route registration and pagination wiring. const { renderFragment } = require('../../../view'); +const path = require('path'); +const { verifyRequestAuth } = require('#src/request-auth'); +const { getFontStylesheetHref } = require('#src/web/lib/media/font-library'); +const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnail-preview'); + +function safeJsonForScript(value) { + return JSON.stringify(value === undefined ? null : value).replace(/ - {{connectedClientsCount}} - clients + {{connectedPlayersCount}} + players {{/if}} @@ -157,12 +157,25 @@

Check this box to enable the Windows and Linux downloads.

+
+ +

The download will open the selected player's public URL when the kiosk starts.

+
- + Download for Windows - + Download for Linux @@ -178,8 +191,7 @@

Screen group snapshot

-

Player URL, playlist assignment, - and live connection count without the spreadsheet feel.

+

Playlist assignment and live connection state without the spreadsheet feel.

Manage screen groups
@@ -191,11 +203,6 @@

{{name}}

- {{#if player_url}} - {{player_url}} - {{else}} - Not available - {{/if}}
{{#if player_connection_count}} @@ -210,10 +217,6 @@
Playlist
{{#if playlist_name}}{{playlist_name}}{{else}}Unassigned{{/if}}
-
-
Connections
-
{{#if player_connection_count}}{{player_connection_count}} connected{{else}}No clients connected{{/if}}
-
{{/each}} diff --git a/src/web/views/signage/screens/form.hbs b/src/web/views/signage/screens/form.hbs index 221c1b0..a8734d7 100644 --- a/src/web/views/signage/screens/form.hbs +++ b/src/web/views/signage/screens/form.hbs @@ -6,7 +6,7 @@
-
+

Screen group details

@@ -40,16 +40,41 @@
-
+
-

Player URL

+

Player URLs

- {{#if screen.player_url}} -

{{screen.player_url}}

+ {{#if screen.player_urls.length}} +
+ + + + + + + + + {{#each screen.player_urls}} + + + + + {{/each}} + +
PlayerURL
+
{{identifier}}
+
+ {{#if player_url}} + {{player_url}} + {{else}} + Not available + {{/if}} +
+
{{else}} -

No player URL is available yet.

+

No player URLs are available yet.

{{/if}}
diff --git a/src/web/views/signage/screens/list.hbs b/src/web/views/signage/screens/list.hbs index 870d31e..db11e81 100644 --- a/src/web/views/signage/screens/list.hbs +++ b/src/web/views/signage/screens/list.hbs @@ -23,7 +23,6 @@ Name - Player URL Playlist Connected Actions @@ -34,13 +33,6 @@ {{#each screens}} {{name}} - - {{#if player_url}} - {{player_url}} - {{else}} - Not available - {{/if}} - {{playlist_name}} {{#if (gt player_connection_count 0)}} @@ -72,7 +64,7 @@ {{/each}} {{else}} - No screen groups yet. + No screen groups yet. {{/if}} diff --git a/src/web/views/signage/slides/popup-preview.hbs b/src/web/views/signage/slides/popup-preview.hbs index eb8d501..2fa6f3e 100644 --- a/src/web/views/signage/slides/popup-preview.hbs +++ b/src/web/views/signage/slides/popup-preview.hbs @@ -58,6 +58,12 @@
+{{#if popupPreviewPayloadJson}} + +{{/if}} +