Add multi-player and remote bridge support
This commit is contained in:
@@ -3,6 +3,10 @@ media/
|
|||||||
!src/web/lib/media/
|
!src/web/lib/media/
|
||||||
!src/web/lib/media/**
|
!src/web/lib/media/**
|
||||||
docker-compose.dev.yml
|
docker-compose.dev.yml
|
||||||
|
/docker-compose/*.dev.yml
|
||||||
|
/docker-compose/*.env
|
||||||
|
/docker-compose/*.env.local
|
||||||
|
/docker-compose/*.env.remote
|
||||||
/dev-demo-seed.js
|
/dev-demo-seed.js
|
||||||
.vscode/
|
.vscode/
|
||||||
.env
|
.env
|
||||||
|
|||||||
+43
-3
@@ -2,6 +2,49 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## 2.5.14 - 2026-08-06
|
||||||
|
|
||||||
### Added
|
### 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.
|
- 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.
|
- 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
|
## 2.5.13 - 2026-08-05
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +1,38 @@
|
|||||||
# Pulse Signage
|
# 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.
|
- Run polished screen experiences with playlists, slides, and reusable templates.
|
||||||
- Build polished screen experiences with reusable templates and playlists.
|
- Keep announcements, schedules, RSS feeds, API content, and other live data in sync.
|
||||||
- Push announcements, schedules, RSS feeds, API content, and other dynamic content to screens.
|
- Manage many screens from a single dashboard.
|
||||||
- See what is live, what is queued, and what needs attention from a single admin dashboard.
|
- Support everyday signage, event messages, lobbies, dashboards, and other always-on displays.
|
||||||
- Use the same system for everyday signage, timed messages, and more structured information displays.
|
- Keep the player, dashboard, and bridge connected so updates move quickly and consistently.
|
||||||
|
|
||||||
## What It Handles
|
## Why It Fits
|
||||||
|
|
||||||
- Screens and playlist assignments.
|
- It keeps signage under your own control.
|
||||||
- Slides, templates, and canvas sizes.
|
- It is built for teams that need screens to stay current without extra manual work.
|
||||||
- Announcements and screen-specific targeting.
|
- It works well for offices, venues, campuses, and operations teams.
|
||||||
- RSS feeds, API sources, and schedule-based content.
|
- It stays focused on display management instead of trying to be a general-purpose CMS.
|
||||||
- Roles and permissions for admin access.
|
|
||||||
- Background tasks and scheduled refresh jobs.
|
|
||||||
- Player onboarding and live playback control.
|
|
||||||
|
|
||||||
## 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:
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -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.
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
|
PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
|
||||||
PULSE_SIGNAGE_SHARED_SECRET=
|
PULSE_SIGNAGE_SHARED_SECRET=
|
||||||
|
PLAYER_IDENTIFIER=player-local
|
||||||
|
|
||||||
DB_HOST=mysql
|
DB_HOST=mysql
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=pulse-signage
|
DB_NAME=signage
|
||||||
DB_USER=pulse-signage
|
DB_USER=signage_user
|
||||||
DB_PASSWORD=signage_password
|
DB_PASSWORD=signage_password
|
||||||
MYSQL_ROOT_PASSWORD=root_password
|
MYSQL_ROOT_PASSWORD=root_password
|
||||||
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: pulse-signage
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||||
@@ -12,13 +14,12 @@ services:
|
|||||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||||
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
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_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||||
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
|
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
|
||||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
|
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
|
||||||
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
|
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
|
||||||
volumes:
|
volumes:
|
||||||
- media:/app/media
|
- pulse-signage:/app/media
|
||||||
command: ["node", "src/web.js"]
|
command: ["node", "src/web.js"]
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
@@ -34,6 +35,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
|
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
|
||||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player: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:-}
|
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||||
DB_HOST: ${DB_HOST:-mysql}
|
DB_HOST: ${DB_HOST:-mysql}
|
||||||
DB_PORT: ${DB_PORT:-3306}
|
DB_PORT: ${DB_PORT:-3306}
|
||||||
@@ -41,7 +43,7 @@ services:
|
|||||||
DB_USER: ${DB_USER:-signage_user}
|
DB_USER: ${DB_USER:-signage_user}
|
||||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||||
volumes:
|
volumes:
|
||||||
- media:/app/media
|
- pulse-signage:/app/media
|
||||||
command: ["node", "src/player.js"]
|
command: ["node", "src/player.js"]
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
@@ -49,9 +51,30 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- pulse_signage
|
- 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:
|
mysql:
|
||||||
image: mysql:8.4
|
image: mysql:8.4
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
environment:
|
environment:
|
||||||
MYSQL_DATABASE: ${DB_NAME:-signage}
|
MYSQL_DATABASE: ${DB_NAME:-signage}
|
||||||
MYSQL_USER: ${DB_USER:-signage_user}
|
MYSQL_USER: ${DB_USER:-signage_user}
|
||||||
@@ -72,9 +95,9 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
media:
|
pulse-signage:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
pulse_signage:
|
pulse_signage:
|
||||||
name: pulse_signage
|
name: pulse-signage
|
||||||
external: false
|
external: false
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
+6
-7
@@ -112,7 +112,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
|||||||
## Devices
|
## Devices
|
||||||
|
|
||||||
- `d_players` - player registry and connection metadata.
|
- `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.
|
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
|
||||||
|
|
||||||
## Announcements
|
## Announcements
|
||||||
@@ -122,18 +122,18 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
|||||||
|
|
||||||
### `d_players`
|
### `d_players`
|
||||||
|
|
||||||
- `device_id`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at`
|
- `id`, `identifier`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at`
|
||||||
- `device_id` is the primary key.
|
- `id` is the primary key.
|
||||||
|
- `identifier` is unique and is the stable player identity used by the app.
|
||||||
|
|
||||||
### `d_screens`
|
### `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.
|
- `slug` is unique.
|
||||||
- Foreign keys:
|
- Foreign keys:
|
||||||
- `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL`
|
- `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`
|
### `d_onboarding_devices`
|
||||||
|
|
||||||
@@ -275,7 +275,6 @@ erDiagram
|
|||||||
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
|
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
|
||||||
C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules
|
C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules
|
||||||
|
|
||||||
D_PLAYERS ||--o{ D_SCREENS : assigned_to
|
|
||||||
C_PLAYLISTS ||--o{ D_SCREENS : uses
|
C_PLAYLISTS ||--o{ D_SCREENS : uses
|
||||||
D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds
|
D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds
|
||||||
D_ANNOUNCEMENTS ||--o{ D_ANNOUNCEMENT_SCREENS : targets
|
D_ANNOUNCEMENTS ||--o{ D_ANNOUNCEMENT_SCREENS : targets
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.5.14",
|
"version": "2.6.5",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ module.exports = {
|
|||||||
fetchPlayerPublicBaseUrl: data.fetchPlayerPublicBaseUrl,
|
fetchPlayerPublicBaseUrl: data.fetchPlayerPublicBaseUrl,
|
||||||
fetchScreenPlayerRecord: data.fetchScreenPlayerRecord,
|
fetchScreenPlayerRecord: data.fetchScreenPlayerRecord,
|
||||||
fetchTemplateById: data.fetchTemplateById,
|
fetchTemplateById: data.fetchTemplateById,
|
||||||
|
fetchPlayerRegistrations: data.fetchPlayerRegistrations,
|
||||||
fetchSlideById: data.fetchSlideById,
|
fetchSlideById: data.fetchSlideById,
|
||||||
fetchTemplatesData: data.fetchTemplatesData,
|
fetchTemplatesData: data.fetchTemplatesData,
|
||||||
fetchCanvasSizesData: data.fetchCanvasSizesData,
|
fetchCanvasSizesData: data.fetchCanvasSizesData,
|
||||||
|
|||||||
+1
-2
@@ -22,10 +22,9 @@ async function fetchAdminData(pool) {
|
|||||||
ORDER BY s.id DESC
|
ORDER BY s.id DESC
|
||||||
`);
|
`);
|
||||||
const [screens] = await pool.query(`
|
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
|
FROM d_screens s
|
||||||
LEFT JOIN c_playlists p ON p.id = s.playlist_id
|
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
|
ORDER BY s.id DESC
|
||||||
`);
|
`);
|
||||||
const [playlistSlides] = await pool.query(`
|
const [playlistSlides] = await pool.query(`
|
||||||
|
|||||||
+4
-1
@@ -7,7 +7,8 @@ const { fetchPlaylistById } = require('./playlists');
|
|||||||
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
|
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
|
||||||
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
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 { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
|
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
|
||||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||||
@@ -63,6 +64,8 @@ module.exports = {
|
|||||||
fetchScreenPlayerUrls,
|
fetchScreenPlayerUrls,
|
||||||
fetchPlayerPublicBaseUrl,
|
fetchPlayerPublicBaseUrl,
|
||||||
fetchScreenPlayerRecord,
|
fetchScreenPlayerRecord,
|
||||||
|
fetchPlayerRecordByIdentifier,
|
||||||
|
fetchPlayerRegistrations,
|
||||||
fetchTemplateById,
|
fetchTemplateById,
|
||||||
fetchSlideById,
|
fetchSlideById,
|
||||||
fetchTemplatesData,
|
fetchTemplatesData,
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
+48
-19
@@ -13,6 +13,27 @@ function normalizePlayerBaseUrl(value) {
|
|||||||
return String(value || '').trim().replace(/\/$/, '');
|
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) {
|
function buildScreenPlayerUrl(screen, fallbackBaseUrl) {
|
||||||
const slug = String(screen && screen.slug || '').trim();
|
const slug = String(screen && screen.slug || '').trim();
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
@@ -28,20 +49,21 @@ function buildScreenPlayerUrl(screen, fallbackBaseUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchScreenPlayerUrls(pool) {
|
async function fetchScreenPlayerUrls(pool) {
|
||||||
|
const playerBaseUrl = await fetchPlayerPublicBaseUrl(pool);
|
||||||
|
if (!playerBaseUrl) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
const [rows] = await pool.query(`
|
const [rows] = await pool.query(`
|
||||||
SELECT s.slug, p.public_base_url
|
SELECT slug
|
||||||
FROM d_screens s
|
FROM d_screens
|
||||||
LEFT JOIN d_players p ON p.device_id = s.player_id
|
ORDER BY slug ASC
|
||||||
WHERE p.public_base_url IS NOT NULL
|
|
||||||
AND TRIM(p.public_base_url) <> ''
|
|
||||||
ORDER BY p.modified_at DESC, s.slug ASC
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const playerUrls = {};
|
const playerUrls = {};
|
||||||
rows.forEach(function (row) {
|
rows.forEach(function (row) {
|
||||||
const slug = String(row && row.slug || '').trim();
|
const slug = String(row && row.slug || '').trim();
|
||||||
const publicBaseUrl = normalizePlayerBaseUrl(row && row.public_base_url);
|
const playerUrl = buildScreenPlayerUrl({ slug: slug }, playerBaseUrl);
|
||||||
const playerUrl = buildScreenPlayerUrl({ slug: slug }, publicBaseUrl);
|
|
||||||
if (slug && playerUrl && !playerUrls[slug]) {
|
if (slug && playerUrl && !playerUrls[slug]) {
|
||||||
playerUrls[slug] = playerUrl;
|
playerUrls[slug] = playerUrl;
|
||||||
}
|
}
|
||||||
@@ -51,27 +73,32 @@ async function fetchScreenPlayerUrls(pool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlayerPublicBaseUrl(pool) {
|
async function fetchPlayerPublicBaseUrl(pool) {
|
||||||
|
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
|
||||||
const [rows] = await pool.query(`
|
const [rows] = await pool.query(`
|
||||||
SELECT public_base_url
|
SELECT public_base_url
|
||||||
FROM d_players
|
FROM d_players
|
||||||
WHERE device_id = '1'
|
WHERE identifier = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`);
|
`, [configuredPlayerIdentifier]);
|
||||||
|
|
||||||
return normalizePlayerBaseUrl(rows[0] && rows[0].public_base_url) || null;
|
return normalizePlayerBaseUrl(rows[0] && rows[0].public_base_url) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchScreenPlayerRecord(pool, slug) {
|
async function fetchScreenPlayerRecord(pool, slug) {
|
||||||
const [rows] = await pool.query(`
|
if (slug) {
|
||||||
SELECT p.device_id, p.public_base_url, p.internal_base_url, p.last_seen_at
|
const [rows] = await pool.query(
|
||||||
FROM d_screens s
|
`SELECT slug
|
||||||
JOIN d_players p ON p.device_id = s.player_id
|
FROM d_screens
|
||||||
WHERE s.slug = ?
|
WHERE slug = ?
|
||||||
ORDER BY p.modified_at DESC, p.device_id ASC
|
LIMIT 1`,
|
||||||
LIMIT 1
|
[slug]
|
||||||
`, [slug]);
|
);
|
||||||
|
if (!rows.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return rows[0] || null;
|
return fetchPlayerRecordByIdentifier(pool, getConfiguredPlayerIdentifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uniqueScreenSlug(pool, baseSlug, excludeId) {
|
async function uniqueScreenSlug(pool, baseSlug, excludeId) {
|
||||||
@@ -112,6 +139,8 @@ async function fetchScreenEditData(pool) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
slugify,
|
slugify,
|
||||||
normalizePlayerBaseUrl,
|
normalizePlayerBaseUrl,
|
||||||
|
getConfiguredPlayerIdentifier,
|
||||||
|
fetchPlayerRecordByIdentifier,
|
||||||
buildScreenPlayerUrl,
|
buildScreenPlayerUrl,
|
||||||
fetchScreenPlayerUrls,
|
fetchScreenPlayerUrls,
|
||||||
fetchPlayerPublicBaseUrl,
|
fetchPlayerPublicBaseUrl,
|
||||||
|
|||||||
+34
-4
@@ -1,6 +1,18 @@
|
|||||||
// Snapshot only: keep this file aligned with the current schema state.
|
// Snapshot only: keep this file aligned with the current schema state.
|
||||||
async function ensureSchema(pool, options) {
|
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 (
|
CREATE TABLE IF NOT EXISTS c_canvas_sizes (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
@@ -116,13 +128,24 @@ async function ensureSchema(pool, options) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) 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(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS d_screens (
|
CREATE TABLE IF NOT EXISTS d_screens (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||||
playlist_id INT NULL,
|
playlist_id INT NULL,
|
||||||
player_id INT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
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
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const { runMigrations } = require('./migrations');
|
const { runMigrations } = require('./migrations');
|
||||||
await runMigrations(pool, options);
|
await runMigrations(pool, options);
|
||||||
|
} finally {
|
||||||
|
await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
schemaConnection.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
+139
-5
@@ -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.
|
// 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'.
|
// 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'))) {
|
if (!(await columnExists(pool, 'd_screens', 'player_id'))) {
|
||||||
@@ -212,6 +208,93 @@ const VERSIONED_MIGRATIONS = [
|
|||||||
run: async function (pool) {
|
run: async function (pool) {
|
||||||
await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio');
|
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;
|
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) {
|
async function ensureColumn(pool, tableName, columnName, columnDefinition, afterColumn) {
|
||||||
if (await columnExists(pool, tableName, columnName)) {
|
if (await columnExists(pool, tableName, columnName)) {
|
||||||
return;
|
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) {
|
async function dropColumnIfExists(pool, tableName, columnName) {
|
||||||
if (await columnExists(pool, tableName, columnName)) {
|
if (await columnExists(pool, tableName, columnName)) {
|
||||||
try {
|
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.
|
// 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 targetVersion = String(appVersion || '0.0.0').trim();
|
||||||
const currentVersion = String(options && options.currentVersion || '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) {
|
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);
|
await migration.run(pool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
+289
-19
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { WebSocket } = require('ws');
|
||||||
const common = require('./common');
|
const common = require('./common');
|
||||||
const { createPlayerRuntime } = require('./player/runtime');
|
const { createPlayerRuntime } = require('./player/runtime');
|
||||||
const { createPlayerPlaylistService } = require('./player/playlist');
|
const { createPlayerPlaylistService } = require('./player/playlist');
|
||||||
@@ -10,16 +11,20 @@ const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding }
|
|||||||
const { createOnboardingStore } = require('./player/onboarding/store');
|
const { createOnboardingStore } = require('./player/onboarding/store');
|
||||||
const { registerPlayerRoutes } = require('./player/routes');
|
const { registerPlayerRoutes } = require('./player/routes');
|
||||||
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
|
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.
|
// Player runtime, media API, and websocket wiring.
|
||||||
async function start() {
|
async function start() {
|
||||||
const app = express();
|
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 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_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 THIN_CLIENT_BASE_URL = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
|
||||||
const PLAYER_IDENTIFIER = '1';
|
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 ASSET_DIR = path.join(__dirname, 'player', 'public');
|
||||||
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||||
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
|
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
|
||||||
@@ -29,24 +34,151 @@ async function start() {
|
|||||||
pool: pool,
|
pool: pool,
|
||||||
normalizeDeviceId: normalizeDeviceId
|
normalizeDeviceId: normalizeDeviceId
|
||||||
});
|
});
|
||||||
const playerPlaylistService = createPlayerPlaylistService({
|
const playerPlaylistService = isRemotePlayer
|
||||||
pool: pool,
|
? null
|
||||||
common: common,
|
: createPlayerPlaylistService({
|
||||||
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
|
pool: pool,
|
||||||
});
|
common: common,
|
||||||
|
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
|
||||||
|
});
|
||||||
const rtmpStreamService = createRtmpStreamService({
|
const rtmpStreamService = createRtmpStreamService({
|
||||||
mediaDir: MEDIA_DIR
|
mediaDir: MEDIA_DIR
|
||||||
});
|
});
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
playerRuntime.installWebsocket(server);
|
playerRuntime.installWebsocket(server);
|
||||||
app.use(express.json());
|
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, {
|
registerPlayerOnboardingRoutes(app, {
|
||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
playerRuntime: playerRuntime,
|
playerRuntime: playerRuntime,
|
||||||
onboardingStore: onboardingStore,
|
onboardingStore: onboardingStore,
|
||||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
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, {
|
registerPlayerRoutes(app, {
|
||||||
pool: pool,
|
pool: pool,
|
||||||
@@ -58,24 +190,151 @@ async function start() {
|
|||||||
rtmpStreamService: rtmpStreamService,
|
rtmpStreamService: rtmpStreamService,
|
||||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||||
playerInternalBaseUrl: PLAYER_INTERNAL_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) {
|
function createThinClientWebSocketUrl() {
|
||||||
console.error(error);
|
if (!THIN_CLIENT_BASE_URL) {
|
||||||
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
|
return null;
|
||||||
});
|
}
|
||||||
|
|
||||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
return THIN_CLIENT_BASE_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players';
|
||||||
await ensureFontLibrary(MEDIA_DIR);
|
}
|
||||||
|
|
||||||
|
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 () {
|
server.listen(PORT, function () {
|
||||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function syncDatabaseState() {
|
async function syncDatabaseState() {
|
||||||
|
if (isRemotePlayer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
if (playerRuntime.snapshotAllConnections().length > 0) {
|
||||||
await common.pruneStaleOnboardingDevices(pool);
|
await common.pruneStaleOnboardingDevices(pool);
|
||||||
@@ -98,16 +357,27 @@ async function start() {
|
|||||||
|
|
||||||
await syncDatabaseState();
|
await syncDatabaseState();
|
||||||
|
|
||||||
if (PLAYER_IDENTIFIER) {
|
if (PLAYER_DEVICE_ID && !isRemotePlayer) {
|
||||||
const { upsertPlayerRegistration } = require('./player/onboarding');
|
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 () {
|
setInterval(function () {
|
||||||
|
if (isRemotePlayer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
syncDatabaseState().catch(function (error) {
|
syncDatabaseState().catch(function (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
}, DB_SYNC_INTERVAL_MS);
|
}, DB_SYNC_INTERVAL_MS);
|
||||||
|
|
||||||
|
process.on('exit', function () {
|
||||||
|
if (typeof stopThinClientRegistration === 'function') {
|
||||||
|
stopThinClientRegistration();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { start };
|
module.exports = { start };
|
||||||
|
|||||||
+130
-44
@@ -1,8 +1,10 @@
|
|||||||
// Player onboarding routes and signup flow helpers.
|
// Player onboarding routes and signup flow helpers.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||||
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
|
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 { isTransientDbError } = require('./store');
|
||||||
|
|
||||||
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
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 normalizedDeviceId = normalizeDeviceId(deviceId);
|
||||||
const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, '');
|
const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, '');
|
||||||
const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, '');
|
const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, '');
|
||||||
@@ -141,18 +143,11 @@ async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalB
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
return upsertPlayerRegistrationRecord(pool, {
|
||||||
`INSERT INTO d_players (device_id, public_base_url, internal_base_url, last_seen_at)
|
identifier: normalizedDeviceId,
|
||||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
publicBaseUrl: normalizedPublicBaseUrl || null,
|
||||||
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`,
|
internalBaseUrl: normalizedInternalBaseUrl || null
|
||||||
[normalizedDeviceId, normalizedPublicBaseUrl || null, normalizedInternalBaseUrl || null]
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
device_id: normalizedDeviceId,
|
|
||||||
public_base_url: normalizedPublicBaseUrl || null,
|
|
||||||
internal_base_url: normalizedInternalBaseUrl || null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bindPlayerToScreen(pool, deviceId, screenSlug) {
|
async function bindPlayerToScreen(pool, deviceId, screenSlug) {
|
||||||
@@ -167,25 +162,9 @@ async function bindPlayerToScreen(pool, deviceId, screenSlug) {
|
|||||||
return null;
|
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 {
|
return {
|
||||||
screen_id: screenId,
|
screen_id: Number(screenRows[0].id),
|
||||||
screen_slug: normalizedScreenSlug,
|
screen_slug: normalizedScreenSlug
|
||||||
player_id: '1'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,13 +203,66 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
const common = options && options.common ? options.common : null;
|
const common = options && options.common ? options.common : null;
|
||||||
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||||
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : 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) {
|
if (!app || !common) {
|
||||||
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, and playerRuntime.');
|
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();
|
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) {
|
function requireOnboardingPageAuth(req, res, next) {
|
||||||
if (!sharedSecret) {
|
if (!sharedSecret) {
|
||||||
return next();
|
return next();
|
||||||
@@ -254,7 +286,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
app.get('/onboard', async function (req, res, next) {
|
app.get('/onboard', async function (req, res, next) {
|
||||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
try {
|
try {
|
||||||
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
|
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || playerDeviceId || '').trim()));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -272,15 +304,32 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
next();
|
next();
|
||||||
}, async function (req, res, next) {
|
}, async function (req, res, next) {
|
||||||
try {
|
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({
|
res.json({
|
||||||
deviceId: normalizeDeviceId(req.query.deviceId),
|
deviceId: normalizeDeviceId(deviceId),
|
||||||
onboarded: Boolean(status && status.screen_id),
|
onboarded: Boolean(status && status.screen_id),
|
||||||
clientName: status ? status.client_name : null,
|
clientName: status ? status.client_name : null,
|
||||||
screenId: status ? status.screen_id : null,
|
screenId: status ? status.screen_id : null,
|
||||||
screenSlug: status ? status.screen_slug : null,
|
screenSlug: status ? status.screen_slug : null,
|
||||||
screenName: status ? status.screen_name : 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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -289,6 +338,16 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
|
|
||||||
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
|
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
|
||||||
try {
|
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');
|
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
|
||||||
res.json({ screens: rows });
|
res.json({ screens: rows });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -298,11 +357,11 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
|
|
||||||
app.get('/api/onboarding/qr', async function (req, res, next) {
|
app.get('/api/onboarding/qr', async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const deviceId = normalizeDeviceId(req.query.deviceId);
|
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
return res.status(400).json({ error: 'Device ID is required' });
|
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 });
|
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
|
||||||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||||
res.set('Cache-Control', 'no-store');
|
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 {
|
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 clientName = String((req.body && req.body.clientName) || '').trim();
|
||||||
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
|
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
|
||||||
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
|
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
|
||||||
@@ -322,6 +381,30 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
res.set('Retry-After', String(retryAfterSeconds));
|
res.set('Retry-After', String(retryAfterSeconds));
|
||||||
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
|
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) {
|
if (!deviceId) {
|
||||||
return res.status(400).json({ error: 'Device ID is required' });
|
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,
|
screenId: status && status.screen_id ? status.screen_id : null,
|
||||||
screenSlug: status ? status.screen_slug : screenSlug,
|
screenSlug: status ? status.screen_slug : screenSlug,
|
||||||
screenName: status && status.screen_name ? status.screen_name : null,
|
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)
|
queued: Boolean(status && status.queued)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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.'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,14 @@
|
|||||||
return screens;
|
return screens;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!deviceId) { setMessage("Missing device id. Scan the QR code from the player screen again."); return; }
|
try {
|
||||||
try { window.localStorage.setItem(deviceKey, deviceId); } catch (_error) {}
|
if (!deviceId) {
|
||||||
|
deviceId = window.localStorage.getItem(deviceKey) || "";
|
||||||
|
}
|
||||||
|
if (deviceId) {
|
||||||
|
window.localStorage.setItem(deviceKey, deviceId);
|
||||||
|
}
|
||||||
|
} catch (_error) {}
|
||||||
loadScreens().then(function () {
|
loadScreens().then(function () {
|
||||||
try {
|
try {
|
||||||
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
|
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
|
||||||
@@ -58,10 +64,14 @@
|
|||||||
if (!clientName) { setMessage("Client name is required."); return; }
|
if (!clientName) { setMessage("Client name is required."); return; }
|
||||||
if (!screenSlug) { setMessage("Screen is required."); return; }
|
if (!screenSlug) { setMessage("Screen is required."); return; }
|
||||||
setMessage("Saving client...");
|
setMessage("Saving client...");
|
||||||
|
var payload = { clientName: clientName, screenSlug: screenSlug };
|
||||||
|
if (deviceId) {
|
||||||
|
payload.deviceId = deviceId;
|
||||||
|
}
|
||||||
fetch("/api/onboarding", {
|
fetch("/api/onboarding", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
|
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
|
||||||
if (storedClientName && localForm) {
|
if (storedClientName && localForm) {
|
||||||
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
|
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; }
|
if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; }
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
|
|||||||
+186
-14
@@ -3,7 +3,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
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 { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
||||||
|
|
||||||
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
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);
|
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) {
|
function registerPlayerRoutes(app, options) {
|
||||||
const pool = options && options.pool ? options.pool : null;
|
const pool = options && options.pool ? options.pool : null;
|
||||||
const common = options && options.common ? options.common : 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 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 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 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) {
|
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
|
||||||
throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and 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();
|
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) {
|
function requirePageAuth(allowedScopes) {
|
||||||
return function (req, res, next) {
|
return function (req, res, next) {
|
||||||
if (!sharedSecret) {
|
if (!sharedSecret) {
|
||||||
@@ -114,6 +179,26 @@ function registerPlayerRoutes(app, options) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
|
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({
|
res.json({
|
||||||
mediaDir: mediaDir,
|
mediaDir: mediaDir,
|
||||||
uploadDir: path.join(mediaDir, 'uploads')
|
uploadDir: path.join(mediaDir, 'uploads')
|
||||||
@@ -213,9 +298,35 @@ function registerPlayerRoutes(app, options) {
|
|||||||
app.get('/screen/:slug', function (req, res) {
|
app.get('/screen/:slug', function (req, res) {
|
||||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
res.set('Pragma', 'no-cache');
|
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');
|
const { bindPlayerToScreen } = require('./onboarding');
|
||||||
if (playerIdentifier) {
|
if (playerDeviceId) {
|
||||||
void bindPlayerToScreen(pool, playerIdentifier, req.params.slug)
|
void bindPlayerToScreen(pool, playerDeviceId, req.params.slug)
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
console.error(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) {
|
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
||||||
try {
|
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));
|
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||||
if (!slide) {
|
if (!slide) {
|
||||||
return res.status(404).send('Slide not found');
|
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) {
|
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
||||||
try {
|
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');
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
|
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
|
||||||
if (!data.screen) {
|
if (!data.screen) {
|
||||||
@@ -293,6 +440,29 @@ function registerPlayerRoutes(app, options) {
|
|||||||
|
|
||||||
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
|
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
|
||||||
try {
|
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');
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
const announcement = typeof common.fetchActiveAnnouncement === 'function'
|
const announcement = typeof common.fetchActiveAnnouncement === 'function'
|
||||||
? await common.fetchActiveAnnouncement(pool, req.params.slug)
|
? await common.fetchActiveAnnouncement(pool, req.params.slug)
|
||||||
@@ -335,13 +505,15 @@ function registerPlayerRoutes(app, options) {
|
|||||||
const connections = playerRuntime.snapshotConnections(req.params.slug);
|
const connections = playerRuntime.snapshotConnections(req.params.slug);
|
||||||
let screen = null;
|
let screen = null;
|
||||||
let screenLookupFailed = false;
|
let screenLookupFailed = false;
|
||||||
try {
|
if (pool && typeof pool.query === 'function') {
|
||||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
try {
|
||||||
screen = screenRows[0] || null;
|
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||||
} catch (error) {
|
screen = screenRows[0] || null;
|
||||||
screenLookupFailed = isTransientDbError(error);
|
} catch (error) {
|
||||||
if (!screenLookupFailed) {
|
screenLookupFailed = isTransientDbError(error);
|
||||||
throw error;
|
if (!screenLookupFailed) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
@@ -374,7 +546,7 @@ function registerPlayerRoutes(app, options) {
|
|||||||
const isRedirectCommand = command === 'redirect';
|
const isRedirectCommand = command === 'redirect';
|
||||||
let screen = null;
|
let screen = null;
|
||||||
let screenLookupFailed = false;
|
let screenLookupFailed = false;
|
||||||
if (!isRedirectCommand) {
|
if (!isRedirectCommand && pool && typeof pool.query === 'function') {
|
||||||
try {
|
try {
|
||||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||||
screen = screenRows[0] || null;
|
screen = screenRows[0] || null;
|
||||||
|
|||||||
+5
-1
@@ -62,7 +62,7 @@ async function start() {
|
|||||||
const playerActionService = createPlayerActionService({
|
const playerActionService = createPlayerActionService({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
playerInternalBaseUrl: webConfig.playerInternalBaseUrl
|
playerInternalBaseUrl: webConfig.thinClientBaseUrl
|
||||||
});
|
});
|
||||||
const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
|
const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
|
||||||
|
|
||||||
@@ -71,6 +71,7 @@ async function start() {
|
|||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
playerInternalBaseUrl: webConfig.playerInternalBaseUrl,
|
playerInternalBaseUrl: webConfig.playerInternalBaseUrl,
|
||||||
|
thinClientBaseUrl: webConfig.thinClientBaseUrl,
|
||||||
uploadDir: webConfig.uploadsDir,
|
uploadDir: webConfig.uploadsDir,
|
||||||
formatDashboardDate: formatDashboardDate,
|
formatDashboardDate: formatDashboardDate,
|
||||||
notifyPlayerScreens: notifyPlayerScreens,
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
@@ -155,6 +156,8 @@ async function start() {
|
|||||||
},
|
},
|
||||||
hasAnyPermission: hasAnyPermission,
|
hasAnyPermission: hasAnyPermission,
|
||||||
backgroundTaskQueue: backgroundTaskQueue,
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
|
mediaDir: webConfig.mediaDir,
|
||||||
|
uploadSyncService: webBootstrap.uploadSyncService,
|
||||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||||
@@ -207,6 +210,7 @@ async function start() {
|
|||||||
initializeBackgroundTasks: initializeBackgroundTasks,
|
initializeBackgroundTasks: initializeBackgroundTasks,
|
||||||
captureSlideThumbnail: captureSlideThumbnail,
|
captureSlideThumbnail: captureSlideThumbnail,
|
||||||
server: server,
|
server: server,
|
||||||
|
webBaseUrl: webConfig.webBaseUrl,
|
||||||
dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs
|
dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Vendored
+12
-56
@@ -9,6 +9,7 @@ function createWebBootstrap(options) {
|
|||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
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 uploadDir = String(options && options.uploadDir || '').trim();
|
||||||
const dashboardRefreshIntervalMs = 5000;
|
const dashboardRefreshIntervalMs = 5000;
|
||||||
const formatDashboardDate = options && options.formatDashboardDate;
|
const formatDashboardDate = options && options.formatDashboardDate;
|
||||||
@@ -25,49 +26,8 @@ function createWebBootstrap(options) {
|
|||||||
const playerSnapshotSockets = new Map();
|
const playerSnapshotSockets = new Map();
|
||||||
let dashboardRefreshInFlight = null;
|
let dashboardRefreshInFlight = null;
|
||||||
let broadcastDashboardState = null;
|
let broadcastDashboardState = null;
|
||||||
let playerInternalBaseUrl = null;
|
function getPlayerSnapshotSocketUrl(slug) {
|
||||||
let playerInternalBaseUrlPromise = null;
|
const resolvedPlayerInternalBaseUrl = configuredThinClientBaseUrl || configuredPlayerInternalBaseUrl;
|
||||||
|
|
||||||
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();
|
|
||||||
if (!resolvedPlayerInternalBaseUrl) {
|
if (!resolvedPlayerInternalBaseUrl) {
|
||||||
throw new Error('Unable to resolve the player internal base URL.');
|
throw new Error('Unable to resolve the player internal base URL.');
|
||||||
}
|
}
|
||||||
@@ -78,16 +38,6 @@ function createWebBootstrap(options) {
|
|||||||
return url.toString();
|
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) {
|
function clearPlayerSnapshotSocket(slug) {
|
||||||
const key = String(slug || '').trim();
|
const key = String(slug || '').trim();
|
||||||
playerSnapshotSockets.delete(key);
|
playerSnapshotSockets.delete(key);
|
||||||
@@ -100,12 +50,12 @@ function createWebBootstrap(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
playerSnapshotSockets.set(key, null);
|
playerSnapshotSockets.set(key, null);
|
||||||
const socketUrlPromise = getPlayerSnapshotSocketUrl(key);
|
|
||||||
const authHeaders = createRequestAuthHeaders({
|
const authHeaders = createRequestAuthHeaders({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
||||||
});
|
});
|
||||||
socketUrlPromise.then(function (socketUrl) {
|
|
||||||
|
Promise.resolve(getPlayerSnapshotSocketUrl(key)).then(function (socketUrl) {
|
||||||
const socket = new WebSocket(socketUrl, {
|
const socket = new WebSocket(socketUrl, {
|
||||||
headers: authHeaders
|
headers: authHeaders
|
||||||
});
|
});
|
||||||
@@ -117,7 +67,11 @@ function createWebBootstrap(options) {
|
|||||||
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
|
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
|
||||||
return;
|
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) {
|
if (broadcastDashboardState) {
|
||||||
broadcastDashboardState().catch(function (error) {
|
broadcastDashboardState().catch(function (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -151,6 +105,7 @@ function createWebBootstrap(options) {
|
|||||||
const dashboardStateService = createDashboardStateService({
|
const dashboardStateService = createDashboardStateService({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
|
thinClientBaseUrl: configuredThinClientBaseUrl,
|
||||||
playerSnapshotCache: playerSnapshotCache,
|
playerSnapshotCache: playerSnapshotCache,
|
||||||
playerSnapshotSockets: playerSnapshotSockets,
|
playerSnapshotSockets: playerSnapshotSockets,
|
||||||
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
||||||
@@ -259,6 +214,7 @@ function createWebBootstrap(options) {
|
|||||||
return {
|
return {
|
||||||
upload: upload,
|
upload: upload,
|
||||||
uploadSyncService: uploadSyncService,
|
uploadSyncService: uploadSyncService,
|
||||||
|
playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null,
|
||||||
buildDashboardState: buildDashboardState,
|
buildDashboardState: buildDashboardState,
|
||||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
|
const { resolvePlayerRegistration } = require('#src/data/player-registry');
|
||||||
|
|
||||||
const TASK = {
|
const TASK = {
|
||||||
taskType: 'slide-thumbnail-refresh'
|
taskType: 'slide-thumbnail-refresh'
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchPlayerInternalBaseUrl(pool) {
|
async function fetchPlayerInternalBaseUrl(pool, configuredPlayerInternalBaseUrl) {
|
||||||
const [rows] = await pool.query(
|
const configured = String(configuredPlayerInternalBaseUrl || '').trim().replace(/\/$/, '');
|
||||||
`SELECT internal_base_url
|
if (configured) {
|
||||||
FROM d_players
|
return configured;
|
||||||
WHERE device_id = '1'
|
}
|
||||||
LIMIT 1`
|
|
||||||
);
|
|
||||||
|
|
||||||
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) {
|
function registerSlideThumbnailRefreshTask(options) {
|
||||||
@@ -19,6 +21,7 @@ function registerSlideThumbnailRefreshTask(options) {
|
|||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||||
|
const configuredWebBaseUrl = String(options && options.webBaseUrl || '').trim().replace(/\/$/, '');
|
||||||
|
|
||||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||||
throw new Error('registerSlideThumbnailRefreshTask requires the slide thumbnail dependencies.');
|
throw new Error('registerSlideThumbnailRefreshTask requires the slide thumbnail dependencies.');
|
||||||
@@ -32,18 +35,19 @@ function registerSlideThumbnailRefreshTask(options) {
|
|||||||
throw new Error('Slide id is required.');
|
throw new Error('Slide id is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
const webBaseUrl = configuredWebBaseUrl || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`;
|
||||||
if (!playerInternalBaseUrl) {
|
if (!webBaseUrl) {
|
||||||
throw new Error('Player internal base URL is required.');
|
throw new Error('Web base URL is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return captureSlideThumbnail({
|
return captureSlideThumbnail({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
mediaDir: mediaDir,
|
mediaDir: mediaDir,
|
||||||
baseUrl: playerInternalBaseUrl,
|
baseUrl: webBaseUrl,
|
||||||
slideId: slideId,
|
slideId: slideId,
|
||||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
previousThumbnailPath: payload.previousThumbnailPath || null,
|
||||||
|
fontStylesheetHref: payload.fontStylesheetHref || ''
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
|
const { resolvePlayerRegistration } = require('#src/data/player-registry');
|
||||||
|
|
||||||
const TASK = {
|
const TASK = {
|
||||||
taskType: 'template-slide-thumbnail-refresh'
|
taskType: 'template-slide-thumbnail-refresh'
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchPlayerInternalBaseUrl(pool) {
|
async function fetchPlayerInternalBaseUrl(pool, configuredPlayerInternalBaseUrl) {
|
||||||
const [rows] = await pool.query(
|
const configured = String(configuredPlayerInternalBaseUrl || '').trim().replace(/\/$/, '');
|
||||||
`SELECT internal_base_url
|
if (configured) {
|
||||||
FROM d_players
|
return configured;
|
||||||
WHERE device_id = '1'
|
}
|
||||||
LIMIT 1`
|
|
||||||
);
|
|
||||||
|
|
||||||
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) {
|
function registerTemplateSlideThumbnailRefreshTask(options) {
|
||||||
@@ -19,6 +21,7 @@ function registerTemplateSlideThumbnailRefreshTask(options) {
|
|||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||||
|
const configuredWebBaseUrl = String(options && options.webBaseUrl || '').trim().replace(/\/$/, '');
|
||||||
|
|
||||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||||
throw new Error('registerTemplateSlideThumbnailRefreshTask requires the template thumbnail dependencies.');
|
throw new Error('registerTemplateSlideThumbnailRefreshTask requires the template thumbnail dependencies.');
|
||||||
@@ -32,9 +35,9 @@ function registerTemplateSlideThumbnailRefreshTask(options) {
|
|||||||
throw new Error('Template id is required.');
|
throw new Error('Template id is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
const webBaseUrl = configuredWebBaseUrl || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`;
|
||||||
if (!playerInternalBaseUrl) {
|
if (!webBaseUrl) {
|
||||||
throw new Error('Player internal base URL is required.');
|
throw new Error('Web base URL is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [slides] = await pool.query(
|
const [slides] = await pool.query(
|
||||||
@@ -52,7 +55,7 @@ function registerTemplateSlideThumbnailRefreshTask(options) {
|
|||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
mediaDir: mediaDir,
|
mediaDir: mediaDir,
|
||||||
baseUrl: playerInternalBaseUrl,
|
baseUrl: webBaseUrl,
|
||||||
slideId: slideId,
|
slideId: slideId,
|
||||||
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
|
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ function createWebConfig() {
|
|||||||
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
||||||
const assetDir = path.join(__dirname, '..', 'public');
|
const assetDir = path.join(__dirname, '..', 'public');
|
||||||
const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
|
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 sessionCookieName = 'digital_signage_session';
|
||||||
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
||||||
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
|
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
|
||||||
@@ -19,6 +21,8 @@ function createWebConfig() {
|
|||||||
thumbnailsDir: thumbnailsDir,
|
thumbnailsDir: thumbnailsDir,
|
||||||
assetDir: assetDir,
|
assetDir: assetDir,
|
||||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||||
|
thinClientBaseUrl: thinClientBaseUrl,
|
||||||
|
webBaseUrl: webBaseUrl,
|
||||||
sessionCookieName: sessionCookieName,
|
sessionCookieName: sessionCookieName,
|
||||||
sessionMaxAgeMs: sessionMaxAgeMs,
|
sessionMaxAgeMs: sessionMaxAgeMs,
|
||||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ function normalizeClientName(value) {
|
|||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePlayerBaseUrl(value) {
|
||||||
|
return String(value || '').trim().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) {
|
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) {
|
||||||
return (screens || []).map(function (screen) {
|
return (screens || []).map(function (screen) {
|
||||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
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) {
|
return (screens || []).flatMap(function (screen) {
|
||||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||||
return (connectionState.connections || []).map(function (connection) {
|
return (connectionState.connections || []).map(function (connection) {
|
||||||
const deviceId = String(connection.deviceId || '').trim();
|
const deviceId = String(connection.deviceId || '').trim();
|
||||||
|
const playerBaseUrl = normalizePlayerBaseUrl(connection.playerPublicBaseUrl);
|
||||||
return Object.assign({}, connection, {
|
return Object.assign({}, connection, {
|
||||||
screen_slug: screen.slug,
|
screen_slug: screen.slug,
|
||||||
screen_name: screen.name,
|
screen_name: screen.name,
|
||||||
@@ -30,12 +35,55 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
|
|||||||
playlist_name: screen.playlist_name || null,
|
playlist_name: screen.playlist_name || null,
|
||||||
connectedAtLabel: formatDashboardDate(connection.connectedAt),
|
connectedAtLabel: formatDashboardDate(connection.connectedAt),
|
||||||
lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt),
|
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) {
|
function compareScreenNames(left, right) {
|
||||||
const leftName = String(left && left.name || '').trim();
|
const leftName = String(left && left.name || '').trim();
|
||||||
const rightName = String(right && right.name || '').trim();
|
const rightName = String(right && right.name || '').trim();
|
||||||
@@ -51,6 +99,7 @@ function compareScreenNames(left, right) {
|
|||||||
function createDashboardStateService(options) {
|
function createDashboardStateService(options) {
|
||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
|
const connectedPlayerCountStaleSeconds = options && options.connectedPlayerCountStaleSeconds;
|
||||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||||
const playerSnapshotSockets = options && options.playerSnapshotSockets;
|
const playerSnapshotSockets = options && options.playerSnapshotSockets;
|
||||||
const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription;
|
const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription;
|
||||||
@@ -63,9 +112,13 @@ function createDashboardStateService(options) {
|
|||||||
async function buildDashboardState() {
|
async function buildDashboardState() {
|
||||||
const data = await common.fetchAdminData(pool);
|
const data = await common.fetchAdminData(pool);
|
||||||
const screensData = data.screens || [];
|
const screensData = data.screens || [];
|
||||||
|
const connectedPlayersCount = await fetchConnectedPlayerCount(pool, connectedPlayerCountStaleSeconds);
|
||||||
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
||||||
? await common.fetchScreenPlayerUrls(pool)
|
? await common.fetchScreenPlayerUrls(pool)
|
||||||
: {};
|
: {};
|
||||||
|
const playerRegistrations = typeof common.fetchPlayerRegistrations === 'function'
|
||||||
|
? await common.fetchPlayerRegistrations(pool)
|
||||||
|
: [];
|
||||||
screensData.forEach(function (screen) {
|
screensData.forEach(function (screen) {
|
||||||
ensurePlayerSnapshotSubscription(screen.slug);
|
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 = {};
|
const connectionsBySlug = {};
|
||||||
screensData.forEach(function (screen) {
|
screensData.forEach(function (screen) {
|
||||||
const cached = playerSnapshotCache.get(String(screen.slug || '').trim());
|
const cached = playerSnapshotCache.get(String(screen.slug || '').trim());
|
||||||
@@ -106,7 +168,8 @@ function createDashboardStateService(options) {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.sort(compareScreenNames);
|
.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) {
|
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
||||||
return socket && socket.readyState === WebSocket.OPEN;
|
return socket && socket.readyState === WebSocket.OPEN;
|
||||||
});
|
});
|
||||||
@@ -115,8 +178,10 @@ function createDashboardStateService(options) {
|
|||||||
playlists: data.playlists || [],
|
playlists: data.playlists || [],
|
||||||
screens: screens,
|
screens: screens,
|
||||||
clients: clients,
|
clients: clients,
|
||||||
|
kioskPlayers: kioskPlayers,
|
||||||
slides: data.slides || [],
|
slides: data.slides || [],
|
||||||
playerServiceConnected: playerServiceConnected,
|
playerServiceConnected: playerServiceConnected,
|
||||||
|
connectedPlayersCount: connectedPlayersCount !== null ? connectedPlayersCount : 0,
|
||||||
connectedClientsCount: screens.reduce(function (total, screen) {
|
connectedClientsCount: screens.reduce(function (total, screen) {
|
||||||
return total + Number(screen.player_connection_count || 0);
|
return total + Number(screen.player_connection_count || 0);
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createUploadSyncService: require('./upload-sync').createUploadSyncService,
|
createUploadSyncService: require('./upload-sync').createUploadSyncService,
|
||||||
captureSlideThumbnail: require('./slide-thumbnails').captureSlideThumbnail,
|
captureSlideThumbnail: function captureSlideThumbnail(options) {
|
||||||
|
return require('./slide-thumbnails').captureSlideThumbnail(options);
|
||||||
|
},
|
||||||
fontLibrary: require('./font-library')
|
fontLibrary: require('./font-library')
|
||||||
};
|
};
|
||||||
@@ -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 '<div class="slide-preview-region slide-preview-text-region" style="' + region.baseStyle + '"><div class="slide-preview-text-content" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;overflow:hidden;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? '<div class="slide-preview-region slide-preview-image-region" style="' + region.baseStyle + '"><img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" /></div>'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'video') {
|
||||||
|
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||||
|
return src
|
||||||
|
? '<div class="slide-preview-region slide-preview-video-region" style="' + region.baseStyle + '"><video class="slide-preview-video" src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video></div>'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'webpage') {
|
||||||
|
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||||
|
return src
|
||||||
|
? '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? '<div class="slide-preview-region slide-preview-qr-code-region" style="' + region.baseStyle + radiusStyle + '"><img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'qr code') + '" /></div>'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'html') {
|
||||||
|
const html = String(rawValue || '').trim();
|
||||||
|
return html
|
||||||
|
? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe></div>'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'rtmp') {
|
||||||
|
const label = String(rawValue || '').trim() || 'RTMP source';
|
||||||
|
return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
@@ -2,14 +2,6 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
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 {
|
const {
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
mediaKind,
|
mediaKind,
|
||||||
@@ -73,6 +65,23 @@ function getRegionContent(slide, region) {
|
|||||||
return content && typeof content === 'object' ? content : { value: content };
|
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) {
|
function hasVisibleContent(html) {
|
||||||
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
||||||
}
|
}
|
||||||
@@ -86,7 +95,7 @@ function buildTextRegionMarkup(region, regionContent) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
return '<div class="slide-preview-region slide-preview-text-region" style="' + region.baseStyle + '"><div class="slide-preview-text-content" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;overflow:hidden;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||||
@@ -96,21 +105,21 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
|||||||
if (regionType === 'image') {
|
if (regionType === 'image') {
|
||||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||||
return src
|
return src
|
||||||
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />'
|
? '<div class="slide-preview-region slide-preview-image-region" style="' + region.baseStyle + '"><img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" /></div>'
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (regionType === 'video') {
|
if (regionType === 'video') {
|
||||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||||
return src
|
return src
|
||||||
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>'
|
? '<div class="slide-preview-region slide-preview-video-region" style="' + region.baseStyle + '"><video class="slide-preview-video" src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video></div>'
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (regionType === 'webpage') {
|
if (regionType === 'webpage') {
|
||||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||||
return src
|
return src
|
||||||
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>'
|
? '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>'
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,26 +128,75 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
|||||||
const borderRadius = Math.max(0, Math.round(Number(regionContent.qr_border_radius || 0)));
|
const borderRadius = Math.max(0, Math.round(Number(regionContent.qr_border_radius || 0)));
|
||||||
const radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : '';
|
const radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : '';
|
||||||
return src
|
return src
|
||||||
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'qr code') + '"' + radiusStyle + ' />'
|
? '<div class="slide-preview-region slide-preview-qr-code-region" style="' + region.baseStyle + radiusStyle + '"><img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'qr code') + '" /></div>'
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (regionType === 'html') {
|
if (regionType === 'html') {
|
||||||
const html = String(rawValue || '').trim();
|
const html = String(rawValue || '').trim();
|
||||||
return html
|
return html
|
||||||
? '<iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe>'
|
? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe></div>'
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (regionType === 'rtmp') {
|
if (regionType === 'rtmp') {
|
||||||
const label = String(rawValue || '').trim() || 'RTMP source';
|
const label = String(rawValue || '').trim() || 'RTMP source';
|
||||||
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>';
|
return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildTextRegionMarkup(region, regionContent);
|
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() {
|
async function launchBrowser() {
|
||||||
|
const puppeteer = await loadPuppeteer();
|
||||||
|
const chromium = await loadChromium();
|
||||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||||
return fs.existsSync(candidate);
|
return fs.existsSync(candidate);
|
||||||
}) || '';
|
}) || '';
|
||||||
@@ -178,6 +236,7 @@ async function launchBrowser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function captureSlideThumbnail(options) {
|
async function captureSlideThumbnail(options) {
|
||||||
|
const sharp = loadSharp();
|
||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||||
@@ -194,7 +253,6 @@ async function captureSlideThumbnail(options) {
|
|||||||
throw new Error('Slide not found.');
|
throw new Error('Slide not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvasSize = getCanvasSize(slide);
|
|
||||||
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
||||||
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
||||||
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
||||||
@@ -206,11 +264,11 @@ async function captureSlideThumbnail(options) {
|
|||||||
|
|
||||||
async function waitForThumbnailRender(page) {
|
async function waitForThumbnailRender(page) {
|
||||||
await page.waitForFunction(function () {
|
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 });
|
}, { timeout: 30000 });
|
||||||
|
|
||||||
await page.waitForFunction(function () {
|
await page.waitForFunction(function () {
|
||||||
var canvas = document.querySelector('.slide-canvas');
|
var canvas = document.querySelector('#popup-preview-canvas');
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -244,18 +302,26 @@ async function captureSlideThumbnail(options) {
|
|||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
try {
|
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 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({
|
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
pathname: previewPath
|
pathname: previewPath
|
||||||
}));
|
}));
|
||||||
await page.setViewport(PLAYER_VIEWPORT);
|
|
||||||
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
await waitForThumbnailRender(page);
|
await waitForThumbnailRender(page);
|
||||||
const canvas = await page.$('.slide-canvas');
|
const canvas = await page.$('#popup-preview-canvas');
|
||||||
if (!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 });
|
await canvas.screenshot({ path: fullSizePath });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -298,5 +364,7 @@ async function captureSlideThumbnail(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
captureSlideThumbnail: captureSlideThumbnail
|
captureSlideThumbnail: captureSlideThumbnail,
|
||||||
|
buildThumbnailPreviewPayload: buildThumbnailPreviewPayload,
|
||||||
|
buildThumbnailPreviewMarkup: buildThumbnailPreviewMarkup
|
||||||
};
|
};
|
||||||
@@ -6,6 +6,7 @@ const crypto = require('crypto');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||||
const { collectFontLibrarySyncOperations } = require('./font-library');
|
const { collectFontLibrarySyncOperations } = require('./font-library');
|
||||||
|
const { getConfiguredPlayerIdentifier, resolvePlayerRegistration } = require('#src/data/player-registry');
|
||||||
|
|
||||||
function normalizeUploadRoot(uploadDir) {
|
function normalizeUploadRoot(uploadDir) {
|
||||||
return path.resolve(String(uploadDir || '').trim());
|
return path.resolve(String(uploadDir || '').trim());
|
||||||
@@ -48,21 +49,20 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
|
||||||
`SELECT internal_base_url
|
const player = await resolvePlayerRegistration(pool, configuredPlayerIdentifier);
|
||||||
FROM d_players
|
const resolvedBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, '');
|
||||||
WHERE device_id = '1'
|
if (resolvedBaseUrl) {
|
||||||
LIMIT 1`
|
playerInternalBaseUrl = resolvedBaseUrl;
|
||||||
);
|
return resolvedBaseUrl;
|
||||||
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
|
}
|
||||||
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return configuredPlayerInternalBaseUrl || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return configuredPlayerInternalBaseUrl || null;
|
||||||
})().then(function (baseUrl) {
|
})().then(function (baseUrl) {
|
||||||
playerInternalBaseUrl = baseUrl || null;
|
|
||||||
playerInternalBaseUrlPromise = null;
|
playerInternalBaseUrlPromise = null;
|
||||||
return playerInternalBaseUrl;
|
return baseUrl || null;
|
||||||
}, function () {
|
}, function () {
|
||||||
playerInternalBaseUrlPromise = null;
|
playerInternalBaseUrlPromise = null;
|
||||||
return configuredPlayerInternalBaseUrl || null;
|
return configuredPlayerInternalBaseUrl || null;
|
||||||
@@ -278,6 +278,11 @@ function createUploadSyncService(options) {
|
|||||||
return Boolean(localUploadDir);
|
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) {
|
function queuePlayerUploadSync(operation) {
|
||||||
if (!operation || !operation.uploadPath) {
|
if (!operation || !operation.uploadPath) {
|
||||||
return;
|
return;
|
||||||
@@ -350,7 +355,9 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,7 +394,9 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||||
|
const { getConfiguredPlayerIdentifier, resolvePlayerRegistration } = require('#src/data/player-registry');
|
||||||
|
|
||||||
function createPlayerActionService(options) {
|
function createPlayerActionService(options) {
|
||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
@@ -13,6 +14,10 @@ function createPlayerActionService(options) {
|
|||||||
let playerInternalBaseUrlPromise = null;
|
let playerInternalBaseUrlPromise = null;
|
||||||
|
|
||||||
async function getPlayerInternalBaseUrl() {
|
async function getPlayerInternalBaseUrl() {
|
||||||
|
if (configuredPlayerInternalBaseUrl) {
|
||||||
|
return configuredPlayerInternalBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
if (playerInternalBaseUrl) {
|
if (playerInternalBaseUrl) {
|
||||||
return playerInternalBaseUrl;
|
return playerInternalBaseUrl;
|
||||||
}
|
}
|
||||||
@@ -27,21 +32,20 @@ function createPlayerActionService(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
|
||||||
`SELECT internal_base_url
|
const player = await resolvePlayerRegistration(pool, configuredPlayerIdentifier);
|
||||||
FROM d_players
|
const resolvedBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, '');
|
||||||
WHERE device_id = '1'
|
if (resolvedBaseUrl) {
|
||||||
LIMIT 1`
|
playerInternalBaseUrl = resolvedBaseUrl;
|
||||||
);
|
return resolvedBaseUrl;
|
||||||
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
|
}
|
||||||
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return configuredPlayerInternalBaseUrl || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return configuredPlayerInternalBaseUrl || null;
|
||||||
})().then(function (baseUrl) {
|
})().then(function (baseUrl) {
|
||||||
playerInternalBaseUrl = baseUrl || null;
|
|
||||||
playerInternalBaseUrlPromise = null;
|
playerInternalBaseUrlPromise = null;
|
||||||
return playerInternalBaseUrl;
|
return baseUrl || null;
|
||||||
}, function () {
|
}, function () {
|
||||||
playerInternalBaseUrlPromise = null;
|
playerInternalBaseUrlPromise = null;
|
||||||
return configuredPlayerInternalBaseUrl || null;
|
return configuredPlayerInternalBaseUrl || null;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ async function initializeWebServer(options) {
|
|||||||
uploadSyncService: webBootstrap.uploadSyncService,
|
uploadSyncService: webBootstrap.uploadSyncService,
|
||||||
captureSlideThumbnail: captureSlideThumbnail,
|
captureSlideThumbnail: captureSlideThumbnail,
|
||||||
mediaDir: mediaDir,
|
mediaDir: mediaDir,
|
||||||
|
webBaseUrl: options && options.webBaseUrl ? options.webBaseUrl : null,
|
||||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ module.exports = function registerMiddleware(app, deps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use(function (req, res, next) {
|
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();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,24 +267,20 @@
|
|||||||
|
|
||||||
function renderScreenTile(screen) {
|
function renderScreenTile(screen) {
|
||||||
var clientCount = Number(screen.player_connection_count || 0);
|
var clientCount = Number(screen.player_connection_count || 0);
|
||||||
var playerUrl = String(screen.player_url || '').trim();
|
|
||||||
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
|
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
|
||||||
var connectionStateClass = clientCount ? 'is-live' : 'is-idle';
|
var connectionStateClass = clientCount ? 'is-live' : 'is-idle';
|
||||||
var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned';
|
var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned';
|
||||||
var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected';
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
|
'<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
|
||||||
'<div class="dashboard-screen-tile-top">',
|
'<div class="dashboard-screen-tile-top">',
|
||||||
'<div class="dashboard-screen-tile-text">',
|
'<div class="dashboard-screen-tile-text">',
|
||||||
'<h4 class="dashboard-screen-name">' + escapeHtml(screen.name || '') + '</h4>',
|
'<h4 class="dashboard-screen-name">' + escapeHtml(screen.name || '') + '</h4>',
|
||||||
'<a class="dashboard-screen-link" href="' + escapeHtml(playerUrl) + '" target="_blank">' + escapeHtml(playerUrl) + '</a>',
|
|
||||||
'</div>',
|
'</div>',
|
||||||
'<span class="dashboard-screen-pill ' + connectionStateClass + '">' + escapeHtml(connectionLabel) + '</span>',
|
'<span class="dashboard-screen-pill ' + connectionStateClass + '">' + escapeHtml(connectionLabel) + '</span>',
|
||||||
'</div>',
|
'</div>',
|
||||||
'<dl class="dashboard-screen-meta">',
|
'<dl class="dashboard-screen-meta">',
|
||||||
'<div><dt>Playlist</dt><dd>' + playlistLabel + '</dd></div>',
|
'<div><dt>Playlist</dt><dd>' + playlistLabel + '</dd></div>',
|
||||||
'<div><dt>Connections</dt><dd>' + escapeHtml(connectionsLabel) + '</dd></div>',
|
|
||||||
'</dl>',
|
'</dl>',
|
||||||
'</article>'
|
'</article>'
|
||||||
].join('');
|
].join('');
|
||||||
@@ -310,7 +306,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateStats(state) {
|
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 screenCount = document.getElementById('dashboard-screen-count');
|
||||||
var slideCount = document.getElementById('dashboard-slide-count');
|
var slideCount = document.getElementById('dashboard-slide-count');
|
||||||
var playlistCount = document.getElementById('dashboard-playlist-count');
|
var playlistCount = document.getElementById('dashboard-playlist-count');
|
||||||
@@ -324,8 +320,8 @@
|
|||||||
if (screenCount && Array.isArray(state.screens)) {
|
if (screenCount && Array.isArray(state.screens)) {
|
||||||
screenCount.textContent = String(state.screens.length);
|
screenCount.textContent = String(state.screens.length);
|
||||||
}
|
}
|
||||||
if (clientCount) {
|
if (playerCount) {
|
||||||
clientCount.textContent = String(Number(state.connectedClientsCount || 0));
|
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 = ['<option value="">Select a player</option>'];
|
||||||
|
|
||||||
|
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('<option value="' + escapeHtml(playerUrl) + '" data-player-identifier="' + escapeHtml(playerIdentifier) + '">' + escapeHtml(playerIdentifier + ' - ' + playerUrl) + '</option>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
function updateScreenGrid(state) {
|
||||||
var grid = document.getElementById('dashboard-screens-grid');
|
var grid = document.getElementById('dashboard-screens-grid');
|
||||||
if (!grid || !Array.isArray(state.screens)) {
|
if (!grid || !Array.isArray(state.screens)) {
|
||||||
@@ -603,6 +655,7 @@
|
|||||||
updateScreenGrid(state);
|
updateScreenGrid(state);
|
||||||
updateScreenCommandControls(state);
|
updateScreenCommandControls(state);
|
||||||
updateClientTable(state);
|
updateClientTable(state);
|
||||||
|
updateKioskLauncherModal(state);
|
||||||
updateDashboardQuickActions(state);
|
updateDashboardQuickActions(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,34 +833,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var checkbox = modal.querySelector('[data-kiosk-launcher-confirm]');
|
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 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() {
|
function resetModalState() {
|
||||||
if (checkbox) {
|
if (checkbox) {
|
||||||
checkbox.checked = false;
|
checkbox.checked = false;
|
||||||
}
|
}
|
||||||
setDownloadsEnabled(false);
|
if (select) {
|
||||||
|
select.value = '';
|
||||||
|
}
|
||||||
|
updateKioskLauncherModal(latestDashboardState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkbox) {
|
if (checkbox) {
|
||||||
checkbox.addEventListener('change', function () {
|
checkbox.addEventListener('change', function () {
|
||||||
setDownloadsEnabled(Boolean(checkbox.checked));
|
updateKioskLauncherModal(latestDashboardState || readScreenCommandStateFromDom());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (select) {
|
||||||
|
select.addEventListener('change', function () {
|
||||||
|
updateKioskLauncherModal(latestDashboardState || readScreenCommandStateFromDom());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
(function () {
|
(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) {
|
function updateSidebarStatus(status, label) {
|
||||||
var dot = document.getElementById('sidebar-status-dot');
|
var dot = document.getElementById('sidebar-status-dot');
|
||||||
var text = document.getElementById('sidebar-status-text');
|
var text = document.getElementById('sidebar-status-text');
|
||||||
@@ -72,7 +81,7 @@
|
|||||||
if (!screenCount) {
|
if (!screenCount) {
|
||||||
updateSidebarStatus('unknown', 'No screens configured');
|
updateSidebarStatus('unknown', 'No screens configured');
|
||||||
} else {
|
} 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) {
|
} catch (_error) {
|
||||||
|
|||||||
@@ -208,11 +208,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
|
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(
|
const targetBaseUrl = String(
|
||||||
targetPlayerRecord && targetPlayerRecord.public_base_url ||
|
|
||||||
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
|
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
|
||||||
''
|
''
|
||||||
).trim().replace(/\/$/, '');
|
).trim().replace(/\/$/, '');
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||||
const actorId = getAuditUserId(req);
|
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, {
|
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
|
||||||
closeUrl: '/screens',
|
closeUrl: '/screens',
|
||||||
newUrl: '/screens/new',
|
newUrl: '/screens/new',
|
||||||
@@ -144,19 +144,14 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
const previousPlaylistId = screen.playlist_id;
|
const previousPlaylistId = screen.playlist_id;
|
||||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
||||||
const previousSlug = String(screen.slug || '').trim();
|
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) {
|
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||||
}
|
}
|
||||||
if (previousSlug && previousSlug !== slug) {
|
if (previousSlug && previousSlug !== slug) {
|
||||||
const playerRecord = typeof common.fetchScreenPlayerRecord === 'function'
|
const playerBaseUrl = typeof common.fetchPlayerPublicBaseUrl === 'function'
|
||||||
? await common.fetchScreenPlayerRecord(pool, previousSlug)
|
? await common.fetchPlayerPublicBaseUrl(pool)
|
||||||
: null;
|
: '';
|
||||||
const playerBaseUrl = playerRecord && playerRecord.public_base_url
|
|
||||||
? String(playerRecord.public_base_url).replace(/\/$/, '')
|
|
||||||
: typeof common.fetchPlayerPublicBaseUrl === 'function'
|
|
||||||
? await common.fetchPlayerPublicBaseUrl(pool)
|
|
||||||
: '';
|
|
||||||
await forwardPlayerCommand(previousSlug, {
|
await forwardPlayerCommand(previousSlug, {
|
||||||
command: 'redirect',
|
command: 'redirect',
|
||||||
url: playerBaseUrl ? `${playerBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
url: playerBaseUrl ? `${playerBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -15,6 +15,8 @@ const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes');
|
|||||||
const registerTimetableRoutes = require('./data-sources/timetables/routes');
|
const registerTimetableRoutes = require('./data-sources/timetables/routes');
|
||||||
const registerSettingsRoutes = require('./settings/background-tasks');
|
const registerSettingsRoutes = require('./settings/background-tasks');
|
||||||
const registerFontRoutes = require('./settings/fonts');
|
const registerFontRoutes = require('./settings/fonts');
|
||||||
|
const registerInternalSyncRoutes = require('./internal/sync');
|
||||||
|
const registerScreensRoutes = require('./signage/screens/routes');
|
||||||
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
||||||
|
|
||||||
function registerRoutes(app, deps) {
|
function registerRoutes(app, deps) {
|
||||||
@@ -22,6 +24,21 @@ function registerRoutes(app, deps) {
|
|||||||
registerAuthAndAccountRoutes(app, deps);
|
registerAuthAndAccountRoutes(app, deps);
|
||||||
registerSignageRoutes(app, deps);
|
registerSignageRoutes(app, deps);
|
||||||
registerSettingsAndContentRoutes(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) {
|
function registerAuthAndAccountRoutes(app, deps) {
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ module.exports = function renderDashboardPage(data, message, currentUser) {
|
|||||||
playlists: data.playlists || [],
|
playlists: data.playlists || [],
|
||||||
screens: data.screens || [],
|
screens: data.screens || [],
|
||||||
clients: data.clients || [],
|
clients: data.clients || [],
|
||||||
|
kioskPlayers: data.kioskPlayers || [],
|
||||||
slides: data.slides || [],
|
slides: data.slides || [],
|
||||||
connectedClientsCount: Number(data.connectedClientsCount || 0),
|
connectedClientsCount: Number(data.connectedClientsCount || 0),
|
||||||
|
connectedPlayersCount: Number(data.connectedPlayersCount || data.connectedClientsCount || 0),
|
||||||
primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null,
|
primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null,
|
||||||
scripts: ['js/dashboard/dashboard-page.js']
|
scripts: ['js/dashboard/dashboard-page.js']
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
|
|||||||
const viewScreen = Object.assign({
|
const viewScreen = Object.assign({
|
||||||
name: '',
|
name: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
playlist_id: null
|
playlist_id: null,
|
||||||
|
player_urls: []
|
||||||
}, screen || {});
|
}, screen || {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Screen route registration and dashboard wiring.
|
// Screen route registration and dashboard wiring.
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const { screenPlayerUrl } = require('../../../routes/common');
|
||||||
|
|
||||||
module.exports = function registerScreensRoutes(app, deps) {
|
module.exports = function registerScreensRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
@@ -19,6 +20,17 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
|
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) {
|
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||||
return function (req, res, next) {
|
return function (req, res, next) {
|
||||||
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
||||||
@@ -58,7 +70,6 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
|
|
||||||
return Object.assign({}, screen, {
|
return Object.assign({}, screen, {
|
||||||
player_connection_count: playerConnectionCount,
|
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);
|
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) {
|
function buildLauncherContent(templatePath, playerUrl) {
|
||||||
const template = fs.readFileSync(templatePath, 'utf8');
|
const template = fs.readFileSync(templatePath, 'utf8');
|
||||||
const normalizedPlayerUrl = String(playerUrl || '').trim();
|
const normalizedPlayerUrl = String(playerUrl || '').trim();
|
||||||
@@ -100,8 +126,8 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
if (!screen) {
|
if (!screen) {
|
||||||
return res.status(404).send('Screen not found');
|
return res.status(404).send('Screen not found');
|
||||||
}
|
}
|
||||||
screen.player_url = await buildPlayerUrl();
|
screen.player_urls = await buildScreenPlayerUrls(screen);
|
||||||
if (screen.player_url) {
|
if (screen.player_urls.length) {
|
||||||
screen.launcher_downloads = launcherDownloadPaths;
|
screen.launcher_downloads = launcherDownloadPaths;
|
||||||
}
|
}
|
||||||
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
|
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 sort = common.getSortQuery(req);
|
||||||
const direction = common.getSortDirectionQuery(req);
|
const direction = common.getSortDirectionQuery(req);
|
||||||
const dashboardState = await buildDashboardState(pool);
|
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);
|
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
res.send(pages.renderScreensPage({
|
res.send(pages.renderScreensPage({
|
||||||
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []).map(function (screen) {
|
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []),
|
||||||
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
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages')
|
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));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -138,11 +155,8 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
if (!screen) {
|
if (!screen) {
|
||||||
return res.status(404).send('Screen not found');
|
return res.status(404).send('Screen not found');
|
||||||
}
|
}
|
||||||
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
screen.player_urls = await buildScreenPlayerUrls(screen);
|
||||||
? await common.fetchScreenPlayerUrls(pool)
|
if (screen.player_urls.length) {
|
||||||
: {};
|
|
||||||
screen.player_url = String(playerUrlsBySlug[screen.slug] || '').trim() || null;
|
|
||||||
if (screen.player_url) {
|
|
||||||
screen.launcher_downloads = launcherDownloadPaths;
|
screen.launcher_downloads = launcherDownloadPaths;
|
||||||
}
|
}
|
||||||
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
|
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) {
|
app.get('/downloads/kiosk/pulse-signage-kiosk.bat', requirePermission('screens.update'), async function (_req, res, next) {
|
||||||
try {
|
try {
|
||||||
const playerUrl = await buildPlayerUrl();
|
const playerUrl = normalizeTargetPlayerUrl(_req.query && _req.query.playerUrl)
|
||||||
|
|| await buildPlayerUrl();
|
||||||
if (!playerUrl) {
|
if (!playerUrl) {
|
||||||
return res.status(404).send('Player URL is not available yet.');
|
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) {
|
app.get('/downloads/kiosk/pulse-signage-kiosk.sh', requirePermission('screens.update'), async function (_req, res, next) {
|
||||||
try {
|
try {
|
||||||
const playerUrl = await buildPlayerUrl();
|
const playerUrl = normalizeTargetPlayerUrl(_req.query && _req.query.playerUrl)
|
||||||
|
|| await buildPlayerUrl();
|
||||||
if (!playerUrl) {
|
if (!playerUrl) {
|
||||||
return res.status(404).send('Player URL is not available yet.');
|
return res.status(404).send('Player URL is not available yet.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
// Slide route registration and pagination wiring.
|
// Slide route registration and pagination wiring.
|
||||||
|
|
||||||
const { renderFragment } = require('../../../view');
|
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(/</g, '\\u003c');
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = function registerSlidesRoutes(app, deps) {
|
module.exports = function registerSlidesRoutes(app, deps) {
|
||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
|
const mediaDir = String(deps.mediaDir || path.join(__dirname, '..', '..', '..', '..', 'media')).trim();
|
||||||
|
const webBaseUrl = String(deps.webBaseUrl || '').trim().replace(/\/$/, '');
|
||||||
const { buildPagination } = require('../../../lib/pagination');
|
const { buildPagination } = require('../../../lib/pagination');
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
|
function requireRequestAuth(req, res, next) {
|
||||||
|
if (!verifyRequestAuth(req)) {
|
||||||
|
return res.status(401).json({ error: 'Request authentication required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
const LIST_PAGE_SIZE = 25;
|
const LIST_PAGE_SIZE = 25;
|
||||||
|
|
||||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||||
@@ -35,5 +53,30 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
|||||||
frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body'
|
frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body'
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/internal/slide-thumbnails/:id/popup-preview', 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 payload = buildThumbnailPreviewPayload(slide, {
|
||||||
|
baseUrl: webBaseUrl || (String(req.headers.host || '').trim() ? `${req.protocol}://${String(req.headers.host).trim()}` : ''),
|
||||||
|
fontStylesheetHref: getFontStylesheetHref(mediaDir)
|
||||||
|
});
|
||||||
|
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.send(renderFragment('slides/popup-preview', {
|
||||||
|
title: 'Slide preview',
|
||||||
|
framePopupCard: true,
|
||||||
|
hideFrameHeader: true,
|
||||||
|
frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body',
|
||||||
|
popupPreviewPayloadJson: safeJsonForScript(payload)
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,8 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
{{#if (hasPermission currentUser "clients.read")}}
|
{{#if (hasPermission currentUser "clients.read")}}
|
||||||
<div class="dashboard-hero-stat">
|
<div class="dashboard-hero-stat">
|
||||||
<span class="dashboard-hero-stat-value">{{connectedClientsCount}}</span>
|
<span id="dashboard-player-count" class="dashboard-hero-stat-value">{{connectedPlayersCount}}</span>
|
||||||
<span class="dashboard-hero-stat-label">clients</span>
|
<span class="dashboard-hero-stat-label">players</span>
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
@@ -157,12 +157,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="mb-0 mt-2 text-muted small">Check this box to enable the Windows and Linux downloads.</p>
|
<p class="mb-0 mt-2 text-muted small">Check this box to enable the Windows and Linux downloads.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<select class="form-select" id="dashboard-kiosk-launcher-player-select" data-kiosk-launcher-player-select>
|
||||||
|
<option value="">Select a player</option>
|
||||||
|
{{#each kioskPlayers}}
|
||||||
|
{{#if player_identifier}}
|
||||||
|
{{#if player_url}}
|
||||||
|
<option value="{{player_url}}" data-player-identifier="{{player_identifier}}">{{player_identifier}} - {{player_url}}</option>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
<p class="mb-0 text-muted small">The download will open the selected player's public URL when the kiosk starts.</p>
|
||||||
|
</div>
|
||||||
<div class="d-flex flex-column flex-sm-row gap-2">
|
<div class="d-flex flex-column flex-sm-row gap-2">
|
||||||
<a class="btn btn-outline-primary btn-lg flex-fill disabled dashboard-kiosk-launcher-download" href="/downloads/kiosk/pulse-signage-kiosk.bat" data-kiosk-launcher-download aria-disabled="true" tabindex="-1">
|
<a class="btn btn-outline-primary btn-lg flex-fill disabled dashboard-kiosk-launcher-download" data-kiosk-launcher-download data-kiosk-launcher-download-base="/downloads/kiosk/pulse-signage-kiosk.bat" aria-disabled="true" tabindex="-1">
|
||||||
<i class="bi bi-windows me-2" aria-hidden="true"></i>
|
<i class="bi bi-windows me-2" aria-hidden="true"></i>
|
||||||
<span>Download for Windows</span>
|
<span>Download for Windows</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="btn btn-outline-secondary btn-lg flex-fill disabled dashboard-kiosk-launcher-download" href="/downloads/kiosk/pulse-signage-kiosk.sh" data-kiosk-launcher-download aria-disabled="true" tabindex="-1">
|
<a class="btn btn-outline-secondary btn-lg flex-fill disabled dashboard-kiosk-launcher-download" data-kiosk-launcher-download data-kiosk-launcher-download-base="/downloads/kiosk/pulse-signage-kiosk.sh" aria-disabled="true" tabindex="-1">
|
||||||
<i class="bi bi-terminal me-2" aria-hidden="true"></i>
|
<i class="bi bi-terminal me-2" aria-hidden="true"></i>
|
||||||
<span>Download for Linux</span>
|
<span>Download for Linux</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -178,8 +191,7 @@
|
|||||||
<div class="card-header dashboard-screen-card-header">
|
<div class="card-header dashboard-screen-card-header">
|
||||||
<div class="dashboard-card-heading">
|
<div class="dashboard-card-heading">
|
||||||
<h3 class="card-title">Screen group snapshot</h3>
|
<h3 class="card-title">Screen group snapshot</h3>
|
||||||
<p class="dashboard-card-subtitle">Player URL, playlist assignment,
|
<p class="dashboard-card-subtitle">Playlist assignment and live connection state without the spreadsheet feel.</p>
|
||||||
and live connection count without the spreadsheet feel.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-sm btn-primary" href="/screens">Manage screen groups</a>
|
<a class="btn btn-sm btn-primary" href="/screens">Manage screen groups</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,11 +203,6 @@
|
|||||||
<div class="dashboard-screen-tile-top">
|
<div class="dashboard-screen-tile-top">
|
||||||
<div class="dashboard-screen-tile-text">
|
<div class="dashboard-screen-tile-text">
|
||||||
<h4 class="dashboard-screen-name">{{name}}</h4>
|
<h4 class="dashboard-screen-name">{{name}}</h4>
|
||||||
{{#if player_url}}
|
|
||||||
<a class="dashboard-screen-link" href="{{player_url}}" target="_blank">{{player_url}}</a>
|
|
||||||
{{else}}
|
|
||||||
<span class="dashboard-screen-link empty">Not available</span>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
</div>
|
||||||
<span class="dashboard-screen-pill {{#if player_connection_count}}is-live{{else}}is-idle{{/if}}">
|
<span class="dashboard-screen-pill {{#if player_connection_count}}is-live{{else}}is-idle{{/if}}">
|
||||||
{{#if player_connection_count}}
|
{{#if player_connection_count}}
|
||||||
@@ -210,10 +217,6 @@
|
|||||||
<dt>Playlist</dt>
|
<dt>Playlist</dt>
|
||||||
<dd>{{#if playlist_name}}{{playlist_name}}{{else}}Unassigned{{/if}}</dd>
|
<dd>{{#if playlist_name}}{{playlist_name}}{{else}}Unassigned{{/if}}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<dt>Connections</dt>
|
|
||||||
<dd>{{#if player_connection_count}}{{player_connection_count}} connected{{else}}No clients connected{{/if}}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-12 col-xl-8">
|
<div class="col-12 col-xl-6">
|
||||||
<div class="card card-outline card-primary admin-form-card h-100">
|
<div class="card card-outline card-primary admin-form-card h-100">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Screen group details</h3>
|
<h3 class="card-title">Screen group details</h3>
|
||||||
@@ -40,16 +40,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-xl-4">
|
<div class="col-12 col-xl-6">
|
||||||
<div class="card card-outline card-secondary admin-form-card h-100">
|
<div class="card card-outline card-secondary admin-form-card h-100">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Player URL</h3>
|
<h3 class="card-title">Player URLs</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{#if screen.player_url}}
|
{{#if screen.player_urls.length}}
|
||||||
<p class="mb-0"><a href="{{screen.player_url}}" target="_blank">{{screen.player_url}}</a></p>
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Player</th>
|
||||||
|
<th>URL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{#each screen.player_urls}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{identifier}}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{#if player_url}}
|
||||||
|
<a href="{{player_url}}" target="_blank" rel="noreferrer">{{player_url}}</a>
|
||||||
|
{{else}}
|
||||||
|
<span class="text-muted">Not available</span>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="mb-0 text-muted">No player URL is available yet.</p>
|
<p class="mb-0 text-muted">No player URLs are available yet.</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-table-sort-key="name">Name</th>
|
<th data-table-sort-key="name">Name</th>
|
||||||
<th data-table-sort-key="url">Player URL</th>
|
|
||||||
<th data-table-sort-key="playlist">Playlist</th>
|
<th data-table-sort-key="playlist">Playlist</th>
|
||||||
<th data-table-sort-key="player_connection_count">Connected</th>
|
<th data-table-sort-key="player_connection_count">Connected</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@@ -34,13 +33,6 @@
|
|||||||
{{#each screens}}
|
{{#each screens}}
|
||||||
<tr data-table-search-row>
|
<tr data-table-search-row>
|
||||||
<td data-label="Name">{{name}}</td>
|
<td data-label="Name">{{name}}</td>
|
||||||
<td data-label="Player URL">
|
|
||||||
{{#if player_url}}
|
|
||||||
<a href="{{player_url}}" target="_blank">{{player_url}}</a>
|
|
||||||
{{else}}
|
|
||||||
<span class="empty">Not available</span>
|
|
||||||
{{/if}}
|
|
||||||
</td>
|
|
||||||
<td data-label="Playlist">{{playlist_name}}</td>
|
<td data-label="Playlist">{{playlist_name}}</td>
|
||||||
<td data-label="Connected clients">
|
<td data-label="Connected clients">
|
||||||
{{#if (gt player_connection_count 0)}}
|
{{#if (gt player_connection_count 0)}}
|
||||||
@@ -72,7 +64,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No screen groups yet.</td></tr>
|
<tr data-table-search-empty-default><td colspan="4" class="empty">No screen groups yet.</td></tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -58,6 +58,12 @@
|
|||||||
<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>
|
<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if popupPreviewPayloadJson}}
|
||||||
|
<script>
|
||||||
|
window.__pulsePopupPreviewPayload = {{{popupPreviewPayloadJson}}};
|
||||||
|
</script>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
function startPreviewVideoPlayback(root) {
|
function startPreviewVideoPlayback(root) {
|
||||||
@@ -127,6 +133,10 @@
|
|||||||
var canvas = document.getElementById('popup-preview-canvas');
|
var canvas = document.getElementById('popup-preview-canvas');
|
||||||
|
|
||||||
function readPayload() {
|
function readPayload() {
|
||||||
|
if (window.__pulsePopupPreviewPayload) {
|
||||||
|
return window.__pulsePopupPreviewPayload;
|
||||||
|
}
|
||||||
|
|
||||||
var hash = String(window.location.hash || '').replace(/^#/, '');
|
var hash = String(window.location.hash || '').replace(/^#/, '');
|
||||||
if (!hash) {
|
if (!hash) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
|
const { createDashboardStateService } = require('../src/web/lib/dashboard-state');
|
||||||
|
|
||||||
|
test('dashboard state counts active players from the registry heartbeat', async () => {
|
||||||
|
const service = createDashboardStateService({
|
||||||
|
pool: {
|
||||||
|
async query(sql) {
|
||||||
|
if (String(sql || '').includes('COUNT(*) AS connected_count')) {
|
||||||
|
return [[{ connected_count: 6 }]];
|
||||||
|
}
|
||||||
|
if (String(sql || '').includes('SELECT s.slug, pod.device_id, pod.client_name')) {
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
async fetchAdminData() {
|
||||||
|
return {
|
||||||
|
screens: [
|
||||||
|
{
|
||||||
|
slug: 'alpha',
|
||||||
|
name: 'Alpha',
|
||||||
|
playlist_name: 'Main Loop'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
playlists: [],
|
||||||
|
slides: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async fetchScreenPlayerUrls() {
|
||||||
|
return {
|
||||||
|
alpha: 'http://player.local/screen/alpha'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async fetchPlayerRegistrations() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
identifier: 'player-alpha',
|
||||||
|
public_base_url: 'http://player.local',
|
||||||
|
last_seen_at: new Date(Date.now() - 10 * 1000)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: 'player-stale',
|
||||||
|
public_base_url: 'http://stale.player.local',
|
||||||
|
last_seen_at: new Date(Date.now() - 10 * 60 * 1000)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
playerSnapshotCache: new Map([
|
||||||
|
['alpha', { count: 1, connections: [{ id: 1, playerPublicBaseUrl: 'http://player.local' }] }]
|
||||||
|
]),
|
||||||
|
playerSnapshotSockets: new Map(),
|
||||||
|
ensurePlayerSnapshotSubscription() {},
|
||||||
|
formatDashboardDate(value) {
|
||||||
|
return String(value || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const state = await service.buildDashboardState();
|
||||||
|
|
||||||
|
assert.equal(state.connectedPlayersCount, 6);
|
||||||
|
assert.equal(state.connectedClientsCount, 1);
|
||||||
|
assert.equal(state.clients[0].player_identifier, 'player-alpha');
|
||||||
|
assert.equal(state.kioskPlayers.length, 1);
|
||||||
|
assert.deepEqual(state.kioskPlayers[0], {
|
||||||
|
player_identifier: 'player-alpha',
|
||||||
|
player_url: 'http://player.local'
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
|
const { createPageAuthToken } = require('../src/request-auth');
|
||||||
|
const { registerPlayerOnboardingRoutes } = require('../src/player/onboarding');
|
||||||
|
|
||||||
|
const originalSharedSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
if (originalSharedSecret === undefined) {
|
||||||
|
delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
|
||||||
|
} else {
|
||||||
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSharedSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
function createAppAndHandlers() {
|
||||||
|
const handlers = {};
|
||||||
|
const app = {
|
||||||
|
use() {},
|
||||||
|
get(path, ...routeHandlers) {
|
||||||
|
handlers[path] = function (req, res) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const runHandler = (index) => {
|
||||||
|
const handler = routeHandlers[index];
|
||||||
|
if (!handler) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextCalled = false;
|
||||||
|
const next = function () {
|
||||||
|
nextCalled = true;
|
||||||
|
return runHandler(index + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = handler(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.resolve(result).then(function (value) {
|
||||||
|
if (!nextCalled) {
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
}, reject);
|
||||||
|
};
|
||||||
|
|
||||||
|
runHandler(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
post() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { app, handlers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResponse() {
|
||||||
|
const headers = {};
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
set(name, value) {
|
||||||
|
headers[name] = value;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
type(value) {
|
||||||
|
headers['Content-Type'] = value;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(value) {
|
||||||
|
this.body = value;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
send(value) {
|
||||||
|
this.body = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerThinClientRoutes(fetchImpl) {
|
||||||
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'bridge-secret';
|
||||||
|
global.fetch = fetchImpl;
|
||||||
|
|
||||||
|
const { app, handlers } = createAppAndHandlers();
|
||||||
|
registerPlayerOnboardingRoutes(app, {
|
||||||
|
app,
|
||||||
|
common: {
|
||||||
|
renderPlayerOnboardingLandingPage() {
|
||||||
|
return 'landing';
|
||||||
|
},
|
||||||
|
renderPlayerOnboardingFormPage() {
|
||||||
|
return 'form';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
thinClientBaseUrl: 'http://bridge.test',
|
||||||
|
playerPublicBaseUrl: 'http://public.test'
|
||||||
|
});
|
||||||
|
|
||||||
|
return handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('remote onboarding status proxies to the bridge and rewrites playerUrl', async () => {
|
||||||
|
const fetchCalls = [];
|
||||||
|
const handlers = registerThinClientRoutes(async (url, init) => {
|
||||||
|
fetchCalls.push({ url, init });
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
get(name) {
|
||||||
|
return String(name || '').toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async json() {
|
||||||
|
return {
|
||||||
|
deviceId: 'device123',
|
||||||
|
onboarded: true,
|
||||||
|
clientName: 'Lobby Player',
|
||||||
|
screenId: 7,
|
||||||
|
screenSlug: 'main-screen',
|
||||||
|
screenName: 'Main Screen',
|
||||||
|
playerUrl: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = createPageAuthToken({ scope: 'onboarding', deviceId: 'device123' });
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await handlers['/api/onboarding/status']({
|
||||||
|
headers: {
|
||||||
|
'x-pulse-page-auth': token
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
deviceId: 'device 123!?'
|
||||||
|
},
|
||||||
|
socket: {}
|
||||||
|
}, res);
|
||||||
|
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
assert.equal(fetchCalls[0].url, 'http://bridge.test/api/onboarding/status?deviceId=device123');
|
||||||
|
assert.equal(fetchCalls[0].init.method, 'GET');
|
||||||
|
assert.equal(fetchCalls[0].init.headers['x-pulse-page-auth'], token);
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.equal(res.body.playerUrl, 'http://public.test/screen/main-screen');
|
||||||
|
assert.equal(res.body.screenSlug, 'main-screen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote onboarding status returns 502 when the bridge is unavailable', async () => {
|
||||||
|
const handlers = registerThinClientRoutes(async () => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = createPageAuthToken({ scope: 'player', deviceId: 'device123' });
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await handlers['/api/onboarding/status']({
|
||||||
|
headers: {
|
||||||
|
'x-pulse-page-auth': token
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
deviceId: 'device123'
|
||||||
|
},
|
||||||
|
socket: {}
|
||||||
|
}, res);
|
||||||
|
|
||||||
|
assert.equal(res.statusCode, 502);
|
||||||
|
assert.deepEqual(res.body, { error: 'Player bridge unavailable.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote onboarding screens proxy preserves the bridge response body', async () => {
|
||||||
|
const fetchCalls = [];
|
||||||
|
const handlers = registerThinClientRoutes(async (url, init) => {
|
||||||
|
fetchCalls.push({ url, init });
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
get(name) {
|
||||||
|
return String(name || '').toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async text() {
|
||||||
|
return JSON.stringify({ screens: [{ id: 1, name: 'Lobby', slug: 'lobby' }] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = createPageAuthToken({ scope: 'onboarding', deviceId: 'device123' });
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await handlers['/api/onboarding/screens']({
|
||||||
|
headers: {
|
||||||
|
'x-pulse-page-auth': token
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
socket: {}
|
||||||
|
}, res);
|
||||||
|
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
assert.equal(fetchCalls[0].url, 'http://bridge.test/api/onboarding/screens');
|
||||||
|
assert.equal(fetchCalls[0].init.method, 'GET');
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8');
|
||||||
|
assert.equal(res.body, JSON.stringify({ screens: [{ id: 1, name: 'Lobby', slug: 'lobby' }] }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { normalizeIdentifier, recordPlayerHeartbeat, upsertPlayerRegistration } = require('../src/data/player-registry');
|
||||||
|
|
||||||
|
test('normalizeIdentifier trims and limits friendly player labels', () => {
|
||||||
|
assert.equal(normalizeIdentifier(' Shop2 '), 'Shop2');
|
||||||
|
assert.equal(normalizeIdentifier(''), '');
|
||||||
|
assert.equal(normalizeIdentifier('x'.repeat(300)).length, 255);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('player registry upserts include the identifier field', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params) {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
if (String(sql || '').includes('information_schema.COLUMNS')) {
|
||||||
|
return [[{ column_count: 1 }]];
|
||||||
|
}
|
||||||
|
if (String(sql || '').includes('INSERT INTO d_players')) {
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
}
|
||||||
|
if (String(sql || '').includes('SELECT id, identifier, public_base_url, internal_base_url, last_seen_at')) {
|
||||||
|
return [[{
|
||||||
|
id: 1,
|
||||||
|
identifier: 'Shop2',
|
||||||
|
public_base_url: 'http://player.local',
|
||||||
|
internal_base_url: 'http://player:8081',
|
||||||
|
last_seen_at: '2026-08-07 00:00:00'
|
||||||
|
}]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const registration = await upsertPlayerRegistration(pool, {
|
||||||
|
deviceId: 'device-01',
|
||||||
|
identifier: 'Shop2',
|
||||||
|
publicBaseUrl: 'http://player.local/',
|
||||||
|
internalBaseUrl: 'http://player:8081/'
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartbeat = await recordPlayerHeartbeat(pool, {
|
||||||
|
deviceId: 'device-01',
|
||||||
|
identifier: 'Shop2',
|
||||||
|
publicBaseUrl: 'http://player.local/',
|
||||||
|
internalBaseUrl: 'http://player:8081/'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(registration, {
|
||||||
|
id: 1,
|
||||||
|
identifier: 'Shop2',
|
||||||
|
public_base_url: 'http://player.local',
|
||||||
|
internal_base_url: 'http://player:8081',
|
||||||
|
last_seen_at: '2026-08-07 00:00:00'
|
||||||
|
});
|
||||||
|
assert.deepEqual(heartbeat, registration);
|
||||||
|
assert.ok(calls.some(function (call) {
|
||||||
|
return /INSERT INTO d_players/.test(String(call.sql || '')) && /identifier/.test(String(call.sql || ''));
|
||||||
|
}));
|
||||||
|
assert.ok(calls.some(function (call) {
|
||||||
|
return /SELECT .*identifier/.test(String(call.sql || ''));
|
||||||
|
}));
|
||||||
|
assert.deepEqual((calls.find(function (call) {
|
||||||
|
return /INSERT INTO d_players/.test(String(call.sql || ''));
|
||||||
|
}) || {}).params, ['Shop2', 'http://player.local', 'http://player:8081']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
test('system status shows connected player count in the live feed label', () => {
|
||||||
|
const script = fs.readFileSync(require.resolve('../src/web/public/js/system-status.js'), 'utf8');
|
||||||
|
const elements = {
|
||||||
|
'sidebar-status-dot': {
|
||||||
|
classList: {
|
||||||
|
remove() {},
|
||||||
|
add() {}
|
||||||
|
},
|
||||||
|
setAttribute() {}
|
||||||
|
},
|
||||||
|
'sidebar-status-text': {
|
||||||
|
textContent: ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sockets = [];
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
document: {
|
||||||
|
getElementById(id) {
|
||||||
|
return elements[id] || null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
WebSocket: true,
|
||||||
|
location: {
|
||||||
|
protocol: 'http:',
|
||||||
|
host: 'example.test'
|
||||||
|
},
|
||||||
|
setTimeout() {
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
clearTimeout() {},
|
||||||
|
webHandleDashboardState() {}
|
||||||
|
},
|
||||||
|
WebSocket: function MockWebSocket(url) {
|
||||||
|
this.url = url;
|
||||||
|
sockets.push(this);
|
||||||
|
this.close = function () {};
|
||||||
|
},
|
||||||
|
JSON: JSON,
|
||||||
|
Number: Number,
|
||||||
|
String: String,
|
||||||
|
Boolean: Boolean,
|
||||||
|
Array: Array,
|
||||||
|
Object: Object,
|
||||||
|
Math: Math,
|
||||||
|
setTimeout() {},
|
||||||
|
clearTimeout() {},
|
||||||
|
console: console
|
||||||
|
};
|
||||||
|
context.window.document = context.document;
|
||||||
|
context.window.WebSocket = context.WebSocket;
|
||||||
|
|
||||||
|
vm.runInNewContext(script, context);
|
||||||
|
|
||||||
|
assert.equal(sockets.length, 1);
|
||||||
|
sockets[0].onmessage({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: 'dashboard-state',
|
||||||
|
state: {
|
||||||
|
screens: [{ slug: 'alpha' }],
|
||||||
|
playerServiceConnected: true,
|
||||||
|
connectedPlayersCount: 4
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(elements['sidebar-status-text'].textContent, 'Live player feed - 4 players connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('system status shows an explicit idle label when no players are connected', () => {
|
||||||
|
const script = fs.readFileSync(require.resolve('../src/web/public/js/system-status.js'), 'utf8');
|
||||||
|
const elements = {
|
||||||
|
'sidebar-status-dot': {
|
||||||
|
classList: {
|
||||||
|
remove() {},
|
||||||
|
add() {}
|
||||||
|
},
|
||||||
|
setAttribute() {}
|
||||||
|
},
|
||||||
|
'sidebar-status-text': {
|
||||||
|
textContent: ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sockets = [];
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
document: {
|
||||||
|
getElementById(id) {
|
||||||
|
return elements[id] || null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
WebSocket: true,
|
||||||
|
location: {
|
||||||
|
protocol: 'http:',
|
||||||
|
host: 'example.test'
|
||||||
|
},
|
||||||
|
setTimeout() {
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
clearTimeout() {},
|
||||||
|
webHandleDashboardState() {}
|
||||||
|
},
|
||||||
|
WebSocket: function MockWebSocket(url) {
|
||||||
|
this.url = url;
|
||||||
|
sockets.push(this);
|
||||||
|
this.close = function () {};
|
||||||
|
},
|
||||||
|
JSON: JSON,
|
||||||
|
Number: Number,
|
||||||
|
String: String,
|
||||||
|
Boolean: Boolean,
|
||||||
|
Array: Array,
|
||||||
|
Object: Object,
|
||||||
|
Math: Math,
|
||||||
|
setTimeout() {},
|
||||||
|
clearTimeout() {},
|
||||||
|
console: console
|
||||||
|
};
|
||||||
|
context.window.document = context.document;
|
||||||
|
context.window.WebSocket = context.WebSocket;
|
||||||
|
|
||||||
|
vm.runInNewContext(script, context);
|
||||||
|
|
||||||
|
assert.equal(sockets.length, 1);
|
||||||
|
sockets[0].onmessage({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: 'dashboard-state',
|
||||||
|
state: {
|
||||||
|
screens: [{ slug: 'alpha' }],
|
||||||
|
playerServiceConnected: true,
|
||||||
|
connectedPlayersCount: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(elements['sidebar-status-text'].textContent, 'Player feed online - no players connected');
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
require('../src/common');
|
require('../src/common');
|
||||||
|
|
||||||
@@ -16,12 +18,203 @@ test('dashboard onboarding link uses the player base url', () => {
|
|||||||
playlists: [],
|
playlists: [],
|
||||||
clients: [],
|
clients: [],
|
||||||
slides: [],
|
slides: [],
|
||||||
connectedClientsCount: 0
|
connectedClientsCount: 0,
|
||||||
|
connectedPlayersCount: 0
|
||||||
},
|
},
|
||||||
'',
|
'',
|
||||||
null
|
{ id: 1, permissions: ['screens.read', 'clients.read'] }
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.match(html, /href="http:\/\/player\.local"/);
|
assert.match(html, /href="http:\/\/player\.local"/);
|
||||||
assert.doesNotMatch(html, /http:\/\/player\.local\//);
|
assert.doesNotMatch(html, /http:\/\/player\.local\//);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dashboard screen snapshot omits player links and duplicate connection counts', () => {
|
||||||
|
const html = renderDashboardPage(
|
||||||
|
{
|
||||||
|
screens: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: 'Demo Conference',
|
||||||
|
slug: 'demo-conference',
|
||||||
|
playlist_name: 'Main Loop',
|
||||||
|
player_connection_count: 3,
|
||||||
|
public_base_url: 'http://player.local/'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
playlists: [],
|
||||||
|
clients: [],
|
||||||
|
slides: [],
|
||||||
|
connectedClientsCount: 3,
|
||||||
|
connectedPlayersCount: 3
|
||||||
|
},
|
||||||
|
'',
|
||||||
|
{ id: 1, permissions: ['screens.read', 'clients.read'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /Screen group snapshot/);
|
||||||
|
assert.match(html, /players/);
|
||||||
|
assert.doesNotMatch(html, /dashboard-screen-link/);
|
||||||
|
assert.doesNotMatch(html, /Connections<\/dt>/);
|
||||||
|
assert.match(html, /3\s+live/);
|
||||||
|
assert.doesNotMatch(html, />3 connected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard kiosk launcher includes connected player choices', () => {
|
||||||
|
const html = renderDashboardPage(
|
||||||
|
{
|
||||||
|
screens: [],
|
||||||
|
playlists: [],
|
||||||
|
kioskPlayers: [
|
||||||
|
{
|
||||||
|
player_identifier: 'player-alpha',
|
||||||
|
player_url: 'http://player-a.example'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
player_identifier: 'player-beta',
|
||||||
|
player_url: 'http://player-b.example'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
slides: [],
|
||||||
|
connectedClientsCount: 1,
|
||||||
|
connectedPlayersCount: 1
|
||||||
|
},
|
||||||
|
'',
|
||||||
|
{ id: 1, permissions: ['screens.read', 'clients.read', 'dashboard.allow'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(html, /dashboard-kiosk-launcher-player-select/);
|
||||||
|
assert.match(html, /<option value="http:\/\/player-a\.example" data-player-identifier="player-alpha">player-alpha - http:\/\/player-a\.example<\/option>/);
|
||||||
|
assert.match(html, /<option value="http:\/\/player-b\.example" data-player-identifier="player-beta">player-beta - http:\/\/player-b\.example<\/option>/);
|
||||||
|
assert.match(html, /data-kiosk-launcher-download-base="\/downloads\/kiosk\/pulse-signage-kiosk\.bat"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard kiosk launcher requires both confirmation and a player selection', () => {
|
||||||
|
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
|
||||||
|
const downloadLink = {
|
||||||
|
classList: {
|
||||||
|
classes: new Set(['disabled']),
|
||||||
|
add(name) { this.classes.add(name); },
|
||||||
|
remove(name) { this.classes.delete(name); },
|
||||||
|
contains(name) { return this.classes.has(name); }
|
||||||
|
},
|
||||||
|
attributes: {
|
||||||
|
href: '',
|
||||||
|
'data-kiosk-launcher-download-base': '/downloads/kiosk/pulse-signage-kiosk.bat',
|
||||||
|
'aria-disabled': 'true',
|
||||||
|
tabindex: '-1'
|
||||||
|
},
|
||||||
|
setAttribute(name, value) {
|
||||||
|
this.attributes[name] = String(value);
|
||||||
|
},
|
||||||
|
getAttribute(name) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : '';
|
||||||
|
},
|
||||||
|
removeAttribute(name) {
|
||||||
|
delete this.attributes[name];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const checkbox = {
|
||||||
|
checked: false,
|
||||||
|
listeners: {},
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
this.listeners[type] = handler;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const select = {
|
||||||
|
value: '',
|
||||||
|
options: [
|
||||||
|
{ value: '', textContent: 'Select a player' },
|
||||||
|
{ value: 'http://player-a.example', textContent: 'player-alpha' }
|
||||||
|
],
|
||||||
|
listeners: {},
|
||||||
|
innerHTML: '',
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
this.listeners[type] = handler;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const modal = {
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
return selector === '[data-kiosk-launcher-download]' ? [downloadLink] : [];
|
||||||
|
},
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === '[data-kiosk-launcher-confirm]') {
|
||||||
|
return checkbox;
|
||||||
|
}
|
||||||
|
if (selector === '[data-kiosk-launcher-player-select]') {
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
this.listeners = this.listeners || {};
|
||||||
|
this.listeners[type] = handler;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
document: {
|
||||||
|
getElementById(id) {
|
||||||
|
if (id === 'dashboard-kiosk-launcher-modal') {
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
querySelector() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
webUiHelpers: {
|
||||||
|
escapeHtml(value) { return String(value); },
|
||||||
|
formatDashboardDate(value) { return String(value); },
|
||||||
|
getClientRowKey() { return ''; },
|
||||||
|
getClientDisplayName() { return ''; },
|
||||||
|
setButtonVariant() {},
|
||||||
|
normalizeDisplayIp(value) { return String(value); }
|
||||||
|
},
|
||||||
|
WebSocket: null,
|
||||||
|
location: {
|
||||||
|
protocol: 'http:',
|
||||||
|
host: 'example.test'
|
||||||
|
},
|
||||||
|
setTimeout() { return 1; },
|
||||||
|
clearTimeout() {},
|
||||||
|
alert() {},
|
||||||
|
prompt() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
webHandleDashboardState() {}
|
||||||
|
},
|
||||||
|
WebSocket: function MockWebSocket() {},
|
||||||
|
JSON: JSON,
|
||||||
|
Number: Number,
|
||||||
|
String: String,
|
||||||
|
Boolean: Boolean,
|
||||||
|
Array: Array,
|
||||||
|
Object: Object,
|
||||||
|
Math: Math,
|
||||||
|
Set: Set,
|
||||||
|
URLSearchParams: URLSearchParams,
|
||||||
|
FormData: function FormData() {},
|
||||||
|
setTimeout() {},
|
||||||
|
clearTimeout() {},
|
||||||
|
console: console
|
||||||
|
};
|
||||||
|
context.window.document = context.document;
|
||||||
|
context.window.WebSocket = context.WebSocket;
|
||||||
|
|
||||||
|
vm.runInNewContext(script, context);
|
||||||
|
|
||||||
|
assert.equal(downloadLink.classList.contains('disabled'), true);
|
||||||
|
assert.equal(Object.prototype.hasOwnProperty.call(downloadLink.attributes, 'href'), false);
|
||||||
|
|
||||||
|
checkbox.checked = true;
|
||||||
|
checkbox.listeners.change();
|
||||||
|
assert.equal(downloadLink.classList.contains('disabled'), true);
|
||||||
|
assert.equal(Object.prototype.hasOwnProperty.call(downloadLink.attributes, 'href'), false);
|
||||||
|
|
||||||
|
select.value = 'http://player-a.example';
|
||||||
|
select.listeners.change();
|
||||||
|
assert.equal(downloadLink.classList.contains('disabled'), false);
|
||||||
|
assert.equal(downloadLink.attributes.href, '/downloads/kiosk/pulse-signage-kiosk.bat?playerUrl=http%3A%2F%2Fplayer-a.example');
|
||||||
|
});
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
|||||||
uniqueScreenSlug: async () => 'beta',
|
uniqueScreenSlug: async () => 'beta',
|
||||||
fetchDuplicateName: async () => null,
|
fetchDuplicateName: async () => null,
|
||||||
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
|
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
|
||||||
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' })
|
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' }),
|
||||||
|
fetchPlayerPublicBaseUrl: async () => 'http://player.local'
|
||||||
},
|
},
|
||||||
pages,
|
pages,
|
||||||
getAuditUserId() { return 7; },
|
getAuditUserId() { return 7; },
|
||||||
|
|||||||
@@ -34,10 +34,11 @@ test('screen edit page includes shared launcher downloads and base player url',
|
|||||||
async fetchScreenEditData() {
|
async fetchScreenEditData() {
|
||||||
return { playlists: [] };
|
return { playlists: [] };
|
||||||
},
|
},
|
||||||
async fetchScreenPlayerUrls() {
|
async fetchPlayerRegistrations() {
|
||||||
return {
|
return [
|
||||||
'demo-conference': 'http://player.example/screen/demo-conference'
|
{ identifier: 'player-alpha', public_base_url: 'http://alpha.example' },
|
||||||
};
|
{ identifier: 'player-beta', public_base_url: 'http://beta.example/' }
|
||||||
|
];
|
||||||
},
|
},
|
||||||
async fetchPlayerPublicBaseUrl() {
|
async fetchPlayerPublicBaseUrl() {
|
||||||
return 'http://player.example';
|
return 'http://player.example';
|
||||||
@@ -71,7 +72,97 @@ test('screen edit page includes shared launcher downloads and base player url',
|
|||||||
await handler[1]({ params: { id: '7' }, query: {}, currentUser: { id: 1 } }, res, () => {});
|
await handler[1]({ params: { id: '7' }, query: {}, currentUser: { id: 1 } }, res, () => {});
|
||||||
|
|
||||||
assert.equal(res.body, 'ok');
|
assert.equal(res.body, 'ok');
|
||||||
assert.deepEqual(renderedArgs.screen.player_url, 'http://player.example/screen/demo-conference');
|
assert.deepEqual(renderedArgs.screen.player_urls, [
|
||||||
|
{
|
||||||
|
identifier: 'player-alpha',
|
||||||
|
public_base_url: 'http://alpha.example',
|
||||||
|
player_url: 'http://alpha.example/screen/demo-conference'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: 'player-beta',
|
||||||
|
public_base_url: 'http://beta.example',
|
||||||
|
player_url: 'http://beta.example/screen/demo-conference'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
assert.deepEqual(renderedArgs.screen.launcher_downloads, {
|
||||||
|
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
|
||||||
|
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('screen edit query route includes player urls for every registration', async () => {
|
||||||
|
const handlers = {};
|
||||||
|
const app = {
|
||||||
|
get(path, ...routeHandlers) {
|
||||||
|
handlers[path] = routeHandlers;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderedArgs = null;
|
||||||
|
const pages = {
|
||||||
|
renderScreenEditPage(screen, data, message, currentUser) {
|
||||||
|
renderedArgs = { screen, data, message, currentUser };
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerScreensRoutes(app, {
|
||||||
|
pool: {
|
||||||
|
async query() {
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
async fetchScreenById() {
|
||||||
|
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
|
||||||
|
},
|
||||||
|
async fetchScreenEditData() {
|
||||||
|
return { playlists: [] };
|
||||||
|
},
|
||||||
|
async fetchPlayerRegistrations() {
|
||||||
|
return [
|
||||||
|
{ identifier: 'player-alpha', public_base_url: 'http://alpha.example' }
|
||||||
|
];
|
||||||
|
},
|
||||||
|
async fetchPlayerPublicBaseUrl() {
|
||||||
|
return 'http://player.example';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pages,
|
||||||
|
buildDashboardState: async () => ({ screens: [] }),
|
||||||
|
getScreenDeleteBlockMessage: async () => '',
|
||||||
|
getScreenConnections: async () => [],
|
||||||
|
playerPublicBaseUrl: 'http://player.example',
|
||||||
|
requirePermission() {
|
||||||
|
return function (_req, _res, next) {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = handlers['/screens'];
|
||||||
|
assert.equal(Array.isArray(handler), true);
|
||||||
|
|
||||||
|
const res = {
|
||||||
|
send(value) {
|
||||||
|
this.body = value;
|
||||||
|
},
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await handler[1]({ query: { edit: '7' }, currentUser: { id: 1 } }, res, () => {});
|
||||||
|
|
||||||
|
assert.equal(res.body, 'ok');
|
||||||
|
assert.deepEqual(renderedArgs.screen.player_urls, [
|
||||||
|
{
|
||||||
|
identifier: 'player-alpha',
|
||||||
|
public_base_url: 'http://alpha.example',
|
||||||
|
player_url: 'http://alpha.example/screen/demo-conference'
|
||||||
|
}
|
||||||
|
]);
|
||||||
assert.deepEqual(renderedArgs.screen.launcher_downloads, {
|
assert.deepEqual(renderedArgs.screen.launcher_downloads, {
|
||||||
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
|
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
|
||||||
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
|
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
|
||||||
@@ -150,4 +241,72 @@ test('screen launcher downloads are shared and attached', async () => {
|
|||||||
assert.match(res.headers['content-disposition'], /pulse-signage-kiosk\.bat/);
|
assert.match(res.headers['content-disposition'], /pulse-signage-kiosk\.bat/);
|
||||||
assert.match(res.body, /TARGET_URL=http:\/\/player\.example/);
|
assert.match(res.body, /TARGET_URL=http:\/\/player\.example/);
|
||||||
assert.match(res.body, /--kiosk/);
|
assert.match(res.body, /--kiosk/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('screen launcher downloads can target an explicit player url', async () => {
|
||||||
|
const handlers = {};
|
||||||
|
const app = {
|
||||||
|
get(path, ...routeHandlers) {
|
||||||
|
handlers[path] = routeHandlers;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerScreensRoutes(app, {
|
||||||
|
pool: {
|
||||||
|
async query() {
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
async fetchScreenById() {
|
||||||
|
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
|
||||||
|
},
|
||||||
|
async fetchScreenEditData() {
|
||||||
|
return { playlists: [] };
|
||||||
|
},
|
||||||
|
async fetchPlayerPublicBaseUrl() {
|
||||||
|
return 'http://player.example';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pages: { renderScreenEditPage() { return 'ok'; } },
|
||||||
|
buildDashboardState: async () => ({ screens: [] }),
|
||||||
|
getScreenDeleteBlockMessage: async () => '',
|
||||||
|
getScreenConnections: async () => [],
|
||||||
|
playerPublicBaseUrl: 'http://player.example',
|
||||||
|
requirePermission() {
|
||||||
|
return function (_req, _res, next) {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const windowsHandler = handlers['/downloads/kiosk/pulse-signage-kiosk.bat'];
|
||||||
|
assert.equal(Array.isArray(windowsHandler), true);
|
||||||
|
|
||||||
|
const res = {
|
||||||
|
headers: {},
|
||||||
|
statusCode: 200,
|
||||||
|
set(name, value) {
|
||||||
|
this.headers[name.toLowerCase()] = value;
|
||||||
|
},
|
||||||
|
attachment(filename) {
|
||||||
|
this.headers['content-disposition'] = `attachment; filename="${filename}"`;
|
||||||
|
},
|
||||||
|
type(value) {
|
||||||
|
this.headers['content-type'] = value;
|
||||||
|
},
|
||||||
|
send(value) {
|
||||||
|
this.body = value;
|
||||||
|
},
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await windowsHandler[1]({ query: { playerUrl: 'http://player-b.example/screen/demo-conference' } }, res, () => {});
|
||||||
|
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.match(res.body, /TARGET_URL=http:\/\/player-b\.example\/screen\/demo-conference/);
|
||||||
|
assert.doesNotMatch(res.body, /TARGET_URL=http:\/\/player\.example/);
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user