Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b300d6ddb | ||
|
|
1101ffac34 | ||
|
|
7e758ecca8 | ||
|
|
2bd47fb96a | ||
|
|
0801c119ae | ||
|
|
433be06bc7 | ||
|
|
74318eb34e | ||
|
|
a4f8a807ff | ||
|
|
59730837ad |
@@ -2,3 +2,5 @@
|
||||
!package.json
|
||||
!src/
|
||||
!src/**
|
||||
!scripts/
|
||||
!scripts/**
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
|
||||
PULSE_SIGNAGE_SHARED_SECRET=
|
||||
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_NAME=pulse-signage
|
||||
DB_USER=pulse-signage
|
||||
DB_PASSWORD=signage_password
|
||||
MYSQL_ROOT_PASSWORD=root_password
|
||||
|
||||
PLAYER_PUBLIC_BASE_URL=http://localhost:8081
|
||||
PLAYER_INTERNAL_BASE_URL=http://player:8081
|
||||
|
||||
SESSION_MAX_AGE_DAYS=14
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_NAME=Admin
|
||||
DEFAULT_ADMIN_PASSWORD=admin
|
||||
PASSWORD_HASH_ITERATIONS=310000
|
||||
@@ -3,6 +3,8 @@ media/
|
||||
!src/web/lib/media/
|
||||
!src/web/lib/media/**
|
||||
docker-compose.dev.yml
|
||||
/docker-compose/*.dev.yml
|
||||
/docker-compose/*.env
|
||||
/dev-demo-seed.js
|
||||
.vscode/
|
||||
.env
|
||||
|
||||
@@ -2,6 +2,76 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.6.5 - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- Multiple players are now supported across the player registry, dashboard snapshots, and screen group management, so a deployment can track more than one connected player at a time.
|
||||
- The player-agent can now run remotely, which lets a player connect through the bridge instead of requiring everything to stay on the same host.
|
||||
- The Docker Compose setup now includes a public stack with a player bridge service and a separate remote player stack, so local and split-device deployments share the same documented layout.
|
||||
- Dedicated local and remote compose manifests now live at `docker-compose/docker-compose.yml` and `docker-compose/docker-compose.remote.yml`, with matching example env files for the two deployment modes.
|
||||
- Admin client commands now have a dedicated route and test coverage, which keeps dashboard actions aligned with the current player control-plane flow.
|
||||
|
||||
### Changed
|
||||
|
||||
- Screen group views now show each registered player's public base URL and computed player URL on the edit screen instead of assuming a single local player.
|
||||
- The dashboard status indicator now reports the connected player count, which makes the live feed status more explicit when several players are online.
|
||||
- Player command dispatch, playlist playback, and render preloading were tightened so the player keeps using the active web base URL and can warm assets before the slide is shown.
|
||||
- Playlist video-duration selection now uses the longest matching video region when a slide contains more than one video region.
|
||||
- Media sync, font sync, and upload sync now follow the newer player-agent workflow, keeping background tasks and dashboard refreshes consistent with split-device deployments.
|
||||
- The onboarding landing page now shows a built-in QR placeholder and falls back to it when the QR request fails, so the screen never starts blank.
|
||||
- Upload sync now resolves the player internal base URL once per batch and reuses it for queued upload and delete operations, keeping mirrored media writes pointed at the active player endpoint.
|
||||
- The connected clients list now defaults to client name, then IP address, so rows stay in a stable order when connection details change.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Connected clients search results now keep the row action column, because the row template resolves `currentUser` from the parent view context while rendering inside the `clients` loop.
|
||||
- The move-client modal now carries the row's player base URL through to the redirect step, so clients move correctly even when local and bridge-backed players are mixed.
|
||||
- Paginated table cards now only apply their minimum height when the table content would otherwise exceed that threshold, which keeps short result sets compact in smaller browser windows.
|
||||
|
||||
## 2.6.4 - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- The screen table no longer stores a player foreign key, and screen/player URLs now resolve from the current player registration instead of a screen assignment.
|
||||
|
||||
## 2.6.3 - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- The screen table no longer enforces a foreign key to the player table, which keeps player assignments flexible while preserving the existing screen schema.
|
||||
|
||||
## 2.6.2 - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- The player registry now upgrades to an integer player id with a separate player identifier so the schema migration can complete cleanly on existing databases.
|
||||
- Dashboard screen lookups now tolerate the upgraded player table layout during the rollout.
|
||||
|
||||
## 2.6.1 - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- The web side now resolves the player base URL from the database-backed player registration record instead of depending on a web-container environment fallback.
|
||||
- Player registrations now store a separate friendly identifier, so a player can keep the same device key while showing a label like `Shop2`.
|
||||
|
||||
## 2.5.14 - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
## 2.5.13 - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Slide and template save forms now allow larger multipart text fields, so rich region content no longer trips Multer's default field-value limit.
|
||||
|
||||
## 2.5.12 - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -8,6 +8,7 @@ COPY package*.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY src ./src
|
||||
COPY scripts ./scripts
|
||||
|
||||
RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css
|
||||
RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css
|
||||
|
||||
@@ -1,81 +1,38 @@
|
||||
# Pulse Signage
|
||||
|
||||
Pulse Signage is a self-hosted digital signage platform built for teams that want clear, flexible control over what appears on every screen.
|
||||
Pulse Signage is a self-hosted digital signage platform for teams that want clear, reliable control over the content on every screen.
|
||||
|
||||
It gives you one place to manage playlists, slides, templates, screen assignments, announcements, data-driven content, and live player control without handing that workflow to a third-party service.
|
||||
It gives you one place to publish playlists, slides, announcements, and live updates without handing the workflow to a third-party service.
|
||||
|
||||
## Why It Stands Out
|
||||
## What It Can Do
|
||||
|
||||
- Keep signage under your own control.
|
||||
- Build polished screen experiences with reusable templates and playlists.
|
||||
- Push announcements, schedules, RSS feeds, API content, and other dynamic content to screens.
|
||||
- See what is live, what is queued, and what needs attention from a single admin dashboard.
|
||||
- Use the same system for everyday signage, timed messages, and more structured information displays.
|
||||
- Run polished screen experiences with playlists, slides, and reusable templates.
|
||||
- Keep announcements, schedules, RSS feeds, API content, and other live data in sync.
|
||||
- Manage many screens from a single dashboard.
|
||||
- Support everyday signage, event messages, lobbies, dashboards, and other always-on displays.
|
||||
- Keep the player, dashboard, and bridge connected so updates move quickly and consistently.
|
||||
|
||||
## What It Handles
|
||||
## Why It Fits
|
||||
|
||||
- Screens and playlist assignments.
|
||||
- Slides, templates, and canvas sizes.
|
||||
- Announcements and screen-specific targeting.
|
||||
- RSS feeds, API sources, and schedule-based content.
|
||||
- Roles and permissions for admin access.
|
||||
- Background tasks and scheduled refresh jobs.
|
||||
- Player onboarding and live playback control.
|
||||
- It keeps signage under your own control.
|
||||
- It is built for teams that need screens to stay current without extra manual work.
|
||||
- It works well for offices, venues, campuses, and operations teams.
|
||||
- It stays focused on display management instead of trying to be a general-purpose CMS.
|
||||
|
||||
## Simple Deployment
|
||||
## Deploying
|
||||
|
||||
Pulse Signage ships with a Docker Compose stack for the published image:
|
||||
Docker Compose is the recommended way to deploy Pulse Signage. It keeps the web app, player, bridge, and database together in a predictable setup.
|
||||
|
||||
- Published-image stack: `docker compose -f docker-compose.yml up -d`
|
||||
If you want the details, start with the [Compose guide](docker-compose/README.md).
|
||||
|
||||
Use this stack when you want to run the tagged image instead of building from source.
|
||||
## Docs
|
||||
|
||||
## Environment File
|
||||
- [Documentation home](docs/README.md) - the technical reference index.
|
||||
- [API reference](docs/api.md) - the player HTTP surface and onboarding endpoints.
|
||||
- [Database schema](docs/schema.md) - the tables and data model the app maintains.
|
||||
- [WebSocket reference](docs/websocket.md) - the live player and snapshot channels.
|
||||
- [Compose guide](docker-compose/README.md) - deployment options and service layout.
|
||||
|
||||
The compose files read from a root `.env` file. Start by copying `.env.example` to `.env`, then adjust the values for your setup.
|
||||
## Explore The Docs
|
||||
|
||||
The variables are split by who uses them:
|
||||
|
||||
Shared across the stack:
|
||||
|
||||
- `PULSE_SIGNAGE_IMAGE`
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET`
|
||||
|
||||
Web container:
|
||||
|
||||
- `SESSION_MAX_AGE_DAYS`
|
||||
- `DEFAULT_ADMIN_USERNAME`
|
||||
- `DEFAULT_ADMIN_NAME`
|
||||
- `DEFAULT_ADMIN_PASSWORD`
|
||||
- `PASSWORD_HASH_ITERATIONS`
|
||||
|
||||
Player container:
|
||||
|
||||
- `PLAYER_PUBLIC_BASE_URL`
|
||||
- `PLAYER_INTERNAL_BASE_URL`
|
||||
|
||||
MySQL container:
|
||||
|
||||
- `DB_HOST`
|
||||
- `DB_PORT`
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `MYSQL_ROOT_PASSWORD`
|
||||
|
||||
`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD` are shared in practice because both app containers talk to the same database service.
|
||||
|
||||
`PLAYER_INTERNAL_BASE_URL` is used by the web side for server-to-player calls, while `PLAYER_PUBLIC_BASE_URL` is used for browser-facing player links.
|
||||
|
||||
## First-Time Use
|
||||
|
||||
The first startup seeds a default admin account if the database is empty.
|
||||
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
## In Practice
|
||||
|
||||
Pulse Signage is designed to feel focused and reliable rather than heavy or noisy. It works well when you want a clean internal signage system for offices, venues, dashboards, or any environment where screens need to stay up to date without a lot of manual effort.
|
||||
|
||||
The player and admin pieces stay linked, so changes made in the dashboard can flow out to screens quickly and consistently.
|
||||
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.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Shared application settings
|
||||
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest"
|
||||
PULSE_SIGNAGE_SHARED_SECRET=""
|
||||
|
||||
# Database settings for the web, player, and bridge services
|
||||
DB_HOST="mysql"
|
||||
DB_PORT=3306
|
||||
DB_NAME="pulse-signage"
|
||||
DB_USER="pulse-signage"
|
||||
DB_PASSWORD="signage_password"
|
||||
MYSQL_ROOT_PASSWORD="root_password"
|
||||
|
||||
# Player settings
|
||||
PLAYER_IDENTIFIER="player-local"
|
||||
PLAYER_PUBLIC_BASE_URL="http://localhost:8081"
|
||||
PLAYER_INTERNAL_BASE_URL="http://player:8081"
|
||||
|
||||
# Web app bootstrap settings
|
||||
SESSION_MAX_AGE_DAYS=14
|
||||
DEFAULT_ADMIN_USERNAME="admin"
|
||||
DEFAULT_ADMIN_NAME="Admin"
|
||||
DEFAULT_ADMIN_PASSWORD="admin"
|
||||
PASSWORD_HASH_ITERATIONS=310000
|
||||
|
||||
# Bridge settings for the player-bridge service
|
||||
WEB_BASE_URL="http://web:8080"
|
||||
@@ -0,0 +1,11 @@
|
||||
# Shared application settings
|
||||
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest"
|
||||
PULSE_SIGNAGE_SHARED_SECRET=""
|
||||
|
||||
# Player settings
|
||||
PLAYER_IDENTIFIER="player-remote"
|
||||
PLAYER_PUBLIC_BASE_URL="http://localhost:8081"
|
||||
PLAYER_AGENT_RECONNECT_DELAY_MS=5000
|
||||
|
||||
# Remote player connectivity settings
|
||||
THIN_CLIENT_BASE_URL="http://192.168.0.80:8090"
|
||||
@@ -0,0 +1,220 @@
|
||||
# Docker Compose Setup
|
||||
|
||||
This folder contains the Docker Compose definitions for Pulse Signage, including the public stack and the remote player stack.
|
||||
|
||||
## Files
|
||||
|
||||
- [docker-compose.yml](docker-compose.yml) - full public stack with web, player, player bridge, and MySQL.
|
||||
- [.env.example](.env.example) - sample environment values for the public stack.
|
||||
- [docker-compose.remote.yml](docker-compose.remote.yml) - remote player-only stack for machines that sit behind the player bridge.
|
||||
- [.env.remote.example](.env.remote.example) - sample environment values for the remote stack.
|
||||
|
||||
## Stack Overview
|
||||
|
||||
### Public stack
|
||||
|
||||
The main compose stack is the most complete setup. It runs:
|
||||
|
||||
- `web` - the dashboard and admin app.
|
||||
- `player` - the local screen player.
|
||||
- `player-bridge` - the bridge service that proxies dashboard commands and player communication.
|
||||
- `mysql` - the database used by the web, player, and bridge services.
|
||||
|
||||
This is the stack to use when you want the full app running on one machine.
|
||||
|
||||
### Remote player
|
||||
|
||||
The remote stack runs only the `player` service.
|
||||
|
||||
Use it when the player is installed on a remote device and connects back through the player bridge instead of running the full app separately.
|
||||
|
||||
## Services
|
||||
|
||||
### `web`
|
||||
|
||||
The dashboard and admin application.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- serves the web UI on port `8080`
|
||||
- reads and writes application data from MySQL
|
||||
- forwards player actions through the configured bridge base URL
|
||||
- renders connected clients, dashboard pages, and admin workflows
|
||||
|
||||
Key configuration:
|
||||
|
||||
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET`
|
||||
- `SESSION_MAX_AGE_DAYS`
|
||||
- `DEFAULT_ADMIN_USERNAME`
|
||||
- `DEFAULT_ADMIN_NAME`
|
||||
- `DEFAULT_ADMIN_PASSWORD`
|
||||
- `PASSWORD_HASH_ITERATIONS`
|
||||
|
||||
### `player`
|
||||
|
||||
The screen runtime that renders playlists and receives commands.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- serves the player UI on port `8081`
|
||||
- connects to MySQL in local mode
|
||||
- connects to the bridge in remote mode through `THIN_CLIENT_BASE_URL`
|
||||
- registers live connections and accepts control commands
|
||||
|
||||
Key configuration:
|
||||
|
||||
- `PLAYER_PUBLIC_BASE_URL`
|
||||
- `PLAYER_INTERNAL_BASE_URL`
|
||||
- `PLAYER_IDENTIFIER`
|
||||
- `THIN_CLIENT_BASE_URL` in remote mode
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET`
|
||||
- database settings in local mode
|
||||
|
||||
### `player-bridge`
|
||||
|
||||
The bridge layer that connects the dashboard to the player network.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- serves the bridge API on port `8090`
|
||||
- forwards authenticated dashboard commands to registered players
|
||||
- exposes screen connection snapshots and player registration data
|
||||
- proxies command traffic between the web app and remote players
|
||||
|
||||
Key configuration:
|
||||
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET`
|
||||
- `WEB_BASE_URL` for the bridge when it should call the web app directly instead of inferring from request headers
|
||||
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
|
||||
|
||||
### `mysql`
|
||||
|
||||
The MySQL 8.4 database used by the public stack.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- stores application data, screen state, onboarding state, and registry records
|
||||
- provides persistent storage through `mysql_data`
|
||||
|
||||
Key configuration:
|
||||
|
||||
- `MYSQL_DATABASE`
|
||||
- `MYSQL_USER`
|
||||
- `MYSQL_PASSWORD`
|
||||
- `MYSQL_ROOT_PASSWORD`
|
||||
|
||||
## Environment Files
|
||||
|
||||
### `.env.example`
|
||||
|
||||
Use this file as a starting point for the public compose stack.
|
||||
|
||||
Important values:
|
||||
|
||||
- `PULSE_SIGNAGE_IMAGE` - image to run for all app services
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests
|
||||
- `PLAYER_IDENTIFIER` - unique local player identifier
|
||||
- `DB_*` - MySQL credentials and database name for the stack
|
||||
- `PLAYER_PUBLIC_BASE_URL` - public URL the player advertises
|
||||
- `PLAYER_INTERNAL_BASE_URL` - internal URL the web app uses for local player calls
|
||||
- `SESSION_MAX_AGE_DAYS` - dashboard session lifetime
|
||||
- `DEFAULT_ADMIN_*` - bootstrap admin account values
|
||||
- `PASSWORD_HASH_ITERATIONS` - password hashing cost
|
||||
|
||||
### `.env.remote.example`
|
||||
|
||||
Use this file on a remote player device.
|
||||
|
||||
Important values:
|
||||
|
||||
- `PULSE_SIGNAGE_IMAGE` - image to run on the device
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment
|
||||
- `PLAYER_IDENTIFIER` - unique remote player identifier
|
||||
- `PLAYER_PUBLIC_BASE_URL` - public URL for the remote player
|
||||
- `THIN_CLIENT_BASE_URL` - bridge URL the player connects back to
|
||||
- `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent
|
||||
|
||||
### `PULSE_SIGNAGE_SHARED_SECRET`
|
||||
|
||||
This secret is the shared signing key for requests between the services. Use a single value for every service that needs to talk to the same stack, including the web app, player, bridge, and any remote player that connects back to that bridge.
|
||||
|
||||
Recommended shape:
|
||||
|
||||
- at least 32 random bytes
|
||||
- ideally 64 hex characters, or another equally long cryptographically random string
|
||||
- not a password, phrase, or anything human-readable
|
||||
|
||||
If you want a quick local value, generate one with a password manager or a command such as `openssl rand -hex 32`.
|
||||
|
||||
Leave it blank only if you intentionally want to run without request signing in a throwaway local setup.
|
||||
|
||||
## Main Configuration Variables
|
||||
|
||||
| Variable | Used By | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `PULSE_SIGNAGE_IMAGE` | web, player, bridge, remote player | Docker image to run for the app services. |
|
||||
| `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge, remote player | Shared secret for authenticated requests between services. |
|
||||
| `DB_HOST` | web, player, bridge | Database host name. |
|
||||
| `DB_PORT` | web, player, bridge | Database port. |
|
||||
| `DB_NAME` | web, player, bridge, mysql | Database name. |
|
||||
| `DB_USER` | web, player, bridge, mysql | Database user. |
|
||||
| `DB_PASSWORD` | web, player, bridge, mysql | Database password. |
|
||||
| `MYSQL_ROOT_PASSWORD` | mysql | Root password for the local MySQL container. |
|
||||
| `SESSION_MAX_AGE_DAYS` | web | Session cookie lifetime. |
|
||||
| `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. |
|
||||
| `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. |
|
||||
| `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. |
|
||||
| `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. |
|
||||
| `PLAYER_INTERNAL_BASE_URL` | web, player | Internal player URL used by the dashboard and player runtime. |
|
||||
| `THIN_CLIENT_BASE_URL` | web, player, remote player | URL of the bridge service. |
|
||||
| `PLAYER_PUBLIC_BASE_URL` | player, remote player | Public URL advertised by the player. |
|
||||
| `PLAYER_IDENTIFIER` | player | Stable player identifier. |
|
||||
| `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. |
|
||||
|
||||
## Ports
|
||||
|
||||
Public stack ports:
|
||||
|
||||
- `8080` - web dashboard
|
||||
- `8081` - player
|
||||
- `8090` - player bridge
|
||||
- `3306` - MySQL
|
||||
|
||||
Remote stack ports:
|
||||
|
||||
- `8081` - player only
|
||||
|
||||
## Volumes
|
||||
|
||||
### Public stack
|
||||
|
||||
- `mysql_data` - persistent MySQL data.
|
||||
- `pulse-signage` - shared media and cache volume for the app services.
|
||||
|
||||
### Remote stack
|
||||
|
||||
- `pulse-signage` - shared media and cache volume for the remote player.
|
||||
|
||||
## Networks
|
||||
|
||||
Each compose file creates its own named network:
|
||||
|
||||
- `pulse-signage` for the public stack
|
||||
- `pulse-signage-remote` for remote player deployment.
|
||||
|
||||
## Notes
|
||||
|
||||
- The public stack expects the app services and MySQL to share the same `PULSE_SIGNAGE_SHARED_SECRET`.
|
||||
- A remote player must use the same `PULSE_SIGNAGE_SHARED_SECRET` as the bridge it connects to.
|
||||
- The bridge service is the dashboard-facing command path for connected remote players.
|
||||
- The remote player should point `THIN_CLIENT_BASE_URL` at the bridge, not at the public web endpoint.
|
||||
- The `PULSE_SIGNAGE_IMAGE` tag defaults to the published image, but it can be overridden for local builds or custom releases.
|
||||
|
||||
## Recommended Setup
|
||||
|
||||
1. Copy `.env.example` to a local `.env` file for the public stack.
|
||||
2. Copy `.env.remote.example` to a device-specific `.env` file for the remote player.
|
||||
3. Make sure `PULSE_SIGNAGE_SHARED_SECRET` matches everywhere.
|
||||
4. Start the public stack first, then start the remote player after the bridge is reachable.
|
||||
5. Verify that the player appears in Connected clients before testing screen commands.
|
||||
@@ -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
|
||||
@@ -1,3 +1,5 @@
|
||||
name: pulse-signage
|
||||
|
||||
services:
|
||||
web:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
@@ -7,18 +9,17 @@ services:
|
||||
environment:
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-signage}
|
||||
DB_USER: ${DB_USER:-signage_user}
|
||||
DB_NAME: ${DB_NAME:-pulse-signage}
|
||||
DB_USER: ${DB_USER:-pulse-signage}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
||||
DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000}
|
||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
|
||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
|
||||
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
|
||||
volumes:
|
||||
- media:/app/media
|
||||
- pulse-signage:/app/media
|
||||
command: ["node", "src/web.js"]
|
||||
depends_on:
|
||||
mysql:
|
||||
@@ -34,14 +35,15 @@ services:
|
||||
environment:
|
||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
|
||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081}
|
||||
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-signage}
|
||||
DB_USER: ${DB_USER:-signage_user}
|
||||
DB_NAME: ${DB_NAME:-pulse-signage}
|
||||
DB_USER: ${DB_USER:-pulse-signage}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
volumes:
|
||||
- media:/app/media
|
||||
- pulse-signage:/app/media
|
||||
command: ["node", "src/player.js"]
|
||||
depends_on:
|
||||
mysql:
|
||||
@@ -49,12 +51,34 @@ services:
|
||||
networks:
|
||||
- pulse_signage
|
||||
|
||||
player-bridge:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:8090"
|
||||
environment:
|
||||
WEB_BASE_URL: ${WEB_BASE_URL:-http://web:8080}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-pulse-signage}
|
||||
DB_USER: ${DB_USER:-pulse-signage}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
command: ["node", "src/player-bridge/index.js"]
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- pulse_signage
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3306:3306"
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME:-signage}
|
||||
MYSQL_USER: ${DB_USER:-signage_user}
|
||||
MYSQL_DATABASE: ${DB_NAME:-pulse-signage}
|
||||
MYSQL_USER: ${DB_USER:-pulse-signage}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
|
||||
command:
|
||||
@@ -72,9 +96,9 @@ services:
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
media:
|
||||
pulse-signage:
|
||||
|
||||
networks:
|
||||
pulse_signage:
|
||||
name: pulse_signage
|
||||
external: false
|
||||
name: pulse-signage
|
||||
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.
|
||||
+17
@@ -78,6 +78,11 @@ Response fields:
|
||||
Returns the upload directory configured for the player service.
|
||||
Access: internal-only.
|
||||
|
||||
Response fields:
|
||||
|
||||
- `mediaDir`
|
||||
- `uploadDir`
|
||||
|
||||
### `PUT /api/media/{filename}`
|
||||
Writes an uploaded file into the player upload directory.
|
||||
Access: internal-only and write-protected behind your deployment boundary.
|
||||
@@ -95,6 +100,14 @@ Query fields:
|
||||
- `source` required
|
||||
- `disableAudio` optional
|
||||
|
||||
Response fields:
|
||||
|
||||
- `key`
|
||||
- `playlistUrl`
|
||||
- `disableAudio`
|
||||
- `ready`
|
||||
- `live`
|
||||
|
||||
### `GET /api/rtmp/streams/{key}/index.m3u8`
|
||||
Returns the RTMP session HLS manifest.
|
||||
Access: internal-only.
|
||||
@@ -114,6 +127,7 @@ Response fields:
|
||||
- `slides`
|
||||
- `rssFeeds`
|
||||
- `apiSources`
|
||||
- `timetableGroups`
|
||||
- `revision`
|
||||
|
||||
### `GET /api/screens/{slug}/connections`
|
||||
@@ -198,6 +212,7 @@ Response fields:
|
||||
|
||||
`GET /api/media/config` returns an object with:
|
||||
|
||||
- `mediaDir`
|
||||
- `uploadDir`
|
||||
|
||||
### RTMP Session Response
|
||||
@@ -208,6 +223,7 @@ Response fields:
|
||||
- `playlistUrl`
|
||||
- `disableAudio`
|
||||
- `ready`
|
||||
- `live`
|
||||
|
||||
### Onboarding Write Response
|
||||
|
||||
@@ -230,6 +246,7 @@ Response fields:
|
||||
- `slides`
|
||||
- `rssFeeds`
|
||||
- `apiSources`
|
||||
- `timetableGroups`
|
||||
- `revision`
|
||||
|
||||
### Connections Response
|
||||
|
||||
+9
-9
@@ -21,7 +21,8 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
### `a_roles`
|
||||
|
||||
- `id`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `id`, `role_key`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `role_key` is unique.
|
||||
- `name` is unique.
|
||||
|
||||
### `a_permissions`
|
||||
@@ -93,7 +94,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
### `c_playlist_slides`
|
||||
|
||||
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `disable_audio`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign keys:
|
||||
- `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE`
|
||||
- `slide_id` -> `c_slides.id` with `ON DELETE CASCADE`
|
||||
@@ -112,7 +113,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
## Devices
|
||||
|
||||
- `d_players` - player registry and connection metadata.
|
||||
- `d_screens` - screen records and playlist/player assignment.
|
||||
- `d_screens` - screen records and playlist assignment.
|
||||
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
|
||||
|
||||
## Announcements
|
||||
@@ -122,18 +123,18 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
### `d_players`
|
||||
|
||||
- `device_id`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at`
|
||||
- `device_id` is the primary key.
|
||||
- `id`, `identifier`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at`
|
||||
- `id` is the primary key.
|
||||
- `identifier` is unique and is the stable player identity used by the app.
|
||||
|
||||
### `d_screens`
|
||||
|
||||
- `id`, `name`, `slug`, `playlist_id`, `player_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `id`, `name`, `slug`, `playlist_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `slug` is unique.
|
||||
- Foreign keys:
|
||||
- `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL`
|
||||
- `player_id` -> `d_players.id` with `ON DELETE RESTRICT`
|
||||
|
||||
- `player_id` is required and defaults to `'1'` for the current singleton-player model.
|
||||
- Screens are no longer tied to a player foreign key directly; player registration and live connection metadata are tracked separately in `d_players`.
|
||||
|
||||
### `d_onboarding_devices`
|
||||
|
||||
@@ -275,7 +276,6 @@ erDiagram
|
||||
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
|
||||
C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules
|
||||
|
||||
D_PLAYERS ||--o{ D_SCREENS : assigned_to
|
||||
C_PLAYLISTS ||--o{ D_SCREENS : uses
|
||||
D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds
|
||||
D_ANNOUNCEMENTS ||--o{ D_ANNOUNCEMENT_SCREENS : targets
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.5.12",
|
||||
"version": "2.6.5",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
|
||||
set "TARGET_URL=%~1"
|
||||
if not defined TARGET_URL set "TARGET_URL=http://localhost:8081"
|
||||
|
||||
set "BROWSER_PATH="
|
||||
set "BROWSER_KIND="
|
||||
|
||||
for %%B in (chrome.exe msedge.exe firefox.exe) do if not defined BROWSER_PATH (
|
||||
for /f "delims=" %%I in ('where %%B 2^>nul') do if not defined BROWSER_PATH (
|
||||
set "BROWSER_PATH=%%I"
|
||||
set "BROWSER_KIND=%%~nB"
|
||||
)
|
||||
)
|
||||
|
||||
if not defined BROWSER_PATH if not defined BROWSER_KIND (
|
||||
for %%P in (
|
||||
"%ProgramFiles%\Google\Chrome\Application\chrome.exe"
|
||||
"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"
|
||||
"%LocalAppData%\Google\Chrome\Application\chrome.exe"
|
||||
) do if not defined BROWSER_PATH if exist "%%~fP" (
|
||||
set "BROWSER_PATH=%%~fP"
|
||||
set "BROWSER_KIND=chrome"
|
||||
)
|
||||
)
|
||||
|
||||
if not defined BROWSER_PATH if not defined BROWSER_KIND (
|
||||
for %%P in (
|
||||
"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"
|
||||
"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"
|
||||
"%LocalAppData%\Microsoft\Edge\Application\msedge.exe"
|
||||
) do if not defined BROWSER_PATH if exist "%%~fP" (
|
||||
set "BROWSER_PATH=%%~fP"
|
||||
set "BROWSER_KIND=edge"
|
||||
)
|
||||
)
|
||||
|
||||
if not defined BROWSER_PATH if not defined BROWSER_KIND (
|
||||
for %%P in (
|
||||
"%ProgramFiles%\Mozilla Firefox\firefox.exe"
|
||||
"%ProgramFiles(x86)%\Mozilla Firefox\firefox.exe"
|
||||
"%LocalAppData%\Mozilla Firefox\firefox.exe"
|
||||
) do if not defined BROWSER_PATH if exist "%%~fP" (
|
||||
set "BROWSER_PATH=%%~fP"
|
||||
set "BROWSER_KIND=firefox"
|
||||
)
|
||||
)
|
||||
|
||||
if not defined BROWSER_PATH (
|
||||
echo No supported browser was found.
|
||||
echo Tried Chrome, Edge, and Firefox in PATH and common install locations.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Launching !BROWSER_KIND! in kiosk mode: !TARGET_URL!
|
||||
if /I "!BROWSER_KIND!"=="firefox" (
|
||||
start "" "!BROWSER_PATH!" -kiosk "!TARGET_URL!"
|
||||
) else (
|
||||
start "" "!BROWSER_PATH!" --disable-notifications --kiosk "!TARGET_URL!"
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
target_url="${1:-http://localhost:8081}"
|
||||
|
||||
find_browser() {
|
||||
local candidate
|
||||
for candidate in "$@"; do
|
||||
if command -v "$candidate" >/dev/null 2>&1; then
|
||||
printf '%s' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
browser=""
|
||||
browser_kind=""
|
||||
|
||||
if browser="$(find_browser google-chrome-stable google-chrome chromium chromium-browser msedge microsoft-edge firefox)"; then
|
||||
case "$browser" in
|
||||
firefox)
|
||||
browser_kind="firefox"
|
||||
;;
|
||||
msedge|microsoft-edge)
|
||||
browser_kind="edge"
|
||||
;;
|
||||
*)
|
||||
browser_kind="chrome"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo "No supported browser was found."
|
||||
echo "Tried Chrome, Chromium, Edge, and Firefox in PATH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launching ${browser_kind} in kiosk mode: ${target_url}"
|
||||
|
||||
case "$browser_kind" in
|
||||
firefox)
|
||||
exec "$browser" -kiosk "$target_url"
|
||||
;;
|
||||
edge|chrome)
|
||||
exec "$browser" --disable-notifications --kiosk "$target_url"
|
||||
;;
|
||||
esac
|
||||
@@ -86,8 +86,10 @@ module.exports = {
|
||||
fetchScreenById: data.fetchScreenById,
|
||||
fetchScreenEditData: data.fetchScreenEditData,
|
||||
fetchScreenPlayerUrls: data.fetchScreenPlayerUrls,
|
||||
fetchPlayerPublicBaseUrl: data.fetchPlayerPublicBaseUrl,
|
||||
fetchScreenPlayerRecord: data.fetchScreenPlayerRecord,
|
||||
fetchTemplateById: data.fetchTemplateById,
|
||||
fetchPlayerRegistrations: data.fetchPlayerRegistrations,
|
||||
fetchSlideById: data.fetchSlideById,
|
||||
fetchTemplatesData: data.fetchTemplatesData,
|
||||
fetchCanvasSizesData: data.fetchCanvasSizesData,
|
||||
|
||||
+1
-2
@@ -22,10 +22,9 @@ async function fetchAdminData(pool) {
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [screens] = await pool.query(`
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name, pl.public_base_url
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
FROM d_screens s
|
||||
LEFT JOIN c_playlists p ON p.id = s.playlist_id
|
||||
LEFT JOIN d_players pl ON pl.device_id = s.player_id
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [playlistSlides] = await pool.query(`
|
||||
|
||||
+5
-1
@@ -7,7 +7,8 @@ const { fetchPlaylistById } = require('./playlists');
|
||||
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
|
||||
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens');
|
||||
const { fetchPlayerRegistrations } = require('./player-registry');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
|
||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||
@@ -61,7 +62,10 @@ module.exports = {
|
||||
fetchScreenById,
|
||||
fetchScreenEditData,
|
||||
fetchScreenPlayerUrls,
|
||||
fetchPlayerPublicBaseUrl,
|
||||
fetchScreenPlayerRecord,
|
||||
fetchPlayerRecordByIdentifier,
|
||||
fetchPlayerRegistrations,
|
||||
fetchTemplateById,
|
||||
fetchSlideById,
|
||||
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
|
||||
};
|
||||
+57
-16
@@ -13,6 +13,27 @@ function normalizePlayerBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function getConfiguredPlayerIdentifier() {
|
||||
return String(process.env.PLAYER_IDENTIFIER || process.env.PLAYER_DEVICE_ID || '').trim() || null;
|
||||
}
|
||||
|
||||
async function fetchPlayerRecordByIdentifier(pool, identifier) {
|
||||
const normalizedIdentifier = String(identifier || '').trim();
|
||||
if (!normalizedIdentifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, identifier, public_base_url, internal_base_url, last_seen_at
|
||||
FROM d_players
|
||||
WHERE identifier = ?
|
||||
LIMIT 1`,
|
||||
[normalizedIdentifier]
|
||||
);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
function buildScreenPlayerUrl(screen, fallbackBaseUrl) {
|
||||
const slug = String(screen && screen.slug || '').trim();
|
||||
if (!slug) {
|
||||
@@ -28,20 +49,21 @@ function buildScreenPlayerUrl(screen, fallbackBaseUrl) {
|
||||
}
|
||||
|
||||
async function fetchScreenPlayerUrls(pool) {
|
||||
const playerBaseUrl = await fetchPlayerPublicBaseUrl(pool);
|
||||
if (!playerBaseUrl) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(`
|
||||
SELECT s.slug, p.public_base_url
|
||||
FROM d_screens s
|
||||
LEFT JOIN d_players p ON p.device_id = s.player_id
|
||||
WHERE p.public_base_url IS NOT NULL
|
||||
AND TRIM(p.public_base_url) <> ''
|
||||
ORDER BY p.modified_at DESC, s.slug ASC
|
||||
SELECT slug
|
||||
FROM d_screens
|
||||
ORDER BY slug ASC
|
||||
`);
|
||||
|
||||
const playerUrls = {};
|
||||
rows.forEach(function (row) {
|
||||
const slug = String(row && row.slug || '').trim();
|
||||
const publicBaseUrl = normalizePlayerBaseUrl(row && row.public_base_url);
|
||||
const playerUrl = buildScreenPlayerUrl({ slug: slug }, publicBaseUrl);
|
||||
const playerUrl = buildScreenPlayerUrl({ slug: slug }, playerBaseUrl);
|
||||
if (slug && playerUrl && !playerUrls[slug]) {
|
||||
playerUrls[slug] = playerUrl;
|
||||
}
|
||||
@@ -50,17 +72,33 @@ async function fetchScreenPlayerUrls(pool) {
|
||||
return playerUrls;
|
||||
}
|
||||
|
||||
async function fetchScreenPlayerRecord(pool, slug) {
|
||||
async function fetchPlayerPublicBaseUrl(pool) {
|
||||
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
|
||||
const [rows] = await pool.query(`
|
||||
SELECT p.device_id, p.public_base_url, p.internal_base_url, p.last_seen_at
|
||||
FROM d_screens s
|
||||
JOIN d_players p ON p.device_id = s.player_id
|
||||
WHERE s.slug = ?
|
||||
ORDER BY p.modified_at DESC, p.device_id ASC
|
||||
SELECT public_base_url
|
||||
FROM d_players
|
||||
WHERE identifier = ?
|
||||
LIMIT 1
|
||||
`, [slug]);
|
||||
`, [configuredPlayerIdentifier]);
|
||||
|
||||
return rows[0] || null;
|
||||
return normalizePlayerBaseUrl(rows[0] && rows[0].public_base_url) || null;
|
||||
}
|
||||
|
||||
async function fetchScreenPlayerRecord(pool, slug) {
|
||||
if (slug) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT slug
|
||||
FROM d_screens
|
||||
WHERE slug = ?
|
||||
LIMIT 1`,
|
||||
[slug]
|
||||
);
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return fetchPlayerRecordByIdentifier(pool, getConfiguredPlayerIdentifier());
|
||||
}
|
||||
|
||||
async function uniqueScreenSlug(pool, baseSlug, excludeId) {
|
||||
@@ -101,8 +139,11 @@ async function fetchScreenEditData(pool) {
|
||||
module.exports = {
|
||||
slugify,
|
||||
normalizePlayerBaseUrl,
|
||||
getConfiguredPlayerIdentifier,
|
||||
fetchPlayerRecordByIdentifier,
|
||||
buildScreenPlayerUrl,
|
||||
fetchScreenPlayerUrls,
|
||||
fetchPlayerPublicBaseUrl,
|
||||
fetchScreenPlayerRecord,
|
||||
uniqueScreenSlug,
|
||||
fetchScreenById,
|
||||
|
||||
+34
-4
@@ -1,6 +1,18 @@
|
||||
// Snapshot only: keep this file aligned with the current schema state.
|
||||
async function ensureSchema(pool, options) {
|
||||
await pool.query(`
|
||||
const schemaLockName = 'pulse_signage_schema_lock';
|
||||
const schemaConnection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
pool = schemaConnection;
|
||||
|
||||
const [lockRows] = await pool.query('SELECT GET_LOCK(?, 120) AS lock_acquired', [schemaLockName]);
|
||||
if (!Number(lockRows && lockRows[0] && lockRows[0].lock_acquired)) {
|
||||
throw new Error('Unable to acquire the schema migration lock.');
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_canvas_sizes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
@@ -116,13 +128,24 @@ async function ensureSchema(pool, options) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_players (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
identifier VARCHAR(128) NOT NULL UNIQUE,
|
||||
public_base_url VARCHAR(512) NULL,
|
||||
internal_base_url VARCHAR(512) NULL,
|
||||
last_seen_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_screens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
playlist_id INT NULL,
|
||||
player_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -360,8 +383,15 @@ async function ensureSchema(pool, options) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
const { runMigrations } = require('./migrations');
|
||||
await runMigrations(pool, options);
|
||||
const { runMigrations } = require('./migrations');
|
||||
await runMigrations(pool, options);
|
||||
} finally {
|
||||
await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () {
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
schemaConnection.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
+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.
|
||||
// This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'.
|
||||
if (!(await columnExists(pool, 'd_screens', 'player_id'))) {
|
||||
@@ -212,6 +208,93 @@ const VERSIONED_MIGRATIONS = [
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.6.2',
|
||||
label: 'v2.6.2 player identity schema',
|
||||
run: async function (pool) {
|
||||
if (!(await columnExists(pool, 'd_players', 'device_id'))) {
|
||||
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
|
||||
|
||||
if (!(await columnExists(pool, 'd_players', 'identifier'))) {
|
||||
await ensureColumn(pool, 'd_players', 'identifier', 'VARCHAR(128) NOT NULL UNIQUE', 'id');
|
||||
}
|
||||
if (!(await columnExists(pool, 'd_players', 'id'))) {
|
||||
await ensureColumn(pool, 'd_players', 'id', 'INT NOT NULL AUTO_INCREMENT', null);
|
||||
await pool.query('ALTER TABLE d_players ADD PRIMARY KEY (id)');
|
||||
} else if (!(await columnIsAutoIncrement(pool, 'd_players', 'id'))) {
|
||||
await pool.query('ALTER TABLE d_players MODIFY COLUMN id INT NOT NULL AUTO_INCREMENT');
|
||||
}
|
||||
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id');
|
||||
return;
|
||||
}
|
||||
|
||||
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
|
||||
|
||||
await pool.query('DROP TABLE IF EXISTS d_players_rebuild');
|
||||
await pool.query(`
|
||||
CREATE TABLE d_players_rebuild (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
identifier VARCHAR(128) NOT NULL UNIQUE,
|
||||
public_base_url VARCHAR(512) NULL,
|
||||
internal_base_url VARCHAR(512) NULL,
|
||||
last_seen_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO d_players_rebuild (identifier, public_base_url, internal_base_url, last_seen_at, created_at, modified_at)
|
||||
SELECT DISTINCT
|
||||
device_id AS identifier,
|
||||
public_base_url,
|
||||
internal_base_url,
|
||||
last_seen_at,
|
||||
created_at,
|
||||
modified_at
|
||||
FROM d_players
|
||||
ORDER BY COALESCE(created_at, modified_at, device_id), device_id
|
||||
`);
|
||||
|
||||
await pool.query('DROP TEMPORARY TABLE IF EXISTS d_player_id_map');
|
||||
await pool.query(`
|
||||
CREATE TEMPORARY TABLE d_player_id_map AS
|
||||
SELECT old_players.device_id AS old_device_id, rebuilt_players.id AS new_player_id
|
||||
FROM d_players old_players
|
||||
JOIN d_players_rebuild rebuilt_players ON rebuilt_players.identifier = old_players.device_id
|
||||
`);
|
||||
|
||||
await pool.query(
|
||||
`UPDATE d_screens s
|
||||
JOIN d_player_id_map m ON m.old_device_id = CAST(s.player_id AS CHAR)
|
||||
SET s.player_id = m.new_player_id
|
||||
WHERE s.player_id IS NOT NULL`
|
||||
);
|
||||
|
||||
await pool.query('DROP TABLE d_players');
|
||||
await pool.query('RENAME TABLE d_players_rebuild TO d_players');
|
||||
|
||||
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.6.3',
|
||||
label: 'v2.6.3 screen-player fk removal',
|
||||
run: async function (pool) {
|
||||
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
|
||||
if (await columnExists(pool, 'd_screens', 'player_id')) {
|
||||
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.6.4',
|
||||
label: 'v2.6.4 drop screen player id',
|
||||
run: async function (pool) {
|
||||
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
|
||||
await dropColumnIfExists(pool, 'd_screens', 'player_id');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -228,6 +311,20 @@ async function columnExists(pool, tableName, columnName) {
|
||||
return Number(rows && rows[0] && rows[0].column_count) > 0;
|
||||
}
|
||||
|
||||
async function columnIsAutoIncrement(pool, tableName, columnName) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS auto_increment_count
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
AND COLUMN_NAME = ?
|
||||
AND EXTRA LIKE '%auto_increment%'`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
return Number(rows && rows[0] && rows[0].auto_increment_count) > 0;
|
||||
}
|
||||
|
||||
async function ensureColumn(pool, tableName, columnName, columnDefinition, afterColumn) {
|
||||
if (await columnExists(pool, tableName, columnName)) {
|
||||
return;
|
||||
@@ -289,6 +386,32 @@ async function ensureForeignKey(pool, tableName, constraintName, columnName, ref
|
||||
}
|
||||
}
|
||||
|
||||
async function dropForeignKeyIfExists(pool, tableName, columnName) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT CONSTRAINT_NAME AS constraint_name
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
AND COLUMN_NAME = ?
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
LIMIT 1`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
const constraintName = String(rows && rows[0] && rows[0].constraint_name || '').trim();
|
||||
if (!constraintName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' DROP FOREIGN KEY ' + constraintName);
|
||||
} catch (error) {
|
||||
if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dropColumnIfExists(pool, tableName, columnName) {
|
||||
if (await columnExists(pool, tableName, columnName)) {
|
||||
try {
|
||||
@@ -443,9 +566,20 @@ async function runMigrations(pool, options) {
|
||||
// Only run migrations that are newer than the installed schema version and not beyond the app version.
|
||||
const targetVersion = String(appVersion || '0.0.0').trim();
|
||||
const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
|
||||
const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id');
|
||||
const screenPlayerColumnPresent = await columnExists(pool, 'd_screens', 'player_id');
|
||||
let effectiveCurrentVersion = currentVersion;
|
||||
|
||||
if (!legacyPlayerSchemaPresent && compareVersions(effectiveCurrentVersion, '2.1.0') < 0) {
|
||||
effectiveCurrentVersion = '2.1.0';
|
||||
}
|
||||
|
||||
if (!screenPlayerColumnPresent && compareVersions(effectiveCurrentVersion, '2.6.3') < 0) {
|
||||
effectiveCurrentVersion = '2.6.3';
|
||||
}
|
||||
|
||||
for (const migration of VERSIONED_MIGRATIONS) {
|
||||
if (compareVersions(migration.version, currentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) {
|
||||
if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) {
|
||||
await migration.run(pool);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,925 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const common = require('../common');
|
||||
const { verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { normalizeDeviceId, upsertPlayerRegistration, recordPlayerHeartbeat } = require('#src/data/player-registry');
|
||||
const { createPlayerPlaylistService } = require('../player/playlist');
|
||||
const { buildThumbnailPreviewData } = require('../player/thumbnail-preview');
|
||||
const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerPublicBaseUrl } = require('../player/onboarding');
|
||||
const { createStyledQrCodeSvg } = require('../data/qr-code');
|
||||
const { verifyPageAuthToken } = require('#src/request-auth');
|
||||
|
||||
function createThinClientConfig() {
|
||||
return {
|
||||
port: Number(process.env.THIN_CLIENT_PORT || 8090),
|
||||
mediaDir: String(process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media')).trim()
|
||||
};
|
||||
}
|
||||
|
||||
function logBridge(message, details) {
|
||||
if (details === undefined) {
|
||||
console.info(`[player-bridge] ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(`[player-bridge] ${message}`, details);
|
||||
}
|
||||
|
||||
function normalizeRemoteAddress(value) {
|
||||
const address = String(value || '').trim();
|
||||
if (!address) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return address.toLowerCase().startsWith('::ffff:') ? address.slice(7) : address;
|
||||
}
|
||||
|
||||
function formatPlayerConnectionLabel(deviceId, remoteAddress) {
|
||||
const normalizedDeviceId = String(deviceId || '').trim() || 'unknown-player';
|
||||
const normalizedRemoteAddress = normalizeRemoteAddress(remoteAddress);
|
||||
return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : normalizedDeviceId;
|
||||
}
|
||||
|
||||
function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const deviceIds = screenPlayerDeviceIds.get(key);
|
||||
if (!Array.isArray(deviceIds) || !deviceIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(new Set(deviceIds.map(function (value) {
|
||||
return normalizeDeviceId(value);
|
||||
}).filter(Boolean))).map(function (deviceId) {
|
||||
const socket = playerSockets.get(deviceId);
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { deviceId: deviceId, socket: socket };
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveWebBaseUrl(req) {
|
||||
const configuredWebBaseUrl = String(process.env.WEB_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
if (configuredWebBaseUrl) {
|
||||
return configuredWebBaseUrl;
|
||||
}
|
||||
|
||||
const forwardedHost = String(req && req.headers && req.headers['x-forwarded-host'] || '').trim().split(',')[0];
|
||||
const host = forwardedHost || String(req && req.headers && req.headers.host || '').trim();
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
||||
const protocol = forwardedProto || (req && req.socket && req.socket.encrypted ? 'https' : 'http');
|
||||
|
||||
let url = null;
|
||||
try {
|
||||
url = new URL(`${protocol}://${host}`);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.port === '8090') {
|
||||
url.port = '8080';
|
||||
} else if (!url.port) {
|
||||
const forwardedPort = String(req && req.headers && req.headers['x-forwarded-port'] || '').trim().split(',')[0];
|
||||
if (forwardedPort) {
|
||||
url.port = forwardedPort === '8090' ? '8080' : forwardedPort;
|
||||
} else if (protocol === 'http') {
|
||||
url.port = '8080';
|
||||
}
|
||||
}
|
||||
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const pool = common.createPool();
|
||||
const config = createThinClientConfig();
|
||||
const playerPlaylistService = createPlayerPlaylistService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
snapshotDir: path.join(config.mediaDir, 'player-cache', 'screen-playlists')
|
||||
});
|
||||
app.use(express.json());
|
||||
const playersWs = new WebSocketServer({ noServer: true });
|
||||
const screenSnapshotsWs = new WebSocketServer({ noServer: true });
|
||||
const playerSockets = new Map();
|
||||
const screenSnapshotCache = new Map();
|
||||
const screenPlayerDeviceIds = new Map();
|
||||
const pendingPlayerCommands = new Map();
|
||||
|
||||
function normalizeProxyBaseUrl(value) {
|
||||
const normalized = String(value || '').trim().replace(/\/$/, '');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(normalized);
|
||||
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') {
|
||||
url.hostname = 'host.docker.internal';
|
||||
}
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch (_error) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlayerSnapshotRegistrations() {
|
||||
if (typeof common.fetchPlayerRegistrations === 'function') {
|
||||
return common.fetchPlayerRegistrations(pool);
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, modified_at
|
||||
FROM d_players
|
||||
ORDER BY modified_at DESC, identifier ASC`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getConnectedPlayerSocket() {
|
||||
for (const socket of playerSockets.values()) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
return socket;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectedPlayerCount() {
|
||||
return Array.from(playerSockets.values()).filter(function (socket) {
|
||||
return socket && socket.readyState === WebSocket.OPEN;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function getConnectedPlayerDeviceId() {
|
||||
const socket = getConnectedPlayerSocket();
|
||||
return socket && socket.playerDeviceId ? String(socket.playerDeviceId).trim() : '';
|
||||
}
|
||||
|
||||
function removeConnectedPlayerSocket(socket) {
|
||||
if (!socket || !socket.playerDeviceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = playerSockets.get(socket.playerDeviceId);
|
||||
if (current !== socket) {
|
||||
return false;
|
||||
}
|
||||
|
||||
playerSockets.delete(socket.playerDeviceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function logPlayerDisconnect(socket) {
|
||||
if (!socket || !socket.playerDeviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
logBridge(`Player ${formatPlayerConnectionLabel(socket.playerDeviceId, socket.bridgeRemoteAddress)} has disconnected`);
|
||||
}
|
||||
|
||||
function resolveMediaPath(fileName) {
|
||||
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
|
||||
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
function sendPlayerCommand(commandPayload) {
|
||||
const socket = getConnectedPlayerSocket();
|
||||
if (!socket) {
|
||||
return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' });
|
||||
}
|
||||
|
||||
return sendPlayerCommandToSocket(socket, commandPayload);
|
||||
}
|
||||
|
||||
function sendPlayerCommandToSocket(socket, commandPayload) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' });
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
const payload = Object.assign({
|
||||
type: 'command',
|
||||
requestId: requestId
|
||||
}, commandPayload || {});
|
||||
|
||||
return new Promise(function (resolve) {
|
||||
const timeout = setTimeout(function () {
|
||||
pendingPlayerCommands.delete(requestId);
|
||||
resolve({ ok: false, status: 504, error: 'Player command timed out.' });
|
||||
}, 10000);
|
||||
|
||||
pendingPlayerCommands.set(requestId, {
|
||||
resolve: function (message) {
|
||||
clearTimeout(timeout);
|
||||
pendingPlayerCommands.delete(requestId);
|
||||
resolve(message);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
socket.send(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
pendingPlayerCommands.delete(requestId);
|
||||
resolve({ ok: false, status: 502, error: error && error.message ? error.message : 'Unable to send player command.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getScreenPlayerDeviceIds(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
const deviceIds = screenPlayerDeviceIds.get(key);
|
||||
return Array.isArray(deviceIds) ? deviceIds.slice() : [];
|
||||
}
|
||||
|
||||
function storeScreenSnapshot(slug, connections, deviceIds) {
|
||||
const key = String(slug || '').trim();
|
||||
const normalizedConnections = Array.isArray(connections) ? connections : [];
|
||||
const normalizedDeviceIds = Array.isArray(deviceIds) ? deviceIds.map(function (value) {
|
||||
return normalizeDeviceId(value);
|
||||
}).filter(Boolean) : [];
|
||||
|
||||
screenSnapshotCache.set(key, {
|
||||
slug: key,
|
||||
count: normalizedConnections.length,
|
||||
connections: normalizedConnections
|
||||
});
|
||||
screenPlayerDeviceIds.set(key, Array.from(new Set(normalizedDeviceIds)));
|
||||
}
|
||||
|
||||
app.get('/health', function (_req, res) {
|
||||
res.json({ ok: true, service: 'player-bridge' });
|
||||
});
|
||||
|
||||
function requireRequestAuth(req, res, next) {
|
||||
if (!verifyRequestAuth(req)) {
|
||||
return res.status(401).json({ error: 'Request authentication required.' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
app.get('/api/players', requireRequestAuth, async function (_req, res, next) {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, created_at, modified_at
|
||||
FROM d_players
|
||||
ORDER BY modified_at DESC, identifier ASC`
|
||||
);
|
||||
res.json({ players: rows });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/players/connected-count', requireRequestAuth, function (_req, res) {
|
||||
res.json({ connectedPlayersCount: getConnectedPlayerCount() });
|
||||
});
|
||||
|
||||
app.get('/api/screens/:slug/connections', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const slug = String(req.params.slug || '').trim();
|
||||
if (!slug) {
|
||||
return res.status(400).json({ error: 'Screen slug is required.' });
|
||||
}
|
||||
|
||||
const snapshot = screenSnapshotCache.get(slug) || { slug: slug, count: 0, connections: [] };
|
||||
res.json({
|
||||
screenSlug: slug,
|
||||
count: Number(snapshot.count || 0),
|
||||
connections: Array.isArray(snapshot.connections) ? snapshot.connections : []
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
function requirePageAuth(allowedScopes) {
|
||||
return function (req, res, next) {
|
||||
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
|
||||
const payload = verifyPageAuthToken(token);
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: 'Page authentication required.' });
|
||||
}
|
||||
|
||||
const scopes = Array.isArray(allowedScopes) ? allowedScopes : [];
|
||||
if (scopes.length && scopes.indexOf(String(payload.scope || '').trim()) === -1) {
|
||||
return res.status(403).json({ error: 'Page authentication scope is not allowed for this route.' });
|
||||
}
|
||||
|
||||
req.playerPageAuth = payload;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
|
||||
res.json({
|
||||
mediaDir: config.mediaDir,
|
||||
uploadDir: path.join(config.mediaDir, 'uploads')
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const relativePath = resolveMediaPath(req.params.filename);
|
||||
if (!relativePath) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
|
||||
const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
||||
const response = await sendPlayerCommand({
|
||||
command: 'media-put',
|
||||
relativePath: relativePath,
|
||||
bodyBase64: bodyBuffer.toString('base64')
|
||||
});
|
||||
|
||||
res.status(response.status || (response.ok ? 200 : 502)).json(response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const relativePath = resolveMediaPath(req.params.filename);
|
||||
if (!relativePath) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
|
||||
const response = await sendPlayerCommand({
|
||||
command: 'media-delete',
|
||||
relativePath: relativePath
|
||||
});
|
||||
|
||||
res.status(response.status || (response.ok ? 200 : 502)).json(response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/screens/:slug/commands', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const slug = String(req.params.slug || '').trim();
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
|
||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||
? req.body.blackout
|
||||
: req.query.blackout;
|
||||
|
||||
if (!slug) {
|
||||
return res.status(400).json({ error: 'Screen slug is required.' });
|
||||
}
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command is required.' });
|
||||
}
|
||||
|
||||
const targetPlayers = resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds);
|
||||
|
||||
if (!targetPlayers.length) {
|
||||
return res.status(404).json({ error: 'Player is not connected.' });
|
||||
}
|
||||
|
||||
const payload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||
? Object.assign({}, req.body, { command: command, screenSlug: slug })
|
||||
: { command: command, screenSlug: slug };
|
||||
if (command === 'blackout' && blackoutValue !== undefined) {
|
||||
payload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
const results = await Promise.all(targetPlayers.map(async function (target) {
|
||||
const requestBody = Object.assign({}, payload, connectionId ? { connectionId: connectionId } : {});
|
||||
const response = await sendPlayerCommandToSocket(target.socket, requestBody);
|
||||
|
||||
return Object.assign({
|
||||
ok: Boolean(response && response.ok),
|
||||
status: response && response.status ? response.status : (response && response.ok ? 200 : 502),
|
||||
playerIdentifier: String(target.deviceId || '').trim()
|
||||
}, response && typeof response === 'object' ? response : {});
|
||||
}));
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
screenSlug: slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
sent: results.filter(function (result) { return result && result.ok; }).length,
|
||||
results: results
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/status', async function (req, res, next) {
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId();
|
||||
const status = await getOnboardingStatus(pool, deviceId);
|
||||
res.json({
|
||||
deviceId: normalizeDeviceId(deviceId),
|
||||
onboarded: Boolean(status && status.screen_id),
|
||||
clientName: status ? status.client_name : null,
|
||||
screenId: status ? status.screen_id : null,
|
||||
screenSlug: status ? status.screen_slug : null,
|
||||
screenName: status ? status.screen_name : null,
|
||||
playerUrl: status && status.screen_slug ? `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/screens', requirePageAuth(['onboarding', 'player']), async function (_req, res, next) {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
|
||||
res.json({ screens: rows });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/qr', async function (req, res, next) {
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId();
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
}
|
||||
const onboardingUrl = `${getPlayerPublicBaseUrl(req)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
|
||||
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
|
||||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.send(svg);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/onboarding', requirePageAuth(['onboarding', 'player']), express.json(), async function (req, res, next) {
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || getConnectedPlayerDeviceId();
|
||||
const clientName = String((req.body && req.body.clientName) || '').trim();
|
||||
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
|
||||
if (!deviceId) {
|
||||
return res.status(503).json({ error: 'Player is not connected.' });
|
||||
}
|
||||
if (!clientName) {
|
||||
return res.status(400).json({ error: 'Client name is required' });
|
||||
}
|
||||
if (!screenSlug) {
|
||||
return res.status(400).json({ error: 'Screen is required' });
|
||||
}
|
||||
|
||||
const status = await commitDeviceBinding(pool, deviceId, clientName, screenSlug, null, []);
|
||||
await bindPlayerToScreen(pool, deviceId, screenSlug);
|
||||
res.json({
|
||||
deviceId: deviceId,
|
||||
clientName: status ? status.client_name : clientName,
|
||||
screenId: status && status.screen_id ? status.screen_id : null,
|
||||
screenSlug: status ? status.screen_slug : screenSlug,
|
||||
screenName: status && status.screen_name ? status.screen_name : null,
|
||||
playerUrl: status && status.screen_slug ? `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPlayerPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`,
|
||||
queued: Boolean(status && status.queued)
|
||||
});
|
||||
} catch (error) {
|
||||
const statusCode = Number(error && error.statusCode || error && error.status || 500);
|
||||
res.status(Number.isFinite(statusCode) && statusCode >= 400 ? statusCode : 500).json({
|
||||
error: error && error.message ? error.message : 'Onboarding failed.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
|
||||
if (!data.screen) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
const etag = '"' + String(data.revision || '') + '"';
|
||||
res.set('ETag', etag);
|
||||
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).includes(etag)) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
const announcement = typeof common.fetchActiveAnnouncement === 'function'
|
||||
? await common.fetchActiveAnnouncement(pool, req.params.slug)
|
||||
: null;
|
||||
const revision = announcement
|
||||
? [announcement.id, announcement.modified_at || '', announcement.expires_at || '', announcement.enabled ? '1' : '0'].join(':')
|
||||
: 'none';
|
||||
const etag = '"' + String(revision || 'none') + '"';
|
||||
res.set('ETag', etag);
|
||||
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).includes(etag)) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
res.json({
|
||||
announcement: announcement,
|
||||
revision: revision
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
|
||||
const data = buildThumbnailPreviewData(slide);
|
||||
|
||||
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
|
||||
return Object.assign({}, feed, {
|
||||
items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
})
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
if (typeof common.fetchApiSourcesData === 'function') {
|
||||
const apiData = await common.fetchApiSourcesData(pool);
|
||||
data.apiSources = (apiData.apiSources || []).map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof common.fetchTimetablesData === 'function') {
|
||||
const timetableData = await common.fetchTimetablesData(pool);
|
||||
data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/players/:deviceId/commands', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.params.deviceId);
|
||||
const socket = playerSockets.get(deviceId);
|
||||
const payload = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return res.status(404).json({ ok: false, error: 'Player is not connected.' });
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(payload));
|
||||
res.json({ ok: true, deviceId: deviceId, sent: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
async function handlePlayerMessage(socket, rawMessage) {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(String(rawMessage || '{}'));
|
||||
} catch (_error) {
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Invalid JSON payload.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Invalid player payload.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(payload.type || '').trim() === 'command-response') {
|
||||
const requestId = String(payload.requestId || '').trim();
|
||||
if (requestId && pendingPlayerCommands.has(requestId)) {
|
||||
pendingPlayerCommands.get(requestId).resolve(payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const messageType = String(payload.type || '').trim().toLowerCase();
|
||||
const deviceId = normalizeDeviceId(payload.deviceId || payload.playerIdentifier || socket.playerDeviceId || '');
|
||||
|
||||
if (!deviceId) {
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Device ID is required.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
socket.playerDeviceId = deviceId;
|
||||
|
||||
if (messageType === 'register') {
|
||||
const player = await upsertPlayerRegistration(pool, {
|
||||
deviceId: deviceId,
|
||||
publicBaseUrl: payload.publicBaseUrl,
|
||||
internalBaseUrl: payload.internalBaseUrl
|
||||
});
|
||||
|
||||
playerSockets.set(deviceId, socket);
|
||||
logBridge(`Player ${formatPlayerConnectionLabel(deviceId, socket.bridgeRemoteAddress)} has connected`);
|
||||
socket.send(JSON.stringify({ type: 'registered', ok: true, player: player }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'heartbeat') {
|
||||
const player = await recordPlayerHeartbeat(pool, {
|
||||
deviceId: deviceId,
|
||||
publicBaseUrl: payload.publicBaseUrl,
|
||||
internalBaseUrl: payload.internalBaseUrl
|
||||
});
|
||||
|
||||
socket.send(JSON.stringify({ type: 'heartbeat-ack', ok: true, player: player }));
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Unsupported player message type.' }));
|
||||
}
|
||||
|
||||
server.on('upgrade', function (request, socket, head) {
|
||||
let pathname = '';
|
||||
try {
|
||||
pathname = new URL(request.url, 'http://localhost').pathname;
|
||||
} catch (_error) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname !== '/ws/players') {
|
||||
const screenMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
|
||||
if (!screenMatch) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verifyRequestAuth(request)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
screenSnapshotsWs.handleUpgrade(request, socket, head, function (ws) {
|
||||
screenSnapshotsWs.emit('connection', ws, request, decodeURIComponent(screenMatch[1]), 'screen-snapshots');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verifyRequestAuth(request)) {
|
||||
const remoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
|
||||
logBridge(remoteAddress ? `Player (ip ${remoteAddress}) denied with wrong shared secret` : 'Player denied with wrong shared secret');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
playersWs.handleUpgrade(request, socket, head, function (ws) {
|
||||
ws.bridgeRemoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
|
||||
playersWs.emit('connection', ws, request);
|
||||
});
|
||||
});
|
||||
|
||||
playersWs.on('connection', function (socket) {
|
||||
socket.send(JSON.stringify({ type: 'ready', channel: 'player-bridge' }));
|
||||
|
||||
socket.on('message', function (message) {
|
||||
handlePlayerMessage(socket, message).catch(function (error) {
|
||||
console.error(error);
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Player bridge request failed.' }));
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('close', function () {
|
||||
if (removeConnectedPlayerSocket(socket)) {
|
||||
logPlayerDisconnect(socket);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
if (removeConnectedPlayerSocket(socket)) {
|
||||
logPlayerDisconnect(socket);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
screenSnapshotsWs.on('connection', function (socket, request, slug) {
|
||||
const normalizedSlug = String(slug || '').trim();
|
||||
const upstreamSockets = new Map();
|
||||
const upstreamSnapshots = new Map();
|
||||
const upstreamDeviceIds = new Set();
|
||||
let refreshTimer = null;
|
||||
let closed = false;
|
||||
|
||||
function sendMergedSnapshot() {
|
||||
if (!normalizedSlug || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connections = [];
|
||||
upstreamSnapshots.forEach(function (payload) {
|
||||
if (payload && Array.isArray(payload.connections)) {
|
||||
connections.push.apply(connections, payload.connections);
|
||||
}
|
||||
});
|
||||
|
||||
storeScreenSnapshot(normalizedSlug, connections, Array.from(upstreamDeviceIds));
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'snapshot',
|
||||
slug: normalizedSlug,
|
||||
connections: connections,
|
||||
sentAt: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
function closeUpstreamSockets() {
|
||||
upstreamSockets.forEach(function (upstreamSocket) {
|
||||
try {
|
||||
upstreamSocket.close();
|
||||
} catch (_error) {
|
||||
}
|
||||
});
|
||||
upstreamSockets.clear();
|
||||
upstreamSnapshots.clear();
|
||||
}
|
||||
|
||||
async function refreshUpstreams() {
|
||||
if (closed || socket.readyState !== WebSocket.OPEN || !normalizedSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
let players = [];
|
||||
try {
|
||||
players = await fetchPlayerSnapshotRegistrations();
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seenKeys = new Set();
|
||||
players.forEach(function (player) {
|
||||
const baseUrl = normalizeProxyBaseUrl(player && player.public_base_url);
|
||||
if (!baseUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceKey = String(player && player.identifier || player && player.id || baseUrl);
|
||||
seenKeys.add(sourceKey);
|
||||
upstreamDeviceIds.add(sourceKey);
|
||||
if (upstreamSockets.has(sourceKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const upstreamUrl = new URL(baseUrl.replace(/^http/, 'ws'));
|
||||
upstreamUrl.pathname = `/ws/screens/${encodeURIComponent(normalizedSlug)}/events`;
|
||||
upstreamUrl.search = '';
|
||||
const upstreamHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/ws/screens/${encodeURIComponent(normalizedSlug)}/events`
|
||||
});
|
||||
const upstreamSocket = new WebSocket(upstreamUrl.toString(), { headers: upstreamHeaders });
|
||||
upstreamSockets.set(sourceKey, upstreamSocket);
|
||||
|
||||
upstreamSocket.onmessage = function (event) {
|
||||
try {
|
||||
const payload = JSON.parse(String(event.data || '{}'));
|
||||
if (!payload || payload.type !== 'snapshot' || String(payload.slug || '').trim() !== normalizedSlug) {
|
||||
return;
|
||||
}
|
||||
upstreamSnapshots.set(sourceKey, {
|
||||
slug: normalizedSlug,
|
||||
connections: Array.isArray(payload.connections) ? payload.connections : []
|
||||
});
|
||||
sendMergedSnapshot();
|
||||
} catch (_error) {
|
||||
}
|
||||
};
|
||||
|
||||
upstreamSocket.onclose = function () {
|
||||
upstreamSockets.delete(sourceKey);
|
||||
upstreamSnapshots.delete(sourceKey);
|
||||
if (!closed) {
|
||||
sendMergedSnapshot();
|
||||
}
|
||||
};
|
||||
|
||||
upstreamSocket.onerror = function () {
|
||||
try {
|
||||
upstreamSocket.close();
|
||||
} catch (_error) {
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Array.from(upstreamSockets.keys()).forEach(function (sourceKey) {
|
||||
if (!seenKeys.has(sourceKey)) {
|
||||
const upstreamSocket = upstreamSockets.get(sourceKey);
|
||||
upstreamSockets.delete(sourceKey);
|
||||
upstreamSnapshots.delete(sourceKey);
|
||||
upstreamDeviceIds.delete(sourceKey);
|
||||
try {
|
||||
upstreamSocket.close();
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sendMergedSnapshot();
|
||||
}
|
||||
|
||||
refreshUpstreams();
|
||||
refreshTimer = setInterval(function () {
|
||||
refreshUpstreams().catch(function (_error) {
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
socket.on('close', function () {
|
||||
closed = true;
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
closeUpstreamSockets();
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
closed = true;
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
closeUpstreamSockets();
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
|
||||
try {
|
||||
const webBaseUrl = resolveWebBaseUrl(_req);
|
||||
if (!webBaseUrl) {
|
||||
return res.status(502).json({ error: 'Web base URL is not configured.' });
|
||||
}
|
||||
|
||||
const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({
|
||||
Accept: 'application/json'
|
||||
}, createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: '/api/internal/sync/player-media'
|
||||
}))
|
||||
});
|
||||
|
||||
res.status(response.status);
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType) {
|
||||
res.type(contentType);
|
||||
}
|
||||
res.send(await response.text());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
fs.mkdirSync(config.mediaDir, { recursive: true });
|
||||
|
||||
server.listen(config.port, function () {
|
||||
console.log(`Player bridge listening on port ${config.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets };
|
||||
|
||||
if (require.main === module) {
|
||||
start().catch(function (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+322
-19
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { WebSocket } = require('ws');
|
||||
const common = require('./common');
|
||||
const { createPlayerRuntime } = require('./player/runtime');
|
||||
const { createPlayerPlaylistService } = require('./player/playlist');
|
||||
@@ -10,16 +11,20 @@ const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding }
|
||||
const { createOnboardingStore } = require('./player/onboarding/store');
|
||||
const { registerPlayerRoutes } = require('./player/routes');
|
||||
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/data/player-registry');
|
||||
|
||||
|
||||
// Player runtime, media API, and websocket wiring.
|
||||
async function start() {
|
||||
const app = express();
|
||||
const pool = common.createPool();
|
||||
const pool = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '') ? null : common.createPool();
|
||||
const PORT = Number(process.env.PLAYER_PORT || 8081);
|
||||
const PLAYER_PUBLIC_BASE_URL = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const PLAYER_INTERNAL_BASE_URL = String(process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const PLAYER_IDENTIFIER = '1';
|
||||
const THIN_CLIENT_BASE_URL = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const isRemotePlayer = Boolean(THIN_CLIENT_BASE_URL);
|
||||
const PLAYER_INTERNAL_BASE_URL = String(isRemotePlayer ? THIN_CLIENT_BASE_URL : (process.env.PLAYER_INTERNAL_BASE_URL || PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, '');
|
||||
const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier();
|
||||
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
||||
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
|
||||
@@ -29,24 +34,170 @@ async function start() {
|
||||
pool: pool,
|
||||
normalizeDeviceId: normalizeDeviceId
|
||||
});
|
||||
const playerPlaylistService = createPlayerPlaylistService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
|
||||
});
|
||||
const playerPlaylistService = isRemotePlayer
|
||||
? null
|
||||
: createPlayerPlaylistService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
|
||||
});
|
||||
const rtmpStreamService = createRtmpStreamService({
|
||||
mediaDir: MEDIA_DIR
|
||||
});
|
||||
const server = http.createServer(app);
|
||||
playerRuntime.installWebsocket(server);
|
||||
app.use(express.json());
|
||||
|
||||
let hasLoggedPlayerStartup = false;
|
||||
|
||||
function logPlayerStartup(connectionState) {
|
||||
if (hasLoggedPlayerStartup) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasLoggedPlayerStartup = true;
|
||||
console.info('[player] startup', {
|
||||
mode: isRemotePlayer ? 'bridge client' : 'local',
|
||||
connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false,
|
||||
publicBaseUrl: PLAYER_PUBLIC_BASE_URL || null,
|
||||
bridgeBaseUrl: PLAYER_INTERNAL_BASE_URL || null,
|
||||
bridgeWebSocketUrl: THIN_CLIENT_BASE_URL ? createThinClientWebSocketUrl() : null
|
||||
});
|
||||
}
|
||||
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
|
||||
function resolveLocalMediaFilePath(fileName) {
|
||||
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
|
||||
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedMediaDir = path.resolve(MEDIA_DIR);
|
||||
const resolvedFilePath = path.resolve(MEDIA_DIR, relativePath);
|
||||
if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedFilePath;
|
||||
}
|
||||
|
||||
async function triggerWebMediaSync() {
|
||||
if (!isRemotePlayer || !THIN_CLIENT_BASE_URL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: '/api/internal/sync/player-media'
|
||||
});
|
||||
const response = await fetch(`${THIN_CLIENT_BASE_URL}/api/internal/sync/player-media`, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({
|
||||
Accept: 'application/json'
|
||||
}, authHeaders)
|
||||
});
|
||||
return Boolean(response && response.ok);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let webMediaSyncCompleted = false;
|
||||
|
||||
async function handleThinClientCommand(socket, rawMessage) {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(String(rawMessage || ''));
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || String(payload.type || '').trim() !== 'command') {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = String(payload.requestId || '').trim() || null;
|
||||
const command = String(payload.command || '').trim().toLowerCase();
|
||||
const response = {
|
||||
type: 'command-response',
|
||||
requestId: requestId,
|
||||
ok: false
|
||||
};
|
||||
|
||||
try {
|
||||
if (command === 'media-put') {
|
||||
const relativePath = String(payload.relativePath || payload.filename || '').trim();
|
||||
const filePath = resolveLocalMediaFilePath(relativePath);
|
||||
const bodyBase64 = String(payload.bodyBase64 || '').trim();
|
||||
if (!filePath || !bodyBase64) {
|
||||
response.error = 'Invalid media payload.';
|
||||
} else {
|
||||
const bodyBuffer = Buffer.from(bodyBase64, 'base64');
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.promises.writeFile(filePath, bodyBuffer);
|
||||
response.ok = true;
|
||||
}
|
||||
} else if (command === 'media-delete') {
|
||||
const relativePath = String(payload.relativePath || payload.filename || '').trim();
|
||||
const filePath = resolveLocalMediaFilePath(relativePath);
|
||||
if (!filePath) {
|
||||
response.error = 'Invalid media path.';
|
||||
} else {
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
response.ok = true;
|
||||
}
|
||||
} else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) !== -1) {
|
||||
const screenSlug = String(payload.screenSlug || payload.slug || '').trim();
|
||||
if (!screenSlug) {
|
||||
response.error = 'Screen slug is required.';
|
||||
} else if (payload.connectionId) {
|
||||
const sent = await playerRuntime.sendCommandToConnection(screenSlug, String(payload.connectionId || '').trim(), payload);
|
||||
response.ok = sent > 0;
|
||||
if (!response.ok) {
|
||||
response.error = 'Player is not connected.';
|
||||
}
|
||||
} else {
|
||||
const sent = await playerRuntime.broadcastCommand(screenSlug, payload);
|
||||
response.ok = sent > 0;
|
||||
if (!response.ok) {
|
||||
response.error = 'Player is not connected.';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.error = 'Unsupported command.';
|
||||
}
|
||||
} catch (error) {
|
||||
response.error = error && error.message ? error.message : 'Command failed.';
|
||||
}
|
||||
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify(response));
|
||||
}
|
||||
}
|
||||
app.use(function (error, _req, res, _next) {
|
||||
console.error(error);
|
||||
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
|
||||
});
|
||||
|
||||
await ensureFontLibrary(MEDIA_DIR);
|
||||
|
||||
registerPlayerOnboardingRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerRuntime: playerRuntime,
|
||||
onboardingStore: onboardingStore,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
thinClientBaseUrl: THIN_CLIENT_BASE_URL,
|
||||
playerDeviceId: PLAYER_DEVICE_ID
|
||||
});
|
||||
registerPlayerRoutes(app, {
|
||||
pool: pool,
|
||||
@@ -58,24 +209,165 @@ async function start() {
|
||||
rtmpStreamService: rtmpStreamService,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
playerIdentifier: PLAYER_IDENTIFIER
|
||||
thinClientBaseUrl: THIN_CLIENT_BASE_URL,
|
||||
playerDeviceId: PLAYER_DEVICE_ID
|
||||
});
|
||||
|
||||
app.use(function (error, _req, res, _next) {
|
||||
console.error(error);
|
||||
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
|
||||
});
|
||||
function createThinClientWebSocketUrl() {
|
||||
if (!THIN_CLIENT_BASE_URL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
await ensureFontLibrary(MEDIA_DIR);
|
||||
return THIN_CLIENT_BASE_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players';
|
||||
}
|
||||
|
||||
function startThinClientRegistration() {
|
||||
const thinClientUrl = createThinClientWebSocketUrl();
|
||||
if (!thinClientUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let socket = null;
|
||||
let reconnectTimer = null;
|
||||
let heartbeatTimer = null;
|
||||
|
||||
function clearTimers() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
clearTimers();
|
||||
const timestamp = String(Date.now());
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: '/ws/players',
|
||||
timestamp: timestamp
|
||||
});
|
||||
let webMediaSyncTriggered = false;
|
||||
socket = new WebSocket(thinClientUrl, {
|
||||
headers: Object.assign({
|
||||
'x-pulse-request-timestamp': timestamp
|
||||
}, authHeaders)
|
||||
});
|
||||
|
||||
socket.on('open', function () {
|
||||
logPlayerStartup({
|
||||
connected: true
|
||||
});
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'register',
|
||||
deviceId: PLAYER_DEVICE_ID,
|
||||
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
||||
}));
|
||||
|
||||
if (!webMediaSyncCompleted) {
|
||||
triggerWebMediaSync().then(function (success) {
|
||||
webMediaSyncTriggered = Boolean(success);
|
||||
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
|
||||
}).catch(function () {
|
||||
webMediaSyncTriggered = false;
|
||||
});
|
||||
}
|
||||
|
||||
heartbeatTimer = setInterval(function () {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
socket.send(JSON.stringify({
|
||||
type: 'heartbeat',
|
||||
deviceId: PLAYER_DEVICE_ID,
|
||||
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
||||
}));
|
||||
|
||||
if (!webMediaSyncTriggered && !webMediaSyncCompleted) {
|
||||
triggerWebMediaSync().then(function (success) {
|
||||
webMediaSyncTriggered = Boolean(success);
|
||||
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
|
||||
}).catch(function () {
|
||||
webMediaSyncTriggered = false;
|
||||
});
|
||||
}
|
||||
}, DB_SYNC_INTERVAL_MS);
|
||||
});
|
||||
|
||||
socket.on('message', function (rawMessage) {
|
||||
handleThinClientCommand(socket, rawMessage).catch(function (error) {
|
||||
try {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'command-response',
|
||||
requestId: null,
|
||||
ok: false,
|
||||
error: error && error.message ? error.message : 'Command failed.'
|
||||
}));
|
||||
} catch (_sendError) {
|
||||
// ignore send errors
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('close', function () {
|
||||
clearTimers();
|
||||
reconnectTimer = setTimeout(connect, 5000);
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore reconnect noise
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
return function stop() {
|
||||
clearTimers();
|
||||
if (socket) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore close errors
|
||||
}
|
||||
socket = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRemotePlayer) {
|
||||
logPlayerStartup({
|
||||
connected: false
|
||||
});
|
||||
}
|
||||
|
||||
const stopThinClientRegistration = startThinClientRegistration();
|
||||
|
||||
server.listen(PORT, function () {
|
||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||
});
|
||||
|
||||
async function syncDatabaseState() {
|
||||
if (isRemotePlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||
await recordPlayerHeartbeat(pool, {
|
||||
deviceId: PLAYER_DEVICE_ID,
|
||||
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
||||
}).catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
if (playerRuntime.snapshotAllConnections().length > 0) {
|
||||
await common.pruneStaleOnboardingDevices(pool);
|
||||
@@ -98,16 +390,27 @@ async function start() {
|
||||
|
||||
await syncDatabaseState();
|
||||
|
||||
if (PLAYER_IDENTIFIER) {
|
||||
if (PLAYER_DEVICE_ID && !isRemotePlayer) {
|
||||
const { upsertPlayerRegistration } = require('./player/onboarding');
|
||||
await upsertPlayerRegistration(pool, PLAYER_IDENTIFIER, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL, null);
|
||||
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL).catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
if (isRemotePlayer) {
|
||||
return;
|
||||
}
|
||||
syncDatabaseState().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, DB_SYNC_INTERVAL_MS);
|
||||
|
||||
process.on('exit', function () {
|
||||
if (typeof stopThinClientRegistration === 'function') {
|
||||
stopThinClientRegistration();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { start };
|
||||
|
||||
+130
-44
@@ -1,8 +1,10 @@
|
||||
// Player onboarding routes and signup flow helpers.
|
||||
|
||||
const express = require('express');
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
|
||||
const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth');
|
||||
const { getSharedSecret, verifyPageAuthToken, createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry');
|
||||
const { isTransientDbError } = require('./store');
|
||||
|
||||
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||
@@ -133,7 +135,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl, screenSlug) {
|
||||
async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl) {
|
||||
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
||||
const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, '');
|
||||
const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, '');
|
||||
@@ -141,18 +143,11 @@ async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalB
|
||||
return null;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO d_players (device_id, public_base_url, internal_base_url, last_seen_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE public_base_url = VALUES(public_base_url), internal_base_url = VALUES(internal_base_url), last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP`,
|
||||
[normalizedDeviceId, normalizedPublicBaseUrl || null, normalizedInternalBaseUrl || null]
|
||||
);
|
||||
|
||||
return {
|
||||
device_id: normalizedDeviceId,
|
||||
public_base_url: normalizedPublicBaseUrl || null,
|
||||
internal_base_url: normalizedInternalBaseUrl || null
|
||||
};
|
||||
return upsertPlayerRegistrationRecord(pool, {
|
||||
identifier: normalizedDeviceId,
|
||||
publicBaseUrl: normalizedPublicBaseUrl || null,
|
||||
internalBaseUrl: normalizedInternalBaseUrl || null
|
||||
});
|
||||
}
|
||||
|
||||
async function bindPlayerToScreen(pool, deviceId, screenSlug) {
|
||||
@@ -167,25 +162,9 @@ async function bindPlayerToScreen(pool, deviceId, screenSlug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const screenId = Number(screenRows[0].id);
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
// Current model: every screen binds to the shared player row '1'.
|
||||
// If we introduce multiple players, resolve the correct player row here instead of hardcoding it.
|
||||
await connection.query("UPDATE d_screens SET player_id = '1', modified_at = CURRENT_TIMESTAMP WHERE id = ?", [screenId]);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
|
||||
return {
|
||||
screen_id: screenId,
|
||||
screen_slug: normalizedScreenSlug,
|
||||
player_id: '1'
|
||||
screen_id: Number(screenRows[0].id),
|
||||
screen_slug: normalizedScreenSlug
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,13 +203,66 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
const common = options && options.common ? options.common : null;
|
||||
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
|
||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
|
||||
|
||||
if (!app || !pool || !common || !playerRuntime) {
|
||||
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, and playerRuntime.');
|
||||
if (!app || !common) {
|
||||
throw new Error('registerPlayerOnboardingRoutes requires app and common.');
|
||||
}
|
||||
|
||||
if (!thinClientBaseUrl && (!pool || !playerRuntime)) {
|
||||
throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless thinClientBaseUrl is configured.');
|
||||
}
|
||||
|
||||
const sharedSecret = getSharedSecret();
|
||||
|
||||
async function fetchThinClient(req, pathname, options) {
|
||||
if (!thinClientBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestOptions = options && typeof options === 'object' ? options : {};
|
||||
const method = String(requestOptions.method || req.method || 'GET').trim().toUpperCase();
|
||||
const body = Object.prototype.hasOwnProperty.call(requestOptions, 'body') ? requestOptions.body : undefined;
|
||||
const requestPathname = String(pathname || '').split('?')[0];
|
||||
const headers = Object.assign({}, requestOptions.headers || {}, createRequestAuthHeaders({
|
||||
method: method,
|
||||
pathname: requestPathname,
|
||||
body: body
|
||||
}));
|
||||
|
||||
if (req.headers['x-pulse-page-auth']) {
|
||||
headers['x-pulse-page-auth'] = String(req.headers['x-pulse-page-auth']).trim();
|
||||
}
|
||||
if (requestOptions.contentType) {
|
||||
headers['content-type'] = requestOptions.contentType;
|
||||
}
|
||||
|
||||
return fetch(new URL(pathname, thinClientBaseUrl).toString(), {
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonResponse(response) {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = String(response.headers && typeof response.headers.get === 'function' ? response.headers.get('content-type') : '').toLowerCase();
|
||||
if (contentType.indexOf('application/json') === -1 && contentType.indexOf('+json') === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requireOnboardingPageAuth(req, res, next) {
|
||||
if (!sharedSecret) {
|
||||
return next();
|
||||
@@ -254,7 +286,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
app.get('/onboard', async function (req, res, next) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
try {
|
||||
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
|
||||
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || playerDeviceId || '').trim()));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -272,15 +304,32 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
next();
|
||||
}, async function (req, res, next) {
|
||||
try {
|
||||
const status = await getOnboardingStatus(pool, req.query.deviceId);
|
||||
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
|
||||
if (thinClientBaseUrl) {
|
||||
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response) {
|
||||
return res.status(502).json({ error: 'Player bridge unavailable.' });
|
||||
}
|
||||
res.status(response.status);
|
||||
const payload = await readJsonResponse(response);
|
||||
if (!payload) {
|
||||
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
|
||||
}
|
||||
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null;
|
||||
return res.json(payload);
|
||||
}
|
||||
|
||||
const status = await getOnboardingStatus(pool, deviceId);
|
||||
res.json({
|
||||
deviceId: normalizeDeviceId(req.query.deviceId),
|
||||
deviceId: normalizeDeviceId(deviceId),
|
||||
onboarded: Boolean(status && status.screen_id),
|
||||
clientName: status ? status.client_name : null,
|
||||
screenId: status ? status.screen_id : null,
|
||||
screenSlug: status ? status.screen_slug : null,
|
||||
screenName: status ? status.screen_name : null,
|
||||
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null
|
||||
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -289,6 +338,16 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
|
||||
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
|
||||
try {
|
||||
if (thinClientBaseUrl) {
|
||||
const response = await fetchThinClient(_req, '/api/onboarding/screens', {
|
||||
method: 'GET'
|
||||
});
|
||||
res.status(response.status);
|
||||
const text = await response.text();
|
||||
res.type(response.headers.get('content-type') || 'application/json');
|
||||
return res.send(text);
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
|
||||
res.json({ screens: rows });
|
||||
} catch (error) {
|
||||
@@ -298,11 +357,11 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
|
||||
app.get('/api/onboarding/qr', async function (req, res, next) {
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.query.deviceId);
|
||||
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
}
|
||||
const onboardingUrl = `${getPublicBaseUrl(req, options && options.playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
|
||||
const onboardingUrl = `${getPublicBaseUrl(req, playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
|
||||
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
|
||||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
@@ -312,9 +371,9 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/onboarding', requireOnboardingPageAuth, async function (req, res, next) {
|
||||
app.post('/api/onboarding', requireOnboardingPageAuth, express.json(), async function (req, res, next) {
|
||||
try {
|
||||
const deviceId = normalizeDeviceId(req.body && req.body.deviceId);
|
||||
const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || playerDeviceId;
|
||||
const clientName = String((req.body && req.body.clientName) || '').trim();
|
||||
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
|
||||
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
|
||||
@@ -322,6 +381,30 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
res.set('Retry-After', String(retryAfterSeconds));
|
||||
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
|
||||
}
|
||||
|
||||
if (thinClientBaseUrl) {
|
||||
const forwardedBody = Object.assign({}, req.body || {}, {
|
||||
deviceId: deviceId
|
||||
});
|
||||
const response = await fetch(new URL('/api/onboarding', thinClientBaseUrl).toString(), {
|
||||
method: 'POST',
|
||||
headers: Object.assign({
|
||||
'content-type': 'application/json'
|
||||
}, createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: '/api/onboarding',
|
||||
body: forwardedBody
|
||||
}), req.headers['x-pulse-page-auth'] ? { 'x-pulse-page-auth': String(req.headers['x-pulse-page-auth']).trim() } : {}),
|
||||
body: JSON.stringify(forwardedBody)
|
||||
});
|
||||
res.status(response.status);
|
||||
const payload = await readJsonResponse(response);
|
||||
if (!payload) {
|
||||
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
|
||||
}
|
||||
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`;
|
||||
return res.json(payload);
|
||||
}
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
}
|
||||
@@ -340,11 +423,14 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
screenId: status && status.screen_id ? status.screen_id : null,
|
||||
screenSlug: status ? status.screen_slug : screenSlug,
|
||||
screenName: status && status.screen_name ? status.screen_name : null,
|
||||
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`,
|
||||
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`,
|
||||
queued: Boolean(status && status.queued)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
const statusCode = Number(error && error.statusCode || error && error.status || 500);
|
||||
res.status(Number.isFinite(statusCode) && statusCode >= 400 ? statusCode : 500).json({
|
||||
error: error && error.message ? error.message : 'Onboarding failed.'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,8 +39,14 @@
|
||||
return screens;
|
||||
});
|
||||
}
|
||||
if (!deviceId) { setMessage("Missing device id. Scan the QR code from the player screen again."); return; }
|
||||
try { window.localStorage.setItem(deviceKey, deviceId); } catch (_error) {}
|
||||
try {
|
||||
if (!deviceId) {
|
||||
deviceId = window.localStorage.getItem(deviceKey) || "";
|
||||
}
|
||||
if (deviceId) {
|
||||
window.localStorage.setItem(deviceKey, deviceId);
|
||||
}
|
||||
} catch (_error) {}
|
||||
loadScreens().then(function () {
|
||||
try {
|
||||
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
|
||||
@@ -58,10 +64,14 @@
|
||||
if (!clientName) { setMessage("Client name is required."); return; }
|
||||
if (!screenSlug) { setMessage("Screen is required."); return; }
|
||||
setMessage("Saving client...");
|
||||
var payload = { clientName: clientName, screenSlug: screenSlug };
|
||||
if (deviceId) {
|
||||
payload.deviceId = deviceId;
|
||||
}
|
||||
fetch("/api/onboarding", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(function (response) {
|
||||
if (response.ok) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
var localForm = document.getElementById("onboarding-local-form");
|
||||
var localMessage = document.getElementById("onboarding-message");
|
||||
var localScreenSelect = document.getElementById("onboarding-screen-select");
|
||||
var qrPlaceholderSrc = "data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E";
|
||||
function parseResponseError(response) {
|
||||
return response.text().then(function (text) {
|
||||
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
|
||||
@@ -60,7 +61,11 @@
|
||||
.catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; });
|
||||
}
|
||||
function loadQr(deviceId) {
|
||||
if (qr) { qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); }
|
||||
if (!qr) { return; }
|
||||
qr.onerror = function () {
|
||||
qr.src = qrPlaceholderSrc;
|
||||
};
|
||||
qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId);
|
||||
}
|
||||
function submitOnboarding(deviceId, clientName, screenSlug) {
|
||||
return fetch("/api/onboarding", {
|
||||
@@ -126,13 +131,16 @@
|
||||
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
|
||||
if (storedClientName && localForm) {
|
||||
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
|
||||
if (clientNameInput && !clientNameInput.value) { clientNameInput.value = storedClientName; }
|
||||
if (clientNameInput) { clientNameInput.value = storedClientName; }
|
||||
}
|
||||
if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; }
|
||||
} catch (_error) {}
|
||||
});
|
||||
redirectIfOnboarded(deviceId).then(function (redirected) {
|
||||
if (redirected) { return; }
|
||||
if (qr && !qr.getAttribute("src")) {
|
||||
qr.src = qrPlaceholderSrc;
|
||||
}
|
||||
loadQr(deviceId);
|
||||
setStatus("Waiting for onboarding to finish.");
|
||||
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
|
||||
|
||||
@@ -144,11 +144,14 @@ function createPlayerPlaylistService(options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) {
|
||||
const videoRegions = Object.keys(parsed).map(function (key) { return parsed[key]; }).filter(function (region) {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||
});
|
||||
|
||||
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||
const duration = videoRegions.reduce(function (longest, region) {
|
||||
const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000;
|
||||
return regionDuration > longest ? regionDuration : longest;
|
||||
}, 0);
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
|
||||
@@ -263,20 +263,33 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
});
|
||||
}
|
||||
|
||||
function schedulePostRenderSetup(root, delayMs) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.isConnected === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(root);
|
||||
}
|
||||
|
||||
if (!isThumbnailPreview()) {
|
||||
initializeRegionInstances(root);
|
||||
}
|
||||
|
||||
initializeRenderedVideoPlayback(root, delayMs);
|
||||
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
playRegionAnimations(root, 'intro');
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldFade) {
|
||||
app.innerHTML = markup;
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(app);
|
||||
}
|
||||
if (!isThumbnailPreview()) {
|
||||
initializeRegionInstances(app);
|
||||
}
|
||||
initializeRenderedVideoPlayback(app);
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
window.requestAnimationFrame(function () {
|
||||
playRegionAnimations(app, 'intro');
|
||||
});
|
||||
}
|
||||
schedulePostRenderSetup(app, 0);
|
||||
return app.firstElementChild;
|
||||
}
|
||||
|
||||
@@ -325,11 +338,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
app.innerHTML = '';
|
||||
nextShell.style.opacity = '1';
|
||||
app.appendChild(nextShell);
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(nextShell);
|
||||
}
|
||||
initializeRegionInstances(nextShell);
|
||||
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
||||
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
|
||||
return nextShell;
|
||||
}
|
||||
|
||||
@@ -340,24 +349,12 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
|
||||
|
||||
app.appendChild(nextShell);
|
||||
void nextShell.offsetHeight;
|
||||
window.requestAnimationFrame(function () {
|
||||
nextShell.style.opacity = '1';
|
||||
previousShell.style.opacity = '0';
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
playRegionAnimations(nextShell, 'intro');
|
||||
}
|
||||
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
|
||||
});
|
||||
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(nextShell);
|
||||
}
|
||||
|
||||
if (!isThumbnailPreview()) {
|
||||
initializeRegionInstances(nextShell);
|
||||
}
|
||||
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
||||
|
||||
slideTransitionTimer = window.setTimeout(function () {
|
||||
if (previousShell && previousShell.parentNode) {
|
||||
previousShell.parentNode.removeChild(previousShell);
|
||||
|
||||
@@ -54,6 +54,9 @@ async function renderSlideAtIndex(sourceSlides, targetIndex) {
|
||||
index = currentIndex;
|
||||
var markup = buildSlideMarkup(slide);
|
||||
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
|
||||
if (typeof scheduleSlideMarkupPreload === 'function') {
|
||||
scheduleSlideMarkupPreload(availableSlides, currentIndex);
|
||||
}
|
||||
sendCommandState(slide);
|
||||
if (!isPaused) {
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
|
||||
@@ -90,6 +93,9 @@ function showCurrent() {
|
||||
if (typeof syncRtmpWarmups === 'function') {
|
||||
syncRtmpWarmups(activeSlides, index);
|
||||
}
|
||||
if (typeof scheduleSlideMarkupPreload === 'function') {
|
||||
scheduleSlideMarkupPreload(activeSlides, index);
|
||||
}
|
||||
if (!activeSlides.length) {
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
@@ -211,6 +217,9 @@ function refresh() {
|
||||
if (typeof syncRtmpWarmups === 'function') {
|
||||
syncRtmpWarmups(nextActiveSlides, index);
|
||||
}
|
||||
if (typeof scheduleSlideMarkupPreload === 'function') {
|
||||
scheduleSlideMarkupPreload(nextActiveSlides, index);
|
||||
}
|
||||
if (nextActiveSlides.length < 2) {
|
||||
pendingPlaylistUpdate = {
|
||||
slides: nextSlides,
|
||||
|
||||
@@ -84,7 +84,7 @@ function getCurrentRenderKey(activeSlides) {
|
||||
return [currentPlaylistSignature || '', viewportKey, 'slide', slide && slide.id ? slide.id : ''].join('|');
|
||||
}
|
||||
|
||||
// Pick the current slide and the next slide for webpage preloading.
|
||||
// Pick the next slide for webpage preloading.
|
||||
function getWebpagePreloadSlides(sourceSlides, targetIndex) {
|
||||
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||
if (!availableSlides.length) {
|
||||
@@ -97,19 +97,71 @@ function getWebpagePreloadSlides(sourceSlides, targetIndex) {
|
||||
}
|
||||
|
||||
const preloadSlides = [];
|
||||
const currentSlide = availableSlides[normalizedIndex];
|
||||
const nextSlide = availableSlides[normalizedIndex + 1];
|
||||
|
||||
if (currentSlide) {
|
||||
preloadSlides.push(currentSlide);
|
||||
}
|
||||
if (nextSlide && nextSlide !== currentSlide) {
|
||||
if (nextSlide) {
|
||||
preloadSlides.push(nextSlide);
|
||||
}
|
||||
|
||||
return preloadSlides;
|
||||
}
|
||||
|
||||
// Pick the next slide to warm its markup before it becomes visible.
|
||||
function getSlideMarkupPreloadSlides(sourceSlides, targetIndex) {
|
||||
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||
if (!availableSlides.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let normalizedIndex = Number(targetIndex || 0);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||
normalizedIndex = 0;
|
||||
}
|
||||
|
||||
const preloadSlides = [];
|
||||
const nextSlide = availableSlides[normalizedIndex + 1];
|
||||
|
||||
if (nextSlide) {
|
||||
preloadSlides.push(nextSlide);
|
||||
}
|
||||
|
||||
return preloadSlides;
|
||||
}
|
||||
|
||||
function scheduleSlideMarkupPreload(sourceSlides, targetIndex) {
|
||||
if (window.__pulseThumbnailPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preloadSlides = getSlideMarkupPreloadSlides(sourceSlides, targetIndex);
|
||||
if (!preloadSlides.length || typeof primeSlideMarkup !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const slide = preloadSlides[0];
|
||||
const preloadSignature = [
|
||||
currentPlaylistSignature || '',
|
||||
slide && slide.id ? slide.id : '',
|
||||
slide && slide.template_id ? slide.template_id : '',
|
||||
slide && slide.modified_at ? slide.modified_at : '',
|
||||
window.innerWidth + 'x' + window.innerHeight,
|
||||
videoRegionRenderVersion || 0
|
||||
].join('|');
|
||||
|
||||
if (scheduleSlideMarkupPreload.signature === preloadSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleSlideMarkupPreload.signature = preloadSignature;
|
||||
|
||||
window.setTimeout(function () {
|
||||
if (scheduleSlideMarkupPreload.signature !== preloadSignature) {
|
||||
return;
|
||||
}
|
||||
primeSlideMarkup(slide);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Mount hidden iframe preloads for the chosen webpage URLs.
|
||||
function syncWebpagePreloads(sourceSlides, targetIndex) {
|
||||
const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex));
|
||||
|
||||
@@ -893,6 +893,45 @@ function getSlideMarkupCacheKey(slide) {
|
||||
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
|
||||
}
|
||||
|
||||
function restorePlayerCanvasDimensions(width, height) {
|
||||
if (!document || !document.documentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
var style = document.documentElement.style;
|
||||
if (width) {
|
||||
style.setProperty('--player-canvas-width', width);
|
||||
} else {
|
||||
style.removeProperty('--player-canvas-width');
|
||||
}
|
||||
|
||||
if (height) {
|
||||
style.setProperty('--player-canvas-height', height);
|
||||
} else {
|
||||
style.removeProperty('--player-canvas-height');
|
||||
}
|
||||
}
|
||||
|
||||
function primeSlideMarkup(slide) {
|
||||
if (!slide) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var cachedMarkup = getCachedSlideMarkup(slide);
|
||||
if (cachedMarkup) {
|
||||
return cachedMarkup;
|
||||
}
|
||||
|
||||
var previousWidth = document && document.documentElement && document.documentElement.style ? String(document.documentElement.style.getPropertyValue('--player-canvas-width') || '') : '';
|
||||
var previousHeight = document && document.documentElement && document.documentElement.style ? String(document.documentElement.style.getPropertyValue('--player-canvas-height') || '') : '';
|
||||
|
||||
try {
|
||||
return buildSlideMarkupForState(slide, false);
|
||||
} finally {
|
||||
restorePlayerCanvasDimensions(previousWidth.trim(), previousHeight.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function notifyVideoRegionSourceReady() {
|
||||
if (typeof videoRegionRenderVersion === 'number') {
|
||||
videoRegionRenderVersion += 1;
|
||||
@@ -919,9 +958,12 @@ function setCachedSlideMarkup(slide, markup) {
|
||||
|
||||
// Slide rendering and markup cache helpers.
|
||||
// Choose the right slide renderer and cache the result.
|
||||
function buildSlideMarkup(slide) {
|
||||
lastRenderedSlide = slide || null;
|
||||
syncBlackoutState();
|
||||
function buildSlideMarkupForState(slide, updateCurrentState) {
|
||||
if (updateCurrentState) {
|
||||
lastRenderedSlide = slide || null;
|
||||
syncBlackoutState();
|
||||
}
|
||||
|
||||
var cachedMarkup = getCachedSlideMarkup(slide);
|
||||
if (cachedMarkup) {
|
||||
return cachedMarkup;
|
||||
@@ -938,3 +980,7 @@ function buildSlideMarkup(slide) {
|
||||
setCachedSlideMarkup(slide, markup);
|
||||
return markup;
|
||||
}
|
||||
|
||||
function buildSlideMarkup(slide) {
|
||||
return buildSlideMarkupForState(slide, true);
|
||||
}
|
||||
|
||||
@@ -304,13 +304,9 @@ function getRtmpWarmupSlides(sourceSlides, targetIndex) {
|
||||
}
|
||||
|
||||
var warmupSlides = [];
|
||||
var currentSlide = availableSlides[normalizedIndex];
|
||||
var nextSlide = availableSlides[normalizedIndex + 1];
|
||||
|
||||
if (currentSlide) {
|
||||
warmupSlides.push(currentSlide);
|
||||
}
|
||||
if (nextSlide && nextSlide !== currentSlide) {
|
||||
if (nextSlide) {
|
||||
warmupSlides.push(nextSlide);
|
||||
}
|
||||
|
||||
@@ -693,12 +689,11 @@ function startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, plac
|
||||
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||
var hls = new window.Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 4,
|
||||
liveMaxLatencyDurationCount: 8,
|
||||
maxBufferLength: 20,
|
||||
liveSyncDurationCount: 6,
|
||||
liveMaxLatencyDurationCount: 12,
|
||||
maxBufferLength: 30,
|
||||
maxLiveSyncPlaybackRate: 1,
|
||||
backBufferLength: 30
|
||||
backBufferLength: 60
|
||||
});
|
||||
video.__rtmpHls = hls;
|
||||
hls.attachMedia(video);
|
||||
|
||||
@@ -122,7 +122,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
setVideoSourceAvailability(requestedSrc, true);
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
var requestedState = getVideoSourceAvailability(requestedSrc);
|
||||
@@ -132,7 +132,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
if (regionKey) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
|
||||
@@ -141,7 +141,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
if (cachedSrc !== requestedSrc) {
|
||||
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
@@ -46,7 +46,7 @@ function renderOnboardingLandingBody() {
|
||||
' <div class="onboarding-layout">',
|
||||
' <div class="onboarding-qr-pane">',
|
||||
' <div class="onboarding-qr-frame">',
|
||||
' <img id="onboarding-qr" alt="Onboarding QR code" />',
|
||||
' <img id="onboarding-qr" alt="Onboarding QR code" src="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E" />',
|
||||
' </div>',
|
||||
' <div id="onboarding-status" class="onboarding-status">Preparing onboarding link...</div>',
|
||||
' </div>',
|
||||
|
||||
+186
-14
@@ -3,7 +3,7 @@
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
||||
|
||||
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
||||
@@ -12,6 +12,17 @@ function isTransientDbError(error) {
|
||||
return Boolean(error && TRANSIENT_DB_ERROR_CODES.indexOf(String(error.code || '').trim()) !== -1);
|
||||
}
|
||||
|
||||
function isBridgeFetchError(error) {
|
||||
const message = String(error && error.message || '').toLowerCase();
|
||||
return Boolean(error && (
|
||||
message.indexOf('fetch failed') !== -1 ||
|
||||
message.indexOf('network error') !== -1 ||
|
||||
message.indexOf('econnreset') !== -1 ||
|
||||
message.indexOf('econnrefused') !== -1 ||
|
||||
message.indexOf('enotfound') !== -1
|
||||
));
|
||||
}
|
||||
|
||||
function registerPlayerRoutes(app, options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const common = options && options.common ? options.common : null;
|
||||
@@ -22,14 +33,68 @@ function registerPlayerRoutes(app, options) {
|
||||
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
|
||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const playerIdentifier = String(options && options.playerIdentifier || '1').trim() || '1';
|
||||
const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
|
||||
|
||||
if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) {
|
||||
throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and rtmpStreamService.');
|
||||
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
|
||||
throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.');
|
||||
}
|
||||
|
||||
if (!thinClientBaseUrl && (!pool || !playerPlaylistService)) {
|
||||
throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless thinClientBaseUrl is configured.');
|
||||
}
|
||||
|
||||
const sharedSecret = getSharedSecret();
|
||||
|
||||
async function fetchThinClient(req, pathname, options) {
|
||||
if (!thinClientBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestOptions = options && typeof options === 'object' ? options : {};
|
||||
const method = String(requestOptions.method || req.method || 'GET').trim().toUpperCase();
|
||||
const body = Object.prototype.hasOwnProperty.call(requestOptions, 'body') ? requestOptions.body : undefined;
|
||||
const requestPathname = String(pathname || '').split('?')[0];
|
||||
const headers = Object.assign({}, requestOptions.headers || {}, createRequestAuthHeaders({
|
||||
method: method,
|
||||
pathname: requestPathname,
|
||||
body: body
|
||||
}));
|
||||
|
||||
if (req.headers['x-pulse-page-auth']) {
|
||||
headers['x-pulse-page-auth'] = String(req.headers['x-pulse-page-auth']).trim();
|
||||
}
|
||||
if (req.headers['if-none-match']) {
|
||||
headers['if-none-match'] = String(req.headers['if-none-match']).trim();
|
||||
}
|
||||
if (requestOptions.contentType) {
|
||||
headers['content-type'] = requestOptions.contentType;
|
||||
}
|
||||
|
||||
return fetch(new URL(pathname, thinClientBaseUrl).toString(), {
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonResponse(response) {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = String(response.headers && typeof response.headers.get === 'function' ? response.headers.get('content-type') : '').toLowerCase();
|
||||
if (contentType.indexOf('application/json') === -1 && contentType.indexOf('+json') === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requirePageAuth(allowedScopes) {
|
||||
return function (req, res, next) {
|
||||
if (!sharedSecret) {
|
||||
@@ -114,6 +179,26 @@ function registerPlayerRoutes(app, options) {
|
||||
});
|
||||
|
||||
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
|
||||
if (thinClientBaseUrl) {
|
||||
void fetch(new URL('/api/media/config', thinClientBaseUrl).toString(), {
|
||||
method: 'GET',
|
||||
headers: createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: '/api/media/config'
|
||||
})
|
||||
}).then(async function (response) {
|
||||
res.status(response.status);
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType) {
|
||||
res.type(contentType);
|
||||
}
|
||||
res.send(await response.text());
|
||||
}).catch(function (_error) {
|
||||
res.status(502).json({ error: 'Thin client unavailable.' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
mediaDir: mediaDir,
|
||||
uploadDir: path.join(mediaDir, 'uploads')
|
||||
@@ -213,9 +298,35 @@ function registerPlayerRoutes(app, options) {
|
||||
app.get('/screen/:slug', function (req, res) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.set('Pragma', 'no-cache');
|
||||
if (thinClientBaseUrl) {
|
||||
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(req.params.slug || '').trim() }).token;
|
||||
void fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), {
|
||||
method: 'GET',
|
||||
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
|
||||
}).then(async function (response) {
|
||||
if (!response || response.status >= 400) {
|
||||
res.set('X-Player-Offline', '1');
|
||||
return res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
}
|
||||
const data = await readJsonResponse(response);
|
||||
if (!data) {
|
||||
res.set('X-Player-Offline', '1');
|
||||
return res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
}
|
||||
res.send(common.renderPlayerPage(req.params.slug, data));
|
||||
}).catch(function (error) {
|
||||
if (!isBridgeFetchError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
res.set('X-Player-Offline', '1');
|
||||
res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { bindPlayerToScreen } = require('./onboarding');
|
||||
if (playerIdentifier) {
|
||||
void bindPlayerToScreen(pool, playerIdentifier, req.params.slug)
|
||||
if (playerDeviceId) {
|
||||
void bindPlayerToScreen(pool, playerDeviceId, req.params.slug)
|
||||
.catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
@@ -231,6 +342,19 @@ function registerPlayerRoutes(app, options) {
|
||||
|
||||
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
if (thinClientBaseUrl) {
|
||||
const response = await fetchThinClient(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response) {
|
||||
return res.status(502).send('Thin client unavailable');
|
||||
}
|
||||
res.status(response.status);
|
||||
res.set('Cache-Control', response.headers.get('cache-control') || 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.type(response.headers.get('content-type') || 'text/html; charset=utf-8');
|
||||
return res.send(await response.text());
|
||||
}
|
||||
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
@@ -273,6 +397,29 @@ function registerPlayerRoutes(app, options) {
|
||||
|
||||
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
if (thinClientBaseUrl) {
|
||||
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response) {
|
||||
return res.status(502).json({ error: 'Thin client unavailable.' });
|
||||
}
|
||||
res.status(response.status);
|
||||
const etag = response.headers.get('etag');
|
||||
const cacheControl = response.headers.get('cache-control');
|
||||
if (etag) {
|
||||
res.set('ETag', etag);
|
||||
}
|
||||
if (cacheControl) {
|
||||
res.set('Cache-Control', cacheControl);
|
||||
}
|
||||
if (response.status === 304) {
|
||||
return res.end();
|
||||
}
|
||||
res.type(response.headers.get('content-type') || 'application/json');
|
||||
return res.send(await response.text());
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
|
||||
if (!data.screen) {
|
||||
@@ -293,6 +440,29 @@ function registerPlayerRoutes(app, options) {
|
||||
|
||||
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
if (thinClientBaseUrl) {
|
||||
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response) {
|
||||
return res.status(502).json({ error: 'Thin client unavailable.' });
|
||||
}
|
||||
res.status(response.status);
|
||||
const etag = response.headers.get('etag');
|
||||
const cacheControl = response.headers.get('cache-control');
|
||||
if (etag) {
|
||||
res.set('ETag', etag);
|
||||
}
|
||||
if (cacheControl) {
|
||||
res.set('Cache-Control', cacheControl);
|
||||
}
|
||||
if (response.status === 304) {
|
||||
return res.end();
|
||||
}
|
||||
res.type(response.headers.get('content-type') || 'application/json');
|
||||
return res.send(await response.text());
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
const announcement = typeof common.fetchActiveAnnouncement === 'function'
|
||||
? await common.fetchActiveAnnouncement(pool, req.params.slug)
|
||||
@@ -335,13 +505,15 @@ function registerPlayerRoutes(app, options) {
|
||||
const connections = playerRuntime.snapshotConnections(req.params.slug);
|
||||
let screen = null;
|
||||
let screenLookupFailed = false;
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||
screen = screenRows[0] || null;
|
||||
} catch (error) {
|
||||
screenLookupFailed = isTransientDbError(error);
|
||||
if (!screenLookupFailed) {
|
||||
throw error;
|
||||
if (pool && typeof pool.query === 'function') {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||
screen = screenRows[0] || null;
|
||||
} catch (error) {
|
||||
screenLookupFailed = isTransientDbError(error);
|
||||
if (!screenLookupFailed) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
@@ -374,7 +546,7 @@ function registerPlayerRoutes(app, options) {
|
||||
const isRedirectCommand = command === 'redirect';
|
||||
let screen = null;
|
||||
let screenLookupFailed = false;
|
||||
if (!isRedirectCommand) {
|
||||
if (!isRedirectCommand && pool && typeof pool.query === 'function') {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||
screen = screenRows[0] || null;
|
||||
|
||||
+5
-2
@@ -62,7 +62,7 @@ async function start() {
|
||||
const playerActionService = createPlayerActionService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: webConfig.playerInternalBaseUrl
|
||||
playerInternalBaseUrl: webConfig.thinClientBaseUrl
|
||||
});
|
||||
const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
|
||||
|
||||
@@ -71,7 +71,7 @@ async function start() {
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: webConfig.playerInternalBaseUrl,
|
||||
playerPublicBaseUrl: webConfig.playerPublicBaseUrl,
|
||||
thinClientBaseUrl: webConfig.thinClientBaseUrl,
|
||||
uploadDir: webConfig.uploadsDir,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
@@ -156,6 +156,8 @@ async function start() {
|
||||
},
|
||||
hasAnyPermission: hasAnyPermission,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
mediaDir: webConfig.mediaDir,
|
||||
uploadSyncService: webBootstrap.uploadSyncService,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||
@@ -208,6 +210,7 @@ async function start() {
|
||||
initializeBackgroundTasks: initializeBackgroundTasks,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
server: server,
|
||||
webBaseUrl: webConfig.webBaseUrl,
|
||||
dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs
|
||||
});
|
||||
|
||||
|
||||
Vendored
+55
-62
@@ -9,7 +9,7 @@ function createWebBootstrap(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
|
||||
const configuredThinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const uploadDir = String(options && options.uploadDir || '').trim();
|
||||
const dashboardRefreshIntervalMs = 5000;
|
||||
const formatDashboardDate = options && options.formatDashboardDate;
|
||||
@@ -22,53 +22,12 @@ function createWebBootstrap(options) {
|
||||
|
||||
const dashboardWs = new WebSocketServer({ noServer: true });
|
||||
const dashboardClients = new Set();
|
||||
const playerSnapshotCache = new Map();
|
||||
const playerSnapshotSockets = new Map();
|
||||
const playerSnapshotCache = options && options.playerSnapshotCache ? options.playerSnapshotCache : new Map();
|
||||
const playerSnapshotSockets = options && options.playerSnapshotSockets ? options.playerSnapshotSockets : new Map();
|
||||
let dashboardRefreshInFlight = null;
|
||||
let broadcastDashboardState = null;
|
||||
let playerInternalBaseUrl = null;
|
||||
let playerInternalBaseUrlPromise = null;
|
||||
|
||||
async function getPlayerInternalBaseUrl() {
|
||||
if (playerInternalBaseUrl) {
|
||||
return playerInternalBaseUrl;
|
||||
}
|
||||
|
||||
if (playerInternalBaseUrlPromise) {
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
playerInternalBaseUrlPromise = (async function () {
|
||||
if (!pool) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
|
||||
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
|
||||
} catch (_error) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
})().then(function (baseUrl) {
|
||||
playerInternalBaseUrl = baseUrl || null;
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return playerInternalBaseUrl;
|
||||
}, function () {
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
});
|
||||
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
async function getPlayerSnapshotSocketUrl(slug) {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
function getPlayerSnapshotSocketUrl(slug) {
|
||||
const resolvedPlayerInternalBaseUrl = configuredThinClientBaseUrl || configuredPlayerInternalBaseUrl;
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
@@ -79,34 +38,28 @@ function createWebBootstrap(options) {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function storePlayerSnapshot(slug, connections) {
|
||||
const normalizedSlug = String(slug || '').trim();
|
||||
const normalizedConnections = Array.isArray(connections) ? connections : [];
|
||||
playerSnapshotCache.set(normalizedSlug, {
|
||||
slug: normalizedSlug,
|
||||
count: normalizedConnections.length,
|
||||
connections: normalizedConnections
|
||||
});
|
||||
}
|
||||
|
||||
function clearPlayerSnapshotSocket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
playerSnapshotSockets.delete(key);
|
||||
}
|
||||
|
||||
function ensurePlayerSnapshotSubscription(slug) {
|
||||
if (options && typeof options.ensurePlayerSnapshotSubscription === 'function' && options.ensurePlayerSnapshotSubscription !== ensurePlayerSnapshotSubscription) {
|
||||
return options.ensurePlayerSnapshotSubscription(slug);
|
||||
}
|
||||
|
||||
const key = String(slug || '').trim();
|
||||
if (!key || playerSnapshotSockets.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
playerSnapshotSockets.set(key, null);
|
||||
const socketUrlPromise = getPlayerSnapshotSocketUrl(key);
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
||||
});
|
||||
socketUrlPromise.then(function (socketUrl) {
|
||||
|
||||
Promise.resolve(getPlayerSnapshotSocketUrl(key)).then(function (socketUrl) {
|
||||
const socket = new WebSocket(socketUrl, {
|
||||
headers: authHeaders
|
||||
});
|
||||
@@ -118,7 +71,11 @@ function createWebBootstrap(options) {
|
||||
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
|
||||
return;
|
||||
}
|
||||
storePlayerSnapshot(key, payload.connections || []);
|
||||
playerSnapshotCache.set(key, {
|
||||
slug: key,
|
||||
count: Array.isArray(payload.connections) ? payload.connections.length : 0,
|
||||
connections: Array.isArray(payload.connections) ? payload.connections : []
|
||||
});
|
||||
if (broadcastDashboardState) {
|
||||
broadcastDashboardState().catch(function (error) {
|
||||
console.error(error);
|
||||
@@ -152,10 +109,10 @@ function createWebBootstrap(options) {
|
||||
const dashboardStateService = createDashboardStateService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
thinClientBaseUrl: configuredThinClientBaseUrl,
|
||||
playerSnapshotCache: playerSnapshotCache,
|
||||
playerSnapshotSockets: playerSnapshotSockets,
|
||||
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
||||
playerPublicBaseUrl: playerPublicBaseUrl,
|
||||
formatDashboardDate: formatDashboardDate
|
||||
});
|
||||
const buildDashboardState = dashboardStateService.buildDashboardState;
|
||||
@@ -177,12 +134,44 @@ function createWebBootstrap(options) {
|
||||
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
|
||||
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
||||
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
||||
let lastDashboardState = null;
|
||||
|
||||
function getFallbackDashboardState() {
|
||||
return lastDashboardState || {
|
||||
playlists: [],
|
||||
screens: [],
|
||||
clients: [],
|
||||
kioskPlayers: [],
|
||||
slides: [],
|
||||
playerServiceConnected: false,
|
||||
connectedPlayersCount: 0,
|
||||
connectedClientsCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDashboardState() {
|
||||
try {
|
||||
const state = await buildDashboardState();
|
||||
lastDashboardState = state;
|
||||
return state;
|
||||
} catch (error) {
|
||||
if (lastDashboardState) {
|
||||
console.error(error);
|
||||
return lastDashboardState;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDashboardStateToSocket(socket) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
const state = await buildDashboardState();
|
||||
const state = await resolveDashboardState().catch(function (error) {
|
||||
console.error(error);
|
||||
return getFallbackDashboardState();
|
||||
});
|
||||
socket.send(JSON.stringify({ type: 'dashboard-state', state: state }));
|
||||
}
|
||||
|
||||
@@ -192,7 +181,10 @@ function createWebBootstrap(options) {
|
||||
}
|
||||
|
||||
dashboardRefreshInFlight = (async function () {
|
||||
const state = await buildDashboardState();
|
||||
const state = await resolveDashboardState().catch(function (error) {
|
||||
console.error(error);
|
||||
return getFallbackDashboardState();
|
||||
});
|
||||
const payload = JSON.stringify({ type: 'dashboard-state', state: state });
|
||||
for (const socket of dashboardClients) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
@@ -261,6 +253,7 @@ function createWebBootstrap(options) {
|
||||
return {
|
||||
upload: upload,
|
||||
uploadSyncService: uploadSyncService,
|
||||
playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null,
|
||||
buildDashboardState: buildDashboardState,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||
|
||||
@@ -58,7 +58,11 @@ function registerUrlHelpers(Handlebars) {
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('playerUrl', function (slug) {
|
||||
const base = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const base = String(arguments[1] || '').trim().replace(/\/$/, '');
|
||||
if (!base) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${base}/screen/${encodeURIComponent(slug)}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
const { resolvePlayerRegistration } = require('#src/data/player-registry');
|
||||
|
||||
const TASK = {
|
||||
taskType: 'slide-thumbnail-refresh'
|
||||
};
|
||||
|
||||
async function fetchPlayerInternalBaseUrl(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
async function fetchPlayerInternalBaseUrl(pool, configuredPlayerInternalBaseUrl) {
|
||||
const configured = String(configuredPlayerInternalBaseUrl || '').trim().replace(/\/$/, '');
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
|
||||
const player = await resolvePlayerRegistration(pool, getConfiguredPlayerIdentifier());
|
||||
return String(player && player.internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
}
|
||||
|
||||
function registerSlideThumbnailRefreshTask(options) {
|
||||
@@ -19,6 +21,7 @@ function registerSlideThumbnailRefreshTask(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const configuredWebBaseUrl = String(options && options.webBaseUrl || '').trim().replace(/\/$/, '');
|
||||
|
||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||
throw new Error('registerSlideThumbnailRefreshTask requires the slide thumbnail dependencies.');
|
||||
@@ -32,18 +35,19 @@ function registerSlideThumbnailRefreshTask(options) {
|
||||
throw new Error('Slide id is required.');
|
||||
}
|
||||
|
||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
||||
if (!playerInternalBaseUrl) {
|
||||
throw new Error('Player internal base URL is required.');
|
||||
const webBaseUrl = configuredWebBaseUrl || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`;
|
||||
if (!webBaseUrl) {
|
||||
throw new Error('Web base URL is required.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
baseUrl: webBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null,
|
||||
fontStylesheetHref: payload.fontStylesheetHref || ''
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
const { resolvePlayerRegistration } = require('#src/data/player-registry');
|
||||
|
||||
const TASK = {
|
||||
taskType: 'template-slide-thumbnail-refresh'
|
||||
};
|
||||
|
||||
async function fetchPlayerInternalBaseUrl(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
async function fetchPlayerInternalBaseUrl(pool, configuredPlayerInternalBaseUrl) {
|
||||
const configured = String(configuredPlayerInternalBaseUrl || '').trim().replace(/\/$/, '');
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
|
||||
const player = await resolvePlayerRegistration(pool, getConfiguredPlayerIdentifier());
|
||||
return String(player && player.internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
}
|
||||
|
||||
function registerTemplateSlideThumbnailRefreshTask(options) {
|
||||
@@ -19,6 +21,7 @@ function registerTemplateSlideThumbnailRefreshTask(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const configuredWebBaseUrl = String(options && options.webBaseUrl || '').trim().replace(/\/$/, '');
|
||||
|
||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||
throw new Error('registerTemplateSlideThumbnailRefreshTask requires the template thumbnail dependencies.');
|
||||
@@ -32,9 +35,9 @@ function registerTemplateSlideThumbnailRefreshTask(options) {
|
||||
throw new Error('Template id is required.');
|
||||
}
|
||||
|
||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
||||
if (!playerInternalBaseUrl) {
|
||||
throw new Error('Player internal base URL is required.');
|
||||
const webBaseUrl = configuredWebBaseUrl || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`;
|
||||
if (!webBaseUrl) {
|
||||
throw new Error('Web base URL is required.');
|
||||
}
|
||||
|
||||
const [slides] = await pool.query(
|
||||
@@ -52,7 +55,7 @@ function registerTemplateSlideThumbnailRefreshTask(options) {
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
baseUrl: webBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
|
||||
});
|
||||
|
||||
@@ -18,50 +18,57 @@ function registerFontSweepTask(options) {
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
|
||||
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
|
||||
const getPlayerTaskMetadata = uploadSyncService && uploadSyncService.getPlayerTaskMetadata;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
|
||||
throw new Error('registerFontSweepTask requires the font sweep dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: TASK.key,
|
||||
title: TASK.title,
|
||||
category: TASK.category,
|
||||
intervalMs: TASK.intervalMs,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: async function () {
|
||||
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
|
||||
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
|
||||
return operation && operation.uploadPath ? operation.uploadPath : '';
|
||||
}).filter(Boolean));
|
||||
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
|
||||
const metadataPromise = typeof getPlayerTaskMetadata === 'function'
|
||||
? Promise.resolve(getPlayerTaskMetadata())
|
||||
: Promise.resolve({});
|
||||
|
||||
for (let i = 0; i < desiredOperations.length; i += 1) {
|
||||
const operation = desiredOperations[i] || {};
|
||||
const uploadPath = String(operation.uploadPath || '').trim();
|
||||
if (!uploadPath) {
|
||||
continue;
|
||||
return metadataPromise.then(function (metadata) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: TASK.key,
|
||||
title: TASK.title,
|
||||
category: TASK.category,
|
||||
intervalMs: TASK.intervalMs,
|
||||
metadata: Object.assign({
|
||||
mediaDir: mediaDir
|
||||
}, metadata || {}),
|
||||
run: async function () {
|
||||
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
|
||||
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
|
||||
return operation && operation.uploadPath ? operation.uploadPath : '';
|
||||
}).filter(Boolean));
|
||||
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
|
||||
|
||||
for (let i = 0; i < desiredOperations.length; i += 1) {
|
||||
const operation = desiredOperations[i] || {};
|
||||
const uploadPath = String(operation.uploadPath || '').trim();
|
||||
if (!uploadPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
||||
await removeUploadFileFromPlayer(uploadPath, mediaDir);
|
||||
} else {
|
||||
await pushUploadFileToPlayer(uploadPath, mediaDir);
|
||||
}
|
||||
}
|
||||
|
||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
||||
for (let i = 0; i < currentUploadPaths.length; i += 1) {
|
||||
const uploadPath = String(currentUploadPaths[i] || '').trim();
|
||||
if (!uploadPath || desiredUploadPaths.has(uploadPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await removeUploadFileFromPlayer(uploadPath, mediaDir);
|
||||
} else {
|
||||
await pushUploadFileToPlayer(uploadPath, mediaDir);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentUploadPaths.length; i += 1) {
|
||||
const uploadPath = String(currentUploadPaths[i] || '').trim();
|
||||
if (!uploadPath || desiredUploadPaths.has(uploadPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await removeUploadFileFromPlayer(uploadPath, mediaDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,22 +8,30 @@ const TASK = {
|
||||
function registerInitialFontSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
|
||||
if (!backgroundTaskQueue || !mediaDir) {
|
||||
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial font sync',
|
||||
category: TASK.category,
|
||||
taskType: 'font-sync',
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir,
|
||||
operations: collectFontLibrarySyncOperations(mediaDir)
|
||||
},
|
||||
persist: true
|
||||
const metadataPromise = uploadSyncService && typeof uploadSyncService.getPlayerTaskMetadata === 'function'
|
||||
? uploadSyncService.getPlayerTaskMetadata()
|
||||
: Promise.resolve({});
|
||||
|
||||
return Promise.resolve(metadataPromise).then(function (metadata) {
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial font sync',
|
||||
category: TASK.category,
|
||||
taskType: 'font-sync',
|
||||
metadata: Object.assign({}, metadata || {}),
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir,
|
||||
operations: collectFontLibrarySyncOperations(mediaDir)
|
||||
},
|
||||
persist: true
|
||||
});
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue initial font sync:', error);
|
||||
});
|
||||
|
||||
@@ -6,21 +6,29 @@ const TASK = {
|
||||
function registerInitialMediaSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
|
||||
if (!backgroundTaskQueue || !mediaDir) {
|
||||
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial media sync',
|
||||
category: TASK.category,
|
||||
taskType: 'media-sync',
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir
|
||||
},
|
||||
persist: true
|
||||
const metadataPromise = uploadSyncService && typeof uploadSyncService.getPlayerTaskMetadata === 'function'
|
||||
? uploadSyncService.getPlayerTaskMetadata()
|
||||
: Promise.resolve({});
|
||||
|
||||
return Promise.resolve(metadataPromise).then(function (metadata) {
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial media sync',
|
||||
category: TASK.category,
|
||||
taskType: 'media-sync',
|
||||
metadata: Object.assign({}, metadata || {}),
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir
|
||||
},
|
||||
persist: true
|
||||
});
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue initial media sync:', error);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ function createWebConfig() {
|
||||
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
||||
const assetDir = path.join(__dirname, '..', 'public');
|
||||
const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
|
||||
const playerPublicBaseUrl = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:8081').replace(/\/$/, '');
|
||||
const thinClientBaseUrl = (process.env.THIN_CLIENT_BASE_URL || 'http://player-bridge:8090').replace(/\/$/, '');
|
||||
const webBaseUrl = (process.env.WEB_BASE_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, '');
|
||||
const sessionCookieName = 'digital_signage_session';
|
||||
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
||||
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
|
||||
@@ -20,7 +21,8 @@ function createWebConfig() {
|
||||
thumbnailsDir: thumbnailsDir,
|
||||
assetDir: assetDir,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerPublicBaseUrl: playerPublicBaseUrl,
|
||||
thinClientBaseUrl: thinClientBaseUrl,
|
||||
webBaseUrl: webBaseUrl,
|
||||
sessionCookieName: sessionCookieName,
|
||||
sessionMaxAgeMs: sessionMaxAgeMs,
|
||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||
|
||||
@@ -6,6 +6,10 @@ function normalizeClientName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizePlayerBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) {
|
||||
return (screens || []).map(function (screen) {
|
||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||
@@ -18,11 +22,12 @@ function enrichScreensWithConnections(screens, connectionsBySlug, onboardingName
|
||||
});
|
||||
}
|
||||
|
||||
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate) {
|
||||
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerIdentifierByBaseUrl, formatDashboardDate) {
|
||||
return (screens || []).flatMap(function (screen) {
|
||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||
return (connectionState.connections || []).map(function (connection) {
|
||||
const deviceId = String(connection.deviceId || '').trim();
|
||||
const playerBaseUrl = normalizePlayerBaseUrl(connection.playerPublicBaseUrl);
|
||||
return Object.assign({}, connection, {
|
||||
screen_slug: screen.slug,
|
||||
screen_name: screen.name,
|
||||
@@ -30,12 +35,55 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
|
||||
playlist_name: screen.playlist_name || null,
|
||||
connectedAtLabel: formatDashboardDate(connection.connectedAt),
|
||||
lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt),
|
||||
player_url: screen.player_url || String(connection.page || '').trim() || null
|
||||
player_identifier: playerIdentifierByBaseUrl && playerBaseUrl ? (playerIdentifierByBaseUrl[playerBaseUrl] || null) : null,
|
||||
player_url: playerBaseUrl || null
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
function buildKioskLauncherPlayers(playerRegistrations, staleSeconds) {
|
||||
return (Array.isArray(playerRegistrations) ? playerRegistrations : [])
|
||||
.filter(function (player) {
|
||||
return Boolean(player && String(player.identifier || '').trim() && String(player.public_base_url || '').trim() && isRecentPlayerRegistration(player, staleSeconds));
|
||||
})
|
||||
.map(function (player) {
|
||||
return {
|
||||
player_identifier: String(player.identifier || '').trim(),
|
||||
player_url: String(player.public_base_url || '').trim().replace(/\/$/, '')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchConnectedPlayerCount(pool, connectedPlayerCountStaleSeconds) {
|
||||
const staleSeconds = Math.max(30, Number(connectedPlayerCountStaleSeconds || 60));
|
||||
if (!pool || typeof pool.query !== 'function' || !Number.isFinite(staleSeconds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS connected_count
|
||||
FROM d_players
|
||||
WHERE last_seen_at IS NOT NULL
|
||||
AND last_seen_at >= DATE_SUB(NOW(), INTERVAL ${staleSeconds} SECOND)`
|
||||
);
|
||||
|
||||
const count = Number(rows && rows[0] && rows[0].connected_count);
|
||||
return Number.isFinite(count) && count >= 0 ? count : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function compareScreenNames(left, right) {
|
||||
const leftName = String(left && left.name || '').trim();
|
||||
const rightName = String(right && right.name || '').trim();
|
||||
@@ -51,6 +99,7 @@ function compareScreenNames(left, right) {
|
||||
function createDashboardStateService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const connectedPlayerCountStaleSeconds = options && options.connectedPlayerCountStaleSeconds;
|
||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||
const playerSnapshotSockets = options && options.playerSnapshotSockets;
|
||||
const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription;
|
||||
@@ -63,11 +112,19 @@ function createDashboardStateService(options) {
|
||||
async function buildDashboardState() {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const screensData = data.screens || [];
|
||||
const connectedPlayersCount = await fetchConnectedPlayerCount(pool, connectedPlayerCountStaleSeconds);
|
||||
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
||||
? await common.fetchScreenPlayerUrls(pool)
|
||||
: {};
|
||||
const playerRegistrations = typeof common.fetchPlayerRegistrations === 'function'
|
||||
? await common.fetchPlayerRegistrations(pool)
|
||||
: [];
|
||||
screensData.forEach(function (screen) {
|
||||
ensurePlayerSnapshotSubscription(screen.slug);
|
||||
try {
|
||||
ensurePlayerSnapshotSubscription(screen.slug);
|
||||
} catch (_error) {
|
||||
// Keep building dashboard state when one player snapshot subscription fails.
|
||||
}
|
||||
});
|
||||
|
||||
const [onboardingRows] = await pool.query(
|
||||
@@ -91,6 +148,15 @@ function createDashboardStateService(options) {
|
||||
}
|
||||
});
|
||||
|
||||
const playerIdentifierByBaseUrl = {};
|
||||
(Array.isArray(playerRegistrations) ? playerRegistrations : []).forEach(function (player) {
|
||||
const baseUrl = normalizePlayerBaseUrl(player && player.public_base_url);
|
||||
const identifier = normalizeClientName(player && player.identifier);
|
||||
if (baseUrl && identifier) {
|
||||
playerIdentifierByBaseUrl[baseUrl] = identifier;
|
||||
}
|
||||
});
|
||||
|
||||
const connectionsBySlug = {};
|
||||
screensData.forEach(function (screen) {
|
||||
const cached = playerSnapshotCache.get(String(screen.slug || '').trim());
|
||||
@@ -106,7 +172,8 @@ function createDashboardStateService(options) {
|
||||
});
|
||||
})
|
||||
.sort(compareScreenNames);
|
||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate);
|
||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerIdentifierByBaseUrl, formatDashboardDate);
|
||||
const kioskPlayers = buildKioskLauncherPlayers(playerRegistrations, connectedPlayerCountStaleSeconds);
|
||||
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
||||
return socket && socket.readyState === WebSocket.OPEN;
|
||||
});
|
||||
@@ -115,8 +182,10 @@ function createDashboardStateService(options) {
|
||||
playlists: data.playlists || [],
|
||||
screens: screens,
|
||||
clients: clients,
|
||||
kioskPlayers: kioskPlayers,
|
||||
slides: data.slides || [],
|
||||
playerServiceConnected: playerServiceConnected,
|
||||
connectedPlayersCount: connectedPlayersCount !== null ? connectedPlayersCount : 0,
|
||||
connectedClientsCount: screens.reduce(function (total, screen) {
|
||||
return total + Number(screen.player_connection_count || 0);
|
||||
}, 0)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
module.exports = {
|
||||
createUploadSyncService: require('./upload-sync').createUploadSyncService,
|
||||
captureSlideThumbnail: require('./slide-thumbnails').captureSlideThumbnail,
|
||||
captureSlideThumbnail: function captureSlideThumbnail(options) {
|
||||
return require('./slide-thumbnails').captureSlideThumbnail(options);
|
||||
},
|
||||
fontLibrary: require('./font-library')
|
||||
};
|
||||
@@ -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 path = require('path');
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const chromiumModule = require('@sparticuz/chromium');
|
||||
const sharp = require('sharp');
|
||||
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
||||
? chromiumModule
|
||||
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
||||
? chromiumModule.default
|
||||
: chromiumModule;
|
||||
const {
|
||||
escapeHtml,
|
||||
mediaKind,
|
||||
@@ -73,6 +65,23 @@ function getRegionContent(slide, region) {
|
||||
return content && typeof content === 'object' ? content : { value: content };
|
||||
}
|
||||
|
||||
function getThumbnailCanvasSize(slide) {
|
||||
const template = slide && slide.template ? slide.template : null;
|
||||
return {
|
||||
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
|
||||
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
|
||||
};
|
||||
}
|
||||
|
||||
function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
|
||||
const left = Number.isFinite(Number(region && region.x)) && canvasWidth > 0 ? (Number(region.x) / canvasWidth) * 100 : 0;
|
||||
const top = Number.isFinite(Number(region && region.y)) && canvasHeight > 0 ? (Number(region.y) / canvasHeight) * 100 : 0;
|
||||
const width = Number.isFinite(Number(region && region.width)) && canvasWidth > 0 ? (Number(region.width) / canvasWidth) * 100 : 0;
|
||||
const height = Number.isFinite(Number(region && region.height)) && canvasHeight > 0 ? (Number(region.height) / canvasHeight) * 100 : 0;
|
||||
|
||||
return 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region && region.z_index || 0) + ';';
|
||||
}
|
||||
|
||||
function hasVisibleContent(html) {
|
||||
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
||||
}
|
||||
@@ -86,7 +95,7 @@ function buildTextRegionMarkup(region, regionContent) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<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) {
|
||||
@@ -96,21 +105,21 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
if (regionType === 'image') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
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') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
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') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
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 radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : '';
|
||||
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') {
|
||||
const html = String(rawValue || '').trim();
|
||||
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') {
|
||||
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);
|
||||
}
|
||||
|
||||
async function loadChromium() {
|
||||
const chromiumModule = await import('@sparticuz/chromium');
|
||||
const resolved = chromiumModule && chromiumModule.default ? chromiumModule.default : chromiumModule;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function loadPuppeteer() {
|
||||
return require('puppeteer-core');
|
||||
}
|
||||
|
||||
function loadSharp() {
|
||||
return require('sharp');
|
||||
}
|
||||
|
||||
function buildThumbnailPreviewMarkup(slide, baseUrl) {
|
||||
const template = slide && slide.template ? slide.template : null;
|
||||
if (!template) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const canvasSize = getThumbnailCanvasSize(slide);
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
return (Array.isArray(template.regions) ? template.regions : []).map(function (region) {
|
||||
const regionContent = getRegionContent(slide, region);
|
||||
const previewRegion = Object.assign({}, region, {
|
||||
baseStyle: buildThumbnailRegionStyle(region, canvasSize.width, canvasSize.height),
|
||||
pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)),
|
||||
pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1))
|
||||
});
|
||||
return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function buildThumbnailPreviewPayload(slide, options) {
|
||||
const template = slide && slide.template ? slide.template : null;
|
||||
const canvasSize = getThumbnailCanvasSize(slide);
|
||||
return {
|
||||
thumbnailPreview: true,
|
||||
canvasWidth: canvasSize.width,
|
||||
canvasHeight: canvasSize.height,
|
||||
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
|
||||
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
|
||||
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
|
||||
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl)
|
||||
};
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
const puppeteer = await loadPuppeteer();
|
||||
const chromium = await loadChromium();
|
||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||
return fs.existsSync(candidate);
|
||||
}) || '';
|
||||
@@ -178,6 +236,7 @@ async function launchBrowser() {
|
||||
}
|
||||
|
||||
async function captureSlideThumbnail(options) {
|
||||
const sharp = loadSharp();
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
@@ -194,7 +253,6 @@ async function captureSlideThumbnail(options) {
|
||||
throw new Error('Slide not found.');
|
||||
}
|
||||
|
||||
const canvasSize = getCanvasSize(slide);
|
||||
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
||||
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
||||
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
||||
@@ -206,11 +264,11 @@ async function captureSlideThumbnail(options) {
|
||||
|
||||
async function waitForThumbnailRender(page) {
|
||||
await page.waitForFunction(function () {
|
||||
return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas'));
|
||||
return document.readyState === 'complete' && Boolean(document.querySelector('#popup-preview-canvas'));
|
||||
}, { timeout: 30000 });
|
||||
|
||||
await page.waitForFunction(function () {
|
||||
var canvas = document.querySelector('.slide-canvas');
|
||||
var canvas = document.querySelector('#popup-preview-canvas');
|
||||
if (!canvas) {
|
||||
return false;
|
||||
}
|
||||
@@ -244,18 +302,26 @@ async function captureSlideThumbnail(options) {
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview';
|
||||
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/popup-preview';
|
||||
const previewUrl = baseUrl + previewPath;
|
||||
const previewPayload = buildThumbnailPreviewPayload(slide, {
|
||||
baseUrl: baseUrl,
|
||||
fontStylesheetHref: options && options.fontStylesheetHref ? options.fontStylesheetHref : ''
|
||||
});
|
||||
await page.setViewport({
|
||||
width: Math.max(1, Number(previewPayload.canvasWidth || PLAYER_VIEWPORT.width)),
|
||||
height: Math.max(1, Number(previewPayload.canvasHeight || PLAYER_VIEWPORT.height)),
|
||||
deviceScaleFactor: 1
|
||||
});
|
||||
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: previewPath
|
||||
}));
|
||||
await page.setViewport(PLAYER_VIEWPORT);
|
||||
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await waitForThumbnailRender(page);
|
||||
const canvas = await page.$('.slide-canvas');
|
||||
const canvas = await page.$('#popup-preview-canvas');
|
||||
if (!canvas) {
|
||||
throw new Error('Player render did not produce a slide canvas.');
|
||||
throw new Error('Popup preview did not produce a slide canvas.');
|
||||
}
|
||||
await canvas.screenshot({ path: fullSizePath });
|
||||
} finally {
|
||||
@@ -298,5 +364,7 @@ async function captureSlideThumbnail(options) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureSlideThumbnail: captureSlideThumbnail
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
buildThumbnailPreviewPayload: buildThumbnailPreviewPayload,
|
||||
buildThumbnailPreviewMarkup: buildThumbnailPreviewMarkup
|
||||
};
|
||||
@@ -6,11 +6,48 @@ const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { collectFontLibrarySyncOperations } = require('./font-library');
|
||||
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
|
||||
|
||||
function normalizeUploadRoot(uploadDir) {
|
||||
return path.resolve(String(uploadDir || '').trim());
|
||||
}
|
||||
|
||||
function isLocalLikeBaseUrl(value) {
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return host === 'localhost'
|
||||
|| host === '127.0.0.1'
|
||||
|| host === '::1'
|
||||
|| host === 'host.docker.internal'
|
||||
|| host === 'player'
|
||||
|| host === 'web'
|
||||
|| host === 'player-bridge'
|
||||
|| host.endsWith('.local')
|
||||
|| host.endsWith('.internal')
|
||||
|| host.endsWith('.docker.internal');
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function normalizePlayerRowBaseUrl(player) {
|
||||
return normalizeBaseUrl(player && player.internal_base_url);
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
function createUploadSyncService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
@@ -19,55 +56,145 @@ function createUploadSyncService(options) {
|
||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
||||
const MAX_FIELD_BYTES = 10 * 1024 * 1024;
|
||||
const PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS = 60000;
|
||||
const pendingPlayerUploadSyncs = new Map();
|
||||
let pendingPlayerUploadSyncFlushTimer = null;
|
||||
let pendingPlayerUploadSyncFlushInFlight = null;
|
||||
let pendingPlayerUploadSyncRetryLogAt = 0;
|
||||
const pendingPlaylistUploadSyncs = new Map();
|
||||
let pendingPlaylistUploadSyncFlushTimer = null;
|
||||
let pendingPlaylistUploadSyncFlushInFlight = null;
|
||||
let playerInternalBaseUrl = null;
|
||||
let playerInternalBaseUrlPromise = null;
|
||||
let playerTaskMetadata = null;
|
||||
let playerTaskMetadataPromise = null;
|
||||
|
||||
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
|
||||
throw new Error('createUploadSyncService requires the upload dependencies.');
|
||||
}
|
||||
|
||||
async function getPlayerInternalBaseUrl() {
|
||||
if (playerInternalBaseUrl) {
|
||||
return playerInternalBaseUrl;
|
||||
const metadata = await getPlayerTaskMetadata();
|
||||
if (!metadata || metadata.playerActive === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (playerInternalBaseUrlPromise) {
|
||||
return playerInternalBaseUrlPromise;
|
||||
return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
|
||||
}
|
||||
|
||||
async function getPlayerTaskMetadata() {
|
||||
if (playerTaskMetadata) {
|
||||
return playerTaskMetadata;
|
||||
}
|
||||
|
||||
playerInternalBaseUrlPromise = (async function () {
|
||||
if (!pool) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
if (playerTaskMetadataPromise) {
|
||||
return playerTaskMetadataPromise;
|
||||
}
|
||||
|
||||
playerTaskMetadataPromise = (async function () {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
|
||||
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
|
||||
if (pool && typeof fetchPlayerRegistrations === 'function') {
|
||||
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
|
||||
const players = await fetchPlayerRegistrations(pool);
|
||||
const registeredPlayers = Array.isArray(players) ? players : [];
|
||||
const recentPlayers = registeredPlayers.filter(function (player) {
|
||||
return isRecentPlayerRegistration(player, 60);
|
||||
});
|
||||
const preferredPlayer = recentPlayers.find(function (player) {
|
||||
const internalBaseUrl = normalizePlayerRowBaseUrl(player);
|
||||
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
|
||||
}) || recentPlayers.find(function (player) {
|
||||
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
|
||||
}) || recentPlayers[0] || null;
|
||||
if (preferredPlayer) {
|
||||
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(preferredPlayer);
|
||||
const resolvedPublicBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.public_base_url);
|
||||
const resolvedIdentifier = String(preferredPlayer && preferredPlayer.identifier || '').trim();
|
||||
playerInternalBaseUrl = resolvedInternalBaseUrl || null;
|
||||
playerTaskMetadata = {
|
||||
playerIdentifier: resolvedIdentifier || null,
|
||||
playerPublicBaseUrl: resolvedPublicBaseUrl || null,
|
||||
playerInternalBaseUrl: resolvedInternalBaseUrl || null,
|
||||
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null,
|
||||
playerActive: true
|
||||
};
|
||||
return playerTaskMetadata;
|
||||
}
|
||||
|
||||
if (registeredPlayers.length) {
|
||||
const stalePlayer = registeredPlayers.find(function (player) {
|
||||
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
|
||||
}) || registeredPlayers[0] || null;
|
||||
const staleInternalBaseUrl = normalizePlayerRowBaseUrl(stalePlayer);
|
||||
const stalePublicBaseUrl = normalizeBaseUrl(stalePlayer && stalePlayer.public_base_url);
|
||||
const staleIdentifier = String(stalePlayer && stalePlayer.identifier || '').trim();
|
||||
playerInternalBaseUrl = staleInternalBaseUrl || null;
|
||||
playerTaskMetadata = {
|
||||
playerIdentifier: staleIdentifier || null,
|
||||
playerPublicBaseUrl: stalePublicBaseUrl || null,
|
||||
playerInternalBaseUrl: staleInternalBaseUrl || null,
|
||||
playerLabel: staleIdentifier || stalePublicBaseUrl || staleInternalBaseUrl || null,
|
||||
playerActive: false
|
||||
};
|
||||
return playerTaskMetadata;
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
}
|
||||
})().then(function (baseUrl) {
|
||||
playerInternalBaseUrl = baseUrl || null;
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return playerInternalBaseUrl;
|
||||
|
||||
playerInternalBaseUrl = configuredPlayerInternalBaseUrl || null;
|
||||
playerTaskMetadata = {
|
||||
playerIdentifier: getConfiguredPlayerIdentifier() || null,
|
||||
playerPublicBaseUrl: null,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null,
|
||||
playerActive: true
|
||||
};
|
||||
return playerTaskMetadata;
|
||||
})().then(function (metadata) {
|
||||
playerTaskMetadataPromise = null;
|
||||
return metadata || null;
|
||||
}, function () {
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
playerTaskMetadataPromise = null;
|
||||
return {
|
||||
playerIdentifier: getConfiguredPlayerIdentifier() || null,
|
||||
playerPublicBaseUrl: null,
|
||||
playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null,
|
||||
playerLabel: getConfiguredPlayerIdentifier() || configuredPlayerInternalBaseUrl || null
|
||||
};
|
||||
});
|
||||
|
||||
return playerInternalBaseUrlPromise;
|
||||
return playerTaskMetadataPromise;
|
||||
}
|
||||
|
||||
function formatPlayerTaskLabel(metadata) {
|
||||
const playerLabel = String(metadata && metadata.playerLabel || '').trim();
|
||||
if (playerLabel) {
|
||||
return playerLabel;
|
||||
}
|
||||
|
||||
const playerIdentifier = String(metadata && metadata.playerIdentifier || '').trim();
|
||||
if (playerIdentifier) {
|
||||
return playerIdentifier;
|
||||
}
|
||||
|
||||
const playerPublicBaseUrl = String(metadata && metadata.playerPublicBaseUrl || '').trim();
|
||||
if (playerPublicBaseUrl) {
|
||||
return playerPublicBaseUrl;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function logMediaSyncSummary(level, message, metadata, details) {
|
||||
const suffix = formatPlayerTaskLabel(metadata);
|
||||
const logger = level === 'warn' ? console.warn : console.info;
|
||||
if (details !== undefined) {
|
||||
logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`, details);
|
||||
return;
|
||||
}
|
||||
logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
function createUploadMiddleware(uploadDir) {
|
||||
@@ -84,7 +211,8 @@ function createUploadSyncService(options) {
|
||||
return multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: MAX_UPLOAD_BYTES
|
||||
fileSize: MAX_UPLOAD_BYTES,
|
||||
fieldSize: MAX_FIELD_BYTES
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -276,6 +404,15 @@ function createUploadSyncService(options) {
|
||||
return Boolean(localUploadDir);
|
||||
}
|
||||
|
||||
function isPlayerUnavailableError(error) {
|
||||
const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase();
|
||||
return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT';
|
||||
}
|
||||
|
||||
function isPlayerUnavailableResponse(response) {
|
||||
return Boolean(response) && Number(response.status) === 503;
|
||||
}
|
||||
|
||||
function queuePlayerUploadSync(operation) {
|
||||
if (!operation || !operation.uploadPath) {
|
||||
return;
|
||||
@@ -303,13 +440,13 @@ function createUploadSyncService(options) {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir) {
|
||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
|
||||
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
|
||||
if (!targetBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -334,7 +471,7 @@ function createUploadSyncService(options) {
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
@@ -343,23 +480,27 @@ function createUploadSyncService(options) {
|
||||
body: fileBuffer
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||
if (!isPlayerUnavailableResponse(response)) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, error);
|
||||
if (!isPlayerUnavailableError(error)) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
|
||||
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
|
||||
if (!targetBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -372,7 +513,7 @@ function createUploadSyncService(options) {
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||
});
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -380,12 +521,16 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||
if (!isPlayerUnavailableResponse(response)) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, error);
|
||||
if (!isPlayerUnavailableError(error)) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -395,9 +540,10 @@ function createUploadSyncService(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
|
||||
for (let i = 0; i < uniqueRefs.length; i += 1) {
|
||||
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir);
|
||||
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl);
|
||||
if (!success) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
@@ -550,6 +696,10 @@ function createUploadSyncService(options) {
|
||||
|
||||
pendingPlaylistUploadSyncs.delete(operation.key);
|
||||
}
|
||||
|
||||
if (pendingEntries.length) {
|
||||
logMediaSyncSummary('info', `Playlist sync flushed ${pendingEntries.length} task${pendingEntries.length === 1 ? '' : 's'}`, pendingEntries[0] && pendingEntries[0].metadata);
|
||||
}
|
||||
})().finally(function () {
|
||||
pendingPlaylistUploadSyncFlushInFlight = null;
|
||||
if (pendingPlaylistUploadSyncs.size) {
|
||||
@@ -571,18 +721,47 @@ function createUploadSyncService(options) {
|
||||
|
||||
pendingPlayerUploadSyncFlushInFlight = (async function () {
|
||||
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values());
|
||||
const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata
|
||||
? pendingEntries[0].metadata
|
||||
: await getPlayerTaskMetadata();
|
||||
if (playerMetadata && playerMetadata.playerActive === false) {
|
||||
pendingPlayerUploadSyncs.clear();
|
||||
pendingPlayerUploadSyncRetryLogAt = 0;
|
||||
return;
|
||||
}
|
||||
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
|
||||
? playerMetadata.playerInternalBaseUrl
|
||||
: await getPlayerInternalBaseUrl();
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
for (let i = 0; i < pendingEntries.length; i += 1) {
|
||||
const operation = pendingEntries[i];
|
||||
let success = false;
|
||||
if (operation.type === 'delete') {
|
||||
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir);
|
||||
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
|
||||
} else {
|
||||
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir);
|
||||
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
|
||||
}
|
||||
if (success) {
|
||||
successCount += 1;
|
||||
pendingPlayerUploadSyncs.delete(operation.uploadPath);
|
||||
} else {
|
||||
failureCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount) {
|
||||
logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, playerMetadata);
|
||||
}
|
||||
if (failureCount) {
|
||||
const now = Date.now();
|
||||
if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) {
|
||||
pendingPlayerUploadSyncRetryLogAt = now;
|
||||
logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, playerMetadata);
|
||||
}
|
||||
} else if (!pendingPlayerUploadSyncs.size) {
|
||||
pendingPlayerUploadSyncRetryLogAt = 0;
|
||||
}
|
||||
})().finally(function () {
|
||||
pendingPlayerUploadSyncFlushInFlight = null;
|
||||
if (pendingPlayerUploadSyncs.size) {
|
||||
@@ -678,12 +857,14 @@ function createUploadSyncService(options) {
|
||||
safePayload.operation = Object.assign({}, safePayload.operation);
|
||||
delete safePayload.operation.pool;
|
||||
}
|
||||
const playerMetadata = await getPlayerTaskMetadata();
|
||||
|
||||
const definition = {
|
||||
key: taskKey,
|
||||
title: title,
|
||||
category: 'media-sync',
|
||||
taskType: 'media-sync',
|
||||
metadata: Object.assign({}, playerMetadata || {}),
|
||||
payload: safePayload,
|
||||
persist: true
|
||||
};
|
||||
@@ -721,7 +902,8 @@ function createUploadSyncService(options) {
|
||||
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
|
||||
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
queueMediaSyncTask: queueMediaSyncTask
|
||||
queueMediaSyncTask: queueMediaSyncTask,
|
||||
getPlayerTaskMetadata: getPlayerTaskMetadata
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+143
-38
@@ -1,4 +1,52 @@
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
|
||||
|
||||
function isLocalLikeBaseUrl(value) {
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return host === 'localhost'
|
||||
|| host === '127.0.0.1'
|
||||
|| host === '::1'
|
||||
|| host === 'host.docker.internal'
|
||||
|| host === 'player'
|
||||
|| host === 'web'
|
||||
|| host === 'player-bridge'
|
||||
|| host.endsWith('.local')
|
||||
|| host.endsWith('.internal')
|
||||
|| host.endsWith('.docker.internal');
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
async function fetchRecentPlayerRegistrations(pool) {
|
||||
if (!pool || typeof fetchPlayerRegistrations !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const players = await fetchPlayerRegistrations(pool);
|
||||
return (Array.isArray(players) ? players : []).filter(function (player) {
|
||||
return isRecentPlayerRegistration(player, 60);
|
||||
});
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createPlayerActionService(options) {
|
||||
const pool = options && options.pool;
|
||||
@@ -22,26 +70,38 @@ function createPlayerActionService(options) {
|
||||
}
|
||||
|
||||
playerInternalBaseUrlPromise = (async function () {
|
||||
if (!pool) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
try {
|
||||
if (pool && typeof fetchPlayerRegistrations === 'function') {
|
||||
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
|
||||
const players = await fetchPlayerRegistrations(pool);
|
||||
const exactPlayer = Array.isArray(players)
|
||||
? players.find(function (player) {
|
||||
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
|
||||
})
|
||||
: null;
|
||||
const registeredPlayers = Array.isArray(players) ? players : [];
|
||||
const preferredPlayer = exactPlayer || registeredPlayers.find(function (player) {
|
||||
const internalBaseUrl = normalizeBaseUrl(player && player.internal_base_url);
|
||||
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
|
||||
}) || registeredPlayers[0] || null;
|
||||
const resolvedBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.internal_base_url);
|
||||
if (resolvedBaseUrl) {
|
||||
playerInternalBaseUrl = resolvedBaseUrl;
|
||||
return resolvedBaseUrl;
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
|
||||
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
|
||||
} catch (_error) {
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
if (configuredPlayerInternalBaseUrl) {
|
||||
playerInternalBaseUrl = configuredPlayerInternalBaseUrl;
|
||||
return configuredPlayerInternalBaseUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
})().then(function (baseUrl) {
|
||||
playerInternalBaseUrl = baseUrl || null;
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return playerInternalBaseUrl;
|
||||
return baseUrl || null;
|
||||
}, function () {
|
||||
playerInternalBaseUrlPromise = null;
|
||||
return configuredPlayerInternalBaseUrl || null;
|
||||
@@ -50,24 +110,26 @@ function createPlayerActionService(options) {
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||
async function forwardPlayerCommandToBaseUrl(baseUrl, slug, commandOrPayload, connectionId) {
|
||||
const targetBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (!targetBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
: { command: commandOrPayload };
|
||||
if (connectionId) {
|
||||
payload.connectionId = connectionId;
|
||||
}
|
||||
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
|
||||
body: payload
|
||||
});
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -89,6 +151,11 @@ function createPlayerActionService(options) {
|
||||
});
|
||||
}
|
||||
|
||||
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId);
|
||||
}
|
||||
|
||||
async function forwardAnnouncementRefresh(slug) {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
@@ -127,29 +194,66 @@ function createPlayerActionService(options) {
|
||||
method: 'GET',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
|
||||
});
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
const recentPlayers = await fetchRecentPlayerRegistrations(pool);
|
||||
const targetBaseUrls = Array.from(new Set((recentPlayers.length ? recentPlayers : []).map(function (player) {
|
||||
return normalizeBaseUrl(player && player.public_base_url);
|
||||
}).filter(Boolean)));
|
||||
|
||||
if (!targetBaseUrls.length) {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (resolvedPlayerInternalBaseUrl) {
|
||||
targetBaseUrls.push(resolvedPlayerInternalBaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetBaseUrls.length) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
const results = await Promise.all(targetBaseUrls.map(async function (baseUrl) {
|
||||
const response = await fetch(`${baseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}));
|
||||
|
||||
const mergedConnections = [];
|
||||
let screen = null;
|
||||
let degraded = false;
|
||||
results.forEach(function (result) {
|
||||
if (!result) {
|
||||
degraded = true;
|
||||
return;
|
||||
}
|
||||
if (!screen && result.screen) {
|
||||
screen = result.screen;
|
||||
}
|
||||
if (Array.isArray(result.connections)) {
|
||||
mergedConnections.push.apply(mergedConnections, result.connections);
|
||||
}
|
||||
if (result.degraded) {
|
||||
degraded = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(function () { return ''; });
|
||||
const error = new Error(errorText || `Unable to load screen connections for ${slug}.`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json().catch(function () {
|
||||
return { connections: [] };
|
||||
});
|
||||
return {
|
||||
screen: screen,
|
||||
screenSlug: slug,
|
||||
count: mergedConnections.length,
|
||||
connections: mergedConnections,
|
||||
degraded: degraded
|
||||
};
|
||||
}
|
||||
|
||||
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
|
||||
@@ -197,6 +301,7 @@ function createPlayerActionService(options) {
|
||||
forwardPlayerCommand: forwardPlayerCommand,
|
||||
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
|
||||
getScreenConnections: getScreenConnections,
|
||||
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
|
||||
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
||||
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
||||
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
|
||||
|
||||
@@ -25,6 +25,7 @@ async function initializeWebServer(options) {
|
||||
uploadSyncService: webBootstrap.uploadSyncService,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
mediaDir: mediaDir,
|
||||
webBaseUrl: options && options.webBaseUrl ? options.webBaseUrl : null,
|
||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ module.exports = function registerMiddleware(app, deps) {
|
||||
});
|
||||
|
||||
app.use(function (req, res, next) {
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) {
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media') {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
@@ -694,7 +694,8 @@
|
||||
|
||||
.table-pagination-page .app-content {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-pagination-page .app-content .container-fluid {
|
||||
@@ -730,7 +731,7 @@
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-height: var(--table-pagination-card-min-height, 0);
|
||||
max-height: var(--table-pagination-card-max-height, var(--background-tasks-task-card-max-height, calc(100dvh - 12rem)));
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -876,6 +877,67 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.dashboard-launcher-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-launcher-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-launcher-copy {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-kiosk-launcher-confirmation {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.dashboard-kiosk-launcher-confirmation .form-check.form-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.dashboard-kiosk-launcher-confirmation .form-check-input {
|
||||
float: none;
|
||||
margin: 0;
|
||||
transform: scale(0.92);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.dashboard-kiosk-launcher-confirmation .form-check-label {
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.dashboard-launcher-open-button {
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.dashboard-kiosk-launcher-download {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dashboard-kiosk-launcher-download.disabled,
|
||||
.dashboard-kiosk-launcher-download[aria-disabled='true'] {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dashboard-launcher-download {
|
||||
width: 9rem;
|
||||
}
|
||||
|
||||
.screen-command-card .card-header {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
|
||||
@@ -6,7 +6,169 @@
|
||||
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||
var LIST_PAGE_SIZE = 25;
|
||||
var latestDashboardState = null;
|
||||
var ALL_SCREENS_SLUG = '__all__';
|
||||
var ALL_SCREENS_LABEL = 'All screens';
|
||||
|
||||
function getClientSearchInput() {
|
||||
var table = document.getElementById('dashboard-clients-table');
|
||||
var container = table && typeof table.closest === 'function'
|
||||
? (table.closest('[data-table-search-container]') || table.closest('.card') || null)
|
||||
: null;
|
||||
|
||||
return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]');
|
||||
}
|
||||
|
||||
function getClientListQueryState() {
|
||||
var searchParams = new URLSearchParams(String(window.location && window.location.search || ''));
|
||||
var searchInput = getClientSearchInput();
|
||||
var searchValue = searchInput ? String(searchInput.value || '').trim() : String(searchParams.get('search') || '').trim();
|
||||
|
||||
return {
|
||||
search: searchValue,
|
||||
sort: String(searchParams.get('sort') || '').trim(),
|
||||
direction: String(searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc',
|
||||
page: Math.max(1, Math.floor(Number(searchParams.get('page') || 1) || 1))
|
||||
};
|
||||
}
|
||||
|
||||
function isClientSearchLoading() {
|
||||
return Boolean(document.querySelector('[data-table-search-loading="true"]'));
|
||||
}
|
||||
|
||||
function getComparableClientSortValue(rawValue) {
|
||||
var value = String(rawValue || '').trim();
|
||||
|
||||
if (!value) {
|
||||
return { type: 'empty', value: '' };
|
||||
}
|
||||
|
||||
var numericValue = Number(value.replace(/,/g, ''));
|
||||
if (!Number.isNaN(numericValue)) {
|
||||
return { type: 'number', value: numericValue };
|
||||
}
|
||||
|
||||
var dateValue = Date.parse(value);
|
||||
if (!Number.isNaN(dateValue)) {
|
||||
return { type: 'date', value: dateValue };
|
||||
}
|
||||
|
||||
return { type: 'string', value: value.toLowerCase() };
|
||||
}
|
||||
|
||||
function compareClientSortValues(leftValue, rightValue) {
|
||||
var left = getComparableClientSortValue(leftValue);
|
||||
var right = getComparableClientSortValue(rightValue);
|
||||
|
||||
if (left.type === 'empty' && right.type === 'empty') {
|
||||
return 0;
|
||||
}
|
||||
if (left.type === 'empty') {
|
||||
return 1;
|
||||
}
|
||||
if (right.type === 'empty') {
|
||||
return -1;
|
||||
}
|
||||
if (left.type === right.type) {
|
||||
if (left.value < right.value) {
|
||||
return -1;
|
||||
}
|
||||
if (left.value > right.value) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return String(left.value).localeCompare(String(right.value), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function createClientSearchMatcher(searchTerm) {
|
||||
var query = String(searchTerm || '').trim().toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
return function () {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
return function (client) {
|
||||
var searchableValues = [
|
||||
client && client.name,
|
||||
client && client.client_name,
|
||||
client && client.clientId,
|
||||
client && client.deviceId,
|
||||
client && client.slug,
|
||||
client && client.screen_slug,
|
||||
client && client.screen_name,
|
||||
client && client.ipAddress,
|
||||
client && client.clientIp,
|
||||
client && client.status,
|
||||
client && client.currentSlideTitle
|
||||
];
|
||||
|
||||
return searchableValues.map(function (value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}).join(' ').indexOf(query) !== -1;
|
||||
};
|
||||
}
|
||||
|
||||
function sortClientsForTable(clients, sortKey, sortDirection) {
|
||||
var normalizedSortKey = String(sortKey || '').trim();
|
||||
var normalizedDirection = String(sortDirection || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
var accessors = {
|
||||
client: function (client) { return String(client && (getClientDisplayName(client) || client.client_name || client.name || client.clientId) || '').trim(); },
|
||||
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
|
||||
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
|
||||
ip: function (client) { return String(client && client.clientIp || '').trim(); },
|
||||
viewport: function (client) {
|
||||
var viewport = client && client.viewport;
|
||||
if (!viewport || !viewport.width || !viewport.height) {
|
||||
return '';
|
||||
}
|
||||
return String(Number(viewport.width) || 0) + 'x' + String(Number(viewport.height) || 0);
|
||||
},
|
||||
connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); }
|
||||
};
|
||||
|
||||
function compareValues(leftValue, rightValue) {
|
||||
return compareClientSortValues(leftValue, rightValue);
|
||||
}
|
||||
|
||||
var sortKeys = accessors[normalizedSortKey]
|
||||
? [normalizedSortKey]
|
||||
: ['client'];
|
||||
|
||||
if (sortKeys[0] === 'client') {
|
||||
sortKeys.push('ip');
|
||||
} else if (sortKeys[0] === 'ip') {
|
||||
sortKeys.push('client');
|
||||
}
|
||||
|
||||
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
|
||||
for (var index = 0; index < sortKeys.length; index += 1) {
|
||||
var sortKeyName = sortKeys[index];
|
||||
var comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
|
||||
|
||||
if (comparison !== 0) {
|
||||
return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function getVisibleClients(state) {
|
||||
var query = getClientListQueryState();
|
||||
var clients = Array.isArray(state && state.clients) ? state.clients.slice() : [];
|
||||
var searchMatcher = createClientSearchMatcher(query.search);
|
||||
|
||||
clients = clients.filter(searchMatcher);
|
||||
clients = sortClientsForTable(clients, query.sort, query.direction);
|
||||
|
||||
return clients.slice((query.page - 1) * LIST_PAGE_SIZE, ((query.page - 1) * LIST_PAGE_SIZE) + LIST_PAGE_SIZE);
|
||||
}
|
||||
|
||||
function getClientMoveModalElements() {
|
||||
return {
|
||||
@@ -15,7 +177,8 @@
|
||||
targetSelect: document.getElementById('client-move-screen-target'),
|
||||
connectionInput: document.querySelector('[data-client-move-connection-id]'),
|
||||
deviceInput: document.querySelector('[data-client-move-device-id]'),
|
||||
clientNameInput: document.querySelector('[data-client-move-client-name]')
|
||||
clientNameInput: document.querySelector('[data-client-move-client-name]'),
|
||||
playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,6 +211,7 @@
|
||||
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
|
||||
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
|
||||
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
|
||||
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
|
||||
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
|
||||
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
|
||||
var options = Array.prototype.slice.call(elements.targetSelect.options || []);
|
||||
@@ -69,6 +233,9 @@
|
||||
if (elements.clientNameInput) {
|
||||
elements.clientNameInput.value = clientName;
|
||||
}
|
||||
if (elements.playerBaseUrlInput) {
|
||||
elements.playerBaseUrlInput.value = playerBaseUrl;
|
||||
}
|
||||
elements.targetSelect.value = '';
|
||||
if (elements.form.querySelector('button[type="submit"]')) {
|
||||
elements.form.querySelector('button[type="submit"]').disabled = false;
|
||||
@@ -89,12 +256,12 @@
|
||||
|
||||
return [
|
||||
'<div class="actions justify-content-end">',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>',
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + (blackout ? 'false' : 'true') + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout" aria-label="' + blackoutButtonLabel + '" title="' + blackoutButtonLabel + '"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonText + '</button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + (blackout ? 'false' : 'true') + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout" aria-label="' + blackoutButtonLabel + '" title="' + blackoutButtonLabel + '"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonText + '</button></form>',
|
||||
'</div>'
|
||||
].join('');
|
||||
}
|
||||
@@ -126,6 +293,10 @@
|
||||
if (connectionInput) {
|
||||
connectionInput.value = client.id || '';
|
||||
}
|
||||
var playerBaseUrlInput = pauseForm.querySelector('input[name="playerBaseUrl"]');
|
||||
if (playerBaseUrlInput) {
|
||||
playerBaseUrlInput.value = client.player_url || '';
|
||||
}
|
||||
pauseForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
@@ -141,6 +312,10 @@
|
||||
if (reloadInput) {
|
||||
reloadInput.value = client.id || '';
|
||||
}
|
||||
var reloadPlayerBaseUrlInput = reloadForm.querySelector('input[name="playerBaseUrl"]');
|
||||
if (reloadPlayerBaseUrlInput) {
|
||||
reloadPlayerBaseUrlInput.value = client.player_url || '';
|
||||
}
|
||||
reloadForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?');
|
||||
}
|
||||
@@ -173,6 +348,10 @@
|
||||
if (blackoutConnectionInput) {
|
||||
blackoutConnectionInput.value = client.id || '';
|
||||
}
|
||||
var blackoutPlayerBaseUrlInput = blackoutForm.querySelector('input[name="playerBaseUrl"]');
|
||||
if (blackoutPlayerBaseUrlInput) {
|
||||
blackoutPlayerBaseUrlInput.value = client.player_url || '';
|
||||
}
|
||||
blackoutForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
@@ -194,6 +373,10 @@
|
||||
if (previousConnectionInput) {
|
||||
previousConnectionInput.value = client.id || '';
|
||||
}
|
||||
var previousPlayerBaseUrlInput = previousForm.querySelector('input[name="playerBaseUrl"]');
|
||||
if (previousPlayerBaseUrlInput) {
|
||||
previousPlayerBaseUrlInput.value = client.player_url || '';
|
||||
}
|
||||
previousForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
@@ -215,6 +398,10 @@
|
||||
if (nextConnectionInput) {
|
||||
nextConnectionInput.value = client.id || '';
|
||||
}
|
||||
var nextPlayerBaseUrlInput = nextForm.querySelector('input[name="playerBaseUrl"]');
|
||||
if (nextPlayerBaseUrlInput) {
|
||||
nextPlayerBaseUrlInput.value = client.player_url || '';
|
||||
}
|
||||
nextForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
}
|
||||
@@ -253,7 +440,7 @@
|
||||
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
|
||||
|
||||
return [
|
||||
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
|
||||
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '" data-client-player-base-url="' + escapeHtml(client.player_url || '') + '">',
|
||||
'<td data-label="Client"><div>' + clientName + '</div></td>',
|
||||
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
|
||||
'<td data-label="Current Slide">' + currentSlide + '</td>',
|
||||
@@ -265,26 +452,67 @@
|
||||
].join('');
|
||||
}
|
||||
|
||||
function updateClientRowCells(row, client, hasActionsColumn) {
|
||||
if (!row || !row.cells || row.cells.length < 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
|
||||
var clientIpValue = normalizeDisplayIp(client.clientIp);
|
||||
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
|
||||
var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>';
|
||||
var clientNameValue = getClientDisplayName(client);
|
||||
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
|
||||
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
|
||||
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
|
||||
|
||||
row.setAttribute('data-client-key', escapeHtml(getClientRowKey(client)));
|
||||
row.setAttribute('data-client-id', escapeHtml(client.clientId || ''));
|
||||
row.setAttribute('data-client-device-id', escapeHtml(client.deviceId || ''));
|
||||
row.setAttribute('data-client-screen-slug', escapeHtml(client.screen_slug || ''));
|
||||
row.setAttribute('data-client-player-base-url', escapeHtml(client.player_url || ''));
|
||||
|
||||
setCellHtml(row.cells[0], '<div>' + clientName + '</div>');
|
||||
setCellHtml(row.cells[1], '<div>' + screenName + '</div>');
|
||||
setCellHtml(row.cells[2], currentSlide);
|
||||
setCellHtml(row.cells[3], clientIp);
|
||||
setCellHtml(row.cells[4], viewport);
|
||||
setCellHtml(row.cells[5], connectedAt);
|
||||
|
||||
syncClientActionCell(row, client, hasActionsColumn);
|
||||
}
|
||||
|
||||
function createClientRowFromTemplate(client, hasActionsColumn) {
|
||||
var template = document.createElement('tbody');
|
||||
template.innerHTML = renderClientRow(client, hasActionsColumn);
|
||||
return template.firstElementChild || null;
|
||||
}
|
||||
|
||||
function setCellHtml(cell, html) {
|
||||
if (!cell || String(cell.innerHTML || '') === String(html || '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cell.innerHTML = html;
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderScreenTile(screen) {
|
||||
var clientCount = Number(screen.player_connection_count || 0);
|
||||
var playerUrl = String(screen.player_url || '').trim();
|
||||
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
|
||||
var connectionStateClass = clientCount ? 'is-live' : 'is-idle';
|
||||
var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned';
|
||||
var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected';
|
||||
|
||||
return [
|
||||
'<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
|
||||
'<div class="dashboard-screen-tile-top">',
|
||||
'<div class="dashboard-screen-tile-text">',
|
||||
'<h4 class="dashboard-screen-name">' + escapeHtml(screen.name || '') + '</h4>',
|
||||
'<a class="dashboard-screen-link" href="' + escapeHtml(playerUrl) + '" target="_blank">' + escapeHtml(playerUrl) + '</a>',
|
||||
'</div>',
|
||||
'<span class="dashboard-screen-pill ' + connectionStateClass + '">' + escapeHtml(connectionLabel) + '</span>',
|
||||
'</div>',
|
||||
'<dl class="dashboard-screen-meta">',
|
||||
'<div><dt>Playlist</dt><dd>' + playlistLabel + '</dd></div>',
|
||||
'<div><dt>Connections</dt><dd>' + escapeHtml(connectionsLabel) + '</dd></div>',
|
||||
'</dl>',
|
||||
'</article>'
|
||||
].join('');
|
||||
@@ -310,7 +538,7 @@
|
||||
}
|
||||
|
||||
function updateStats(state) {
|
||||
var clientCount = document.getElementById('dashboard-client-count');
|
||||
var playerCount = document.getElementById('dashboard-player-count');
|
||||
var screenCount = document.getElementById('dashboard-screen-count');
|
||||
var slideCount = document.getElementById('dashboard-slide-count');
|
||||
var playlistCount = document.getElementById('dashboard-playlist-count');
|
||||
@@ -324,81 +552,137 @@
|
||||
if (screenCount && Array.isArray(state.screens)) {
|
||||
screenCount.textContent = String(state.screens.length);
|
||||
}
|
||||
if (clientCount) {
|
||||
clientCount.textContent = String(Number(state.connectedClientsCount || 0));
|
||||
if (playerCount) {
|
||||
playerCount.textContent = String(Number(state.connectedPlayersCount || 0));
|
||||
}
|
||||
}
|
||||
|
||||
function updateClientTable(state) {
|
||||
function updateClientTable(state, forceRender) {
|
||||
var tbody = document.getElementById('dashboard-clients-table-body');
|
||||
if (!tbody || !Array.isArray(state.clients)) {
|
||||
return;
|
||||
}
|
||||
if (!forceRender && isClientSearchLoading()) {
|
||||
return;
|
||||
}
|
||||
var table = document.getElementById('dashboard-clients-table');
|
||||
var hasActionsColumn = Boolean(table && String(table.getAttribute('data-has-actions-column') || '').toLowerCase() === 'true');
|
||||
if (!state.clients.length) {
|
||||
var visibleClients = getVisibleClients(state);
|
||||
|
||||
var canPatchRows = typeof tbody.querySelectorAll === 'function'
|
||||
&& typeof tbody.insertBefore === 'function'
|
||||
&& typeof tbody.removeChild === 'function'
|
||||
&& typeof document.createElement === 'function';
|
||||
|
||||
if (!visibleClients.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canPatchRows) {
|
||||
tbody.innerHTML = visibleClients.map(function (client) {
|
||||
return renderClientRow(client, hasActionsColumn);
|
||||
}).join('');
|
||||
return;
|
||||
}
|
||||
|
||||
var existingRows = {};
|
||||
Array.prototype.slice.call(tbody.querySelectorAll('tr[data-client-key]')).forEach(function (row) {
|
||||
existingRows[row.getAttribute('data-client-key')] = row;
|
||||
existingRows[String(row.getAttribute('data-client-key') || '').trim()] = row;
|
||||
});
|
||||
|
||||
Array.prototype.slice.call(tbody.querySelectorAll('tr')).forEach(function (row) {
|
||||
if (!row.hasAttribute('data-client-key')) {
|
||||
row.parentNode.removeChild(row);
|
||||
var nextRows = visibleClients.map(function (client) {
|
||||
var rowKey = String(getClientRowKey(client) || '').trim();
|
||||
var row = existingRows[rowKey] || null;
|
||||
|
||||
if (!row) {
|
||||
row = createClientRowFromTemplate(client, hasActionsColumn);
|
||||
} else {
|
||||
updateClientRowCells(row, client, hasActionsColumn);
|
||||
}
|
||||
|
||||
return row;
|
||||
}).filter(function (row) {
|
||||
return Boolean(row);
|
||||
});
|
||||
|
||||
state.clients.forEach(function (client, index) {
|
||||
var rowKey = getClientRowKey(client);
|
||||
var row = existingRows[rowKey];
|
||||
if (!row) {
|
||||
var tempBody = document.createElement('tbody');
|
||||
tempBody.innerHTML = renderClientRow(client, hasActionsColumn);
|
||||
row = tempBody.firstElementChild;
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.setAttribute('data-client-key', rowKey);
|
||||
row.setAttribute('data-client-id', client.clientId || '');
|
||||
row.setAttribute('data-client-device-id', client.deviceId || '');
|
||||
row.setAttribute('data-client-screen-slug', client.screen_slug || '');
|
||||
if (row.cells && row.cells.length >= 6) {
|
||||
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
|
||||
var clientIpValue = normalizeDisplayIp(client.clientIp);
|
||||
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
|
||||
var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>';
|
||||
var clientNameValue = getClientDisplayName(client);
|
||||
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
|
||||
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
|
||||
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
|
||||
|
||||
row.cells[0].innerHTML = '<div>' + clientName + '</div>';
|
||||
row.cells[1].innerHTML = '<div>' + screenName + '</div>';
|
||||
row.cells[2].innerHTML = currentSlide;
|
||||
row.cells[3].innerHTML = clientIp;
|
||||
row.cells[4].innerHTML = viewport;
|
||||
row.cells[5].innerHTML = connectedAt;
|
||||
syncClientActionCell(row, client, hasActionsColumn);
|
||||
}
|
||||
|
||||
nextRows.forEach(function (row, index) {
|
||||
var referenceNode = tbody.children[index] || null;
|
||||
if (referenceNode !== row) {
|
||||
tbody.insertBefore(row, referenceNode);
|
||||
}
|
||||
});
|
||||
|
||||
while (tbody.children.length > state.clients.length) {
|
||||
while (tbody.children.length > nextRows.length) {
|
||||
tbody.removeChild(tbody.lastElementChild);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function refreshClientTableFromLatestState() {
|
||||
if (!latestDashboardState) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateClientTable(latestDashboardState, true);
|
||||
}
|
||||
|
||||
function updateKioskLauncherModal(state) {
|
||||
var modal = document.getElementById('dashboard-kiosk-launcher-modal');
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
var checkbox = modal.querySelector('[data-kiosk-launcher-confirm]');
|
||||
var select = modal.querySelector('[data-kiosk-launcher-player-select]');
|
||||
var downloadLinks = Array.prototype.slice.call(modal.querySelectorAll('[data-kiosk-launcher-download]'));
|
||||
var playersState = state && Array.isArray(state.kioskPlayers)
|
||||
? state.kioskPlayers
|
||||
: (state && Array.isArray(state.clients) ? state.clients : []);
|
||||
var players = playersState.filter(function (client) {
|
||||
return Boolean(client && String(client.player_url || '').trim());
|
||||
});
|
||||
|
||||
if (select && playersState.length) {
|
||||
var currentValue = String(select.value || '').trim();
|
||||
var options = ['<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) {
|
||||
var grid = document.getElementById('dashboard-screens-grid');
|
||||
if (!grid || !Array.isArray(state.screens)) {
|
||||
@@ -455,13 +739,22 @@
|
||||
}
|
||||
|
||||
select.disabled = false;
|
||||
if (!select.value || !screenBySlug[select.value]) {
|
||||
select.value = screens[0].slug || '';
|
||||
}
|
||||
|
||||
var selectedScreen = screenBySlug[select.value] || screens[0];
|
||||
var selectedSlug = String(selectedScreen && selectedScreen.slug || '').trim();
|
||||
var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
|
||||
var isAllSelected = Boolean(selectedOption && String(selectedOption.getAttribute('data-screen-target-all') || '').toLowerCase() === 'true') || select.value === ALL_SCREENS_SLUG;
|
||||
var selectedScreen = isAllSelected
|
||||
? {
|
||||
slug: ALL_SCREENS_SLUG,
|
||||
name: String(selectedOption && selectedOption.textContent || ALL_SCREENS_LABEL).trim() || ALL_SCREENS_LABEL
|
||||
}
|
||||
: screenBySlug[select.value] || null;
|
||||
var selectedSlug = isAllSelected
|
||||
? ALL_SCREENS_SLUG
|
||||
: String(selectedScreen && selectedScreen.slug || '').trim();
|
||||
var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) {
|
||||
if (isAllSelected) {
|
||||
return true;
|
||||
}
|
||||
return String(client && client.screen_slug || '').trim() === selectedSlug;
|
||||
}) : [];
|
||||
var connectionCount = selectedClients.length;
|
||||
@@ -480,12 +773,20 @@
|
||||
pill.textContent = connectionLabel;
|
||||
}
|
||||
if (nameNode) {
|
||||
nameNode.textContent = String(selectedScreen && selectedScreen.name || 'Selected screen');
|
||||
nameNode.textContent = selectedScreen
|
||||
? String(selectedScreen.name || 'Selected screen')
|
||||
: 'Select a target screen group';
|
||||
}
|
||||
if (metaNode) {
|
||||
metaNode.textContent = 'Commands sent here target every client currently using this screen.';
|
||||
metaNode.textContent = !selectedSlug
|
||||
? 'Choose a screen group before sending commands.'
|
||||
: isAllSelected
|
||||
? 'Commands sent here target every client across every screen group.'
|
||||
: 'Commands sent here target every client currently using this screen.';
|
||||
}
|
||||
|
||||
var commandTargetSlug = selectedSlug || '';
|
||||
|
||||
forms.forEach(function (form) {
|
||||
var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase();
|
||||
var commandInput = form.querySelector('input[name="command"]');
|
||||
@@ -498,10 +799,12 @@
|
||||
pauseStateInput.value = allPaused ? 'false' : 'true';
|
||||
}
|
||||
if (button) {
|
||||
button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? 'Resume screen' : 'Pause screen');
|
||||
button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? (isAllSelected ? 'Resume all screens' : 'Resume screen') : (isAllSelected ? 'Pause all screens' : 'Pause screen'));
|
||||
}
|
||||
setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
|
||||
form.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients on this screen?' : 'Pause all connected clients on this screen?');
|
||||
form.setAttribute('data-confirm-message', allPaused
|
||||
? (isAllSelected ? 'Resume all connected clients on all screens?' : 'Resume all connected clients on this screen?')
|
||||
: (isAllSelected ? 'Pause all connected clients on all screens?' : 'Pause all connected clients on this screen?'));
|
||||
} else if (command === 'blackout') {
|
||||
commandInput.value = 'blackout';
|
||||
var blackoutStateInput = form.querySelector('input[name="blackout"]');
|
||||
@@ -509,23 +812,25 @@
|
||||
blackoutStateInput.value = allBlackout ? 'false' : 'true';
|
||||
}
|
||||
if (button) {
|
||||
button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? 'Restore screen' : 'Blackout screen');
|
||||
button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? (isAllSelected ? 'Restore all screens' : 'Restore screen') : (isAllSelected ? 'Blackout all screens' : 'Blackout screen'));
|
||||
}
|
||||
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
|
||||
form.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients on this screen?' : 'Blackout all connected clients on this screen?');
|
||||
form.setAttribute('data-confirm-message', allBlackout
|
||||
? (isAllSelected ? 'Restore all connected clients on all screens?' : 'Restore all connected clients on this screen?')
|
||||
: (isAllSelected ? 'Blackout all connected clients on all screens?' : 'Blackout all connected clients on this screen?'));
|
||||
} else {
|
||||
commandInput.value = command || commandInput.value || '';
|
||||
}
|
||||
}
|
||||
form.action = selectedSlug ? '/clients/' + encodeURIComponent(selectedSlug) + '/commands' : '#';
|
||||
form.action = commandTargetSlug ? '/clients/' + encodeURIComponent(commandTargetSlug) + '/commands' : '#';
|
||||
if (command === 'reload') {
|
||||
form.setAttribute('data-confirm-message', 'Reload selected screen?');
|
||||
form.setAttribute('data-confirm-message', isAllSelected ? 'Reload all screens?' : 'Reload selected screen?');
|
||||
if (button) {
|
||||
button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen';
|
||||
button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>' + (isAllSelected ? 'Reload all screens' : 'Reload screen');
|
||||
}
|
||||
}
|
||||
Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) {
|
||||
control.disabled = !selectedSlug;
|
||||
control.disabled = !commandTargetSlug;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -599,10 +904,12 @@
|
||||
return;
|
||||
}
|
||||
latestDashboardState = state;
|
||||
window.webLatestDashboardState = latestDashboardState;
|
||||
updateStats(state);
|
||||
updateScreenGrid(state);
|
||||
updateScreenCommandControls(state);
|
||||
updateClientTable(state);
|
||||
updateKioskLauncherModal(state);
|
||||
updateDashboardQuickActions(state);
|
||||
}
|
||||
|
||||
@@ -773,10 +1080,57 @@
|
||||
});
|
||||
}
|
||||
|
||||
function initKioskLauncherModal() {
|
||||
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]'));
|
||||
|
||||
function resetModalState() {
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
if (select) {
|
||||
select.value = '';
|
||||
}
|
||||
updateKioskLauncherModal(latestDashboardState);
|
||||
}
|
||||
|
||||
if (checkbox) {
|
||||
checkbox.addEventListener('change', function () {
|
||||
updateKioskLauncherModal(latestDashboardState || readScreenCommandStateFromDom());
|
||||
});
|
||||
}
|
||||
|
||||
if (select) {
|
||||
select.addEventListener('change', function () {
|
||||
updateKioskLauncherModal(latestDashboardState || readScreenCommandStateFromDom());
|
||||
});
|
||||
}
|
||||
|
||||
modal.addEventListener('show.bs.modal', resetModalState);
|
||||
modal.addEventListener('hidden.bs.modal', resetModalState);
|
||||
modal.addEventListener('click', function (event) {
|
||||
var target = event.target && event.target.closest ? event.target.closest('[data-kiosk-launcher-download]') : null;
|
||||
if (target && target.classList.contains('disabled')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
resetModalState();
|
||||
}
|
||||
|
||||
window.webHandleDashboardState = handleDashboardState;
|
||||
window.webRefreshClientTableFromLatestState = refreshClientTableFromLatestState;
|
||||
|
||||
initClientRenameHandler();
|
||||
initClientMoveHandler();
|
||||
initKioskLauncherModal();
|
||||
var screenCommandSelect = document.getElementById('screen-command-select');
|
||||
if (screenCommandSelect) {
|
||||
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
|
||||
|
||||
@@ -313,6 +313,7 @@
|
||||
'<div>' +
|
||||
'<div class="api-region-placeholder-title mb-2">Available placeholders</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2">' + renderPlaceholderChips() + '</div>' +
|
||||
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, or <code>{{title.lower()}}</code>.</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
(function () {
|
||||
function buildLivePlayerFeedLabel(connectedPlayersCount) {
|
||||
var count = Number(connectedPlayersCount || 0);
|
||||
if (count <= 0) {
|
||||
return 'Player feed online - no players connected';
|
||||
}
|
||||
|
||||
return 'Live player feed - ' + count + ' player' + (count === 1 ? '' : 's') + ' connected';
|
||||
}
|
||||
|
||||
function updateSidebarStatus(status, label) {
|
||||
var dot = document.getElementById('sidebar-status-dot');
|
||||
var text = document.getElementById('sidebar-status-text');
|
||||
@@ -72,7 +81,7 @@
|
||||
if (!screenCount) {
|
||||
updateSidebarStatus('unknown', 'No screens configured');
|
||||
} else {
|
||||
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
|
||||
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), buildLivePlayerFeedLabel(payload.state && payload.state.connectedPlayersCount));
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
(function () {
|
||||
var minimumPaginationCardHeight = 20 * 16;
|
||||
|
||||
function rebindTableContainer(container) {
|
||||
if (!container) {
|
||||
return;
|
||||
@@ -33,8 +35,18 @@
|
||||
var cardRect = card.getBoundingClientRect();
|
||||
var bottomInset = 16;
|
||||
var availableHeight = Math.max(0, window.innerHeight - cardRect.top - bottomInset);
|
||||
var contentHeight = Math.max(0, card.scrollHeight || 0);
|
||||
var shouldApplyMinimum = contentHeight > minimumPaginationCardHeight;
|
||||
var cardHeight = Math.max(minimumPaginationCardHeight, availableHeight);
|
||||
|
||||
if (shouldApplyMinimum) {
|
||||
card.style.setProperty('--table-pagination-card-min-height', minimumPaginationCardHeight + 'px');
|
||||
} else {
|
||||
card.style.removeProperty('--table-pagination-card-min-height');
|
||||
}
|
||||
|
||||
card.style.removeProperty('--table-pagination-card-height');
|
||||
card.style.setProperty('--table-pagination-card-max-height', availableHeight + 'px');
|
||||
card.style.setProperty('--table-pagination-card-max-height', cardHeight + 'px');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,6 +124,7 @@
|
||||
var currentUrl = new URL(window.location.href);
|
||||
var pendingSearchTimer = null;
|
||||
var requestSequence = 0;
|
||||
var pendingSearchRequests = 0;
|
||||
var container = input.closest('[data-table-search-container]') || input.closest('.card') || null;
|
||||
|
||||
input.value = String(currentUrl.searchParams.get(searchParam) || '').trim();
|
||||
@@ -133,6 +146,10 @@
|
||||
|
||||
requestSequence += 1;
|
||||
var sequenceId = requestSequence;
|
||||
pendingSearchRequests += 1;
|
||||
if (container) {
|
||||
container.setAttribute('data-table-search-loading', 'true');
|
||||
}
|
||||
|
||||
fetch(nextUrl.toString(), {
|
||||
method: 'GET',
|
||||
@@ -165,6 +182,11 @@
|
||||
}
|
||||
}).catch(function () {
|
||||
window.location.assign(nextUrl.toString());
|
||||
}).finally(function () {
|
||||
pendingSearchRequests = Math.max(0, pendingSearchRequests - 1);
|
||||
if (container && pendingSearchRequests === 0) {
|
||||
container.removeAttribute('data-table-search-loading');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,18 +6,82 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
|
||||
const getScreenConnections = deps.getScreenConnections;
|
||||
const isClientNameAvailable = deps.isClientNameAvailable;
|
||||
const withClientNameReservation = deps.withClientNameReservation;
|
||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||
const playerPublicBaseUrl = String(deps.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
|
||||
const requirePermission = deps.requirePermission;
|
||||
const ALL_SCREENS_SLUG = '__all__';
|
||||
|
||||
async function resolveScreenPlayerBaseUrls(screenSlug, connectionId) {
|
||||
if (typeof getScreenConnections !== 'function' || !screenSlug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getScreenConnections(screenSlug);
|
||||
const liveConnections = Array.isArray(response && response.connections) ? response.connections : [];
|
||||
if (!liveConnections.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedConnectionId = String(connectionId || '').trim();
|
||||
const liveConnection = normalizedConnectionId
|
||||
? liveConnections.find(function (connection) {
|
||||
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
|
||||
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
|
||||
return candidateConnectionId === normalizedConnectionId || candidateDeviceId === normalizedConnectionId;
|
||||
})
|
||||
: null;
|
||||
const targetConnections = liveConnection ? [liveConnection] : liveConnections;
|
||||
|
||||
return Array.from(new Set(targetConnections.map(function (connection) {
|
||||
return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
|
||||
}).filter(Boolean)));
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExplicitPlayerBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
async function resolveAllPlayerBaseUrls() {
|
||||
if (!common || typeof common.fetchPlayerRegistrations !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const players = await common.fetchPlayerRegistrations(pool);
|
||||
return Array.from(new Set((Array.isArray(players) ? players : [])
|
||||
.filter(function (player) {
|
||||
return isRecentPlayerRegistration(player, 60);
|
||||
})
|
||||
.map(function (player) {
|
||||
return String(player && player.public_base_url || '').trim().replace(/\/$/, '');
|
||||
})
|
||||
.filter(Boolean)));
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const slug = String(req.params.slug || '').trim();
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
|
||||
const explicitPlayerBaseUrl = normalizeExplicitPlayerBaseUrl((req.body && (req.body.playerBaseUrl || req.body.playerPublicBaseUrl)) || req.query.playerBaseUrl || req.query.playerPublicBaseUrl || '');
|
||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||
? req.body.blackout
|
||||
: req.query.blackout;
|
||||
@@ -29,11 +93,82 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
return res.status(400).json({ error: 'Command is required' });
|
||||
}
|
||||
|
||||
if (slug === ALL_SCREENS_SLUG) {
|
||||
if (command === 'setclientname' || command === 'moveclient') {
|
||||
return res.status(400).json({ error: 'This command requires a specific screen.' });
|
||||
}
|
||||
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC');
|
||||
if (!screenRows.length) {
|
||||
return res.status(404).json({ error: 'No screens found' });
|
||||
}
|
||||
|
||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||
? Object.assign({}, req.body, { command: command })
|
||||
: { command: command };
|
||||
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
||||
commandPayload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
await Promise.all(screenRows.map(function (screenRow) {
|
||||
const screenSlug = String(screenRow && screenRow.slug || '').trim();
|
||||
return resolveScreenPlayerBaseUrls(screenSlug, connectionId).then(function (playerBaseUrls) {
|
||||
if (playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
||||
return Promise.all(playerBaseUrls.map(function (playerBaseUrl) {
|
||||
return forwardPlayerCommandToBaseUrl(playerBaseUrl, screenSlug, commandPayload, connectionId || undefined);
|
||||
}));
|
||||
}
|
||||
|
||||
return forwardPlayerCommand(screenSlug, commandPayload);
|
||||
});
|
||||
}));
|
||||
|
||||
if (typeof broadcastDashboardState === 'function') {
|
||||
await broadcastDashboardState();
|
||||
}
|
||||
|
||||
return res.json({
|
||||
screen: {
|
||||
id: null,
|
||||
name: 'All screens',
|
||||
slug: ALL_SCREENS_SLUG
|
||||
},
|
||||
screenSlug: slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
targetScreenCount: screenRows.length,
|
||||
ok: true,
|
||||
allScreens: true
|
||||
});
|
||||
}
|
||||
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [slug]);
|
||||
if (!screenRows.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
|
||||
if (explicitPlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function' && command !== 'moveclient') {
|
||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||
? Object.assign({}, req.body, { command: command })
|
||||
: { command: command };
|
||||
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
||||
commandPayload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
const result = await forwardPlayerCommandToBaseUrl(explicitPlayerBaseUrl, slug, commandPayload, connectionId || undefined);
|
||||
|
||||
if (typeof broadcastDashboardState === 'function') {
|
||||
await broadcastDashboardState();
|
||||
}
|
||||
|
||||
return res.json(Object.assign({
|
||||
screen: screenRows[0],
|
||||
screenSlug: slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null
|
||||
}, result && typeof result === 'object' ? result : {}));
|
||||
}
|
||||
|
||||
if (command === 'setclientname') {
|
||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
|
||||
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||
@@ -209,16 +344,32 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
}
|
||||
|
||||
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
|
||||
const targetPlayerRecord = typeof common.fetchScreenPlayerRecord === 'function'
|
||||
? await common.fetchScreenPlayerRecord(pool, targetScreenSlug)
|
||||
const liveConnection = Array.isArray(liveConnections)
|
||||
? liveConnections.find(function (connection) {
|
||||
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
|
||||
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
|
||||
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
|
||||
})
|
||||
: null;
|
||||
const targetBaseUrl = String(targetPlayerRecord && targetPlayerRecord.public_base_url || playerPublicBaseUrl || '').trim().replace(/\/$/, '');
|
||||
const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
|
||||
const sourcePlayerBaseUrl = String(
|
||||
explicitPlayerBaseUrl ||
|
||||
(liveConnection && liveConnection.playerPublicBaseUrl) ||
|
||||
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
|
||||
''
|
||||
).trim().replace(/\/$/, '');
|
||||
const targetPlayerUrl = sourcePlayerBaseUrl ? `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
|
||||
|
||||
await forwardPlayerCommand(slug, {
|
||||
command: 'redirect',
|
||||
url: targetPlayerUrl
|
||||
}, connectionId || deviceId || undefined);
|
||||
if (sourcePlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
||||
await forwardPlayerCommandToBaseUrl(sourcePlayerBaseUrl, slug, {
|
||||
command: 'redirect',
|
||||
url: targetPlayerUrl
|
||||
}, connectionId || deviceId || undefined);
|
||||
} else {
|
||||
await forwardPlayerCommand(slug, {
|
||||
command: 'redirect',
|
||||
url: targetPlayerUrl
|
||||
}, connectionId || deviceId || undefined);
|
||||
}
|
||||
|
||||
if (typeof broadcastDashboardState === 'function') {
|
||||
await broadcastDashboardState();
|
||||
@@ -244,9 +395,16 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
commandPayload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
const result = connectionId
|
||||
? await forwardPlayerCommand(slug, commandPayload, connectionId)
|
||||
: await forwardPlayerCommand(slug, commandPayload);
|
||||
const playerBaseUrls = await resolveScreenPlayerBaseUrls(slug, connectionId);
|
||||
const result = playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function'
|
||||
? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) {
|
||||
return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandPayload, connectionId);
|
||||
})).then(function (results) {
|
||||
return Array.isArray(results) && results.length ? results[0] : { ok: true };
|
||||
})
|
||||
: (connectionId
|
||||
? await forwardPlayerCommand(slug, commandPayload, connectionId)
|
||||
: await forwardPlayerCommand(slug, commandPayload));
|
||||
|
||||
if (typeof broadcastDashboardState === 'function') {
|
||||
await broadcastDashboardState();
|
||||
|
||||
@@ -11,7 +11,6 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
|
||||
const getScreenConnections = deps.getScreenConnections;
|
||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const SCREEN_NAME_MAX_LENGTH = 255;
|
||||
@@ -112,7 +111,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, player_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, slug, playlistId, '1', actorId, actorId]);
|
||||
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
|
||||
closeUrl: '/screens',
|
||||
newUrl: '/screens/new',
|
||||
@@ -145,17 +144,17 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
||||
const previousSlug = String(screen.slug || '').trim();
|
||||
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, '1', getAuditUserId(req), screen.id]);
|
||||
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||
}
|
||||
if (previousSlug && previousSlug !== slug) {
|
||||
const playerRecord = typeof common.fetchScreenPlayerRecord === 'function'
|
||||
? await common.fetchScreenPlayerRecord(pool, previousSlug)
|
||||
: null;
|
||||
const playerBaseUrl = typeof common.fetchPlayerPublicBaseUrl === 'function'
|
||||
? await common.fetchPlayerPublicBaseUrl(pool)
|
||||
: '';
|
||||
await forwardPlayerCommand(previousSlug, {
|
||||
command: 'redirect',
|
||||
url: (playerRecord && playerRecord.public_base_url ? String(playerRecord.public_base_url).replace(/\/$/, '') : PLAYER_PUBLIC_BASE_URL) + `/screen/${encodeURIComponent(slug)}`
|
||||
url: playerBaseUrl ? `${playerBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
||||
});
|
||||
}
|
||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Shared route helpers for web view rendering and player URL formatting.
|
||||
|
||||
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -11,8 +9,13 @@ function escapeHtml(value) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function screenPlayerUrl(slug) {
|
||||
return `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`;
|
||||
function screenPlayerUrl(slug, baseUrl) {
|
||||
const normalizedBaseUrl = String(baseUrl || '').trim().replace(/\/$/, '');
|
||||
if (!normalizedBaseUrl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${normalizedBaseUrl}/screen/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -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 registerSettingsRoutes = require('./settings/background-tasks');
|
||||
const registerFontRoutes = require('./settings/fonts');
|
||||
const registerInternalSyncRoutes = require('./internal/sync');
|
||||
const registerScreensRoutes = require('./signage/screens/routes');
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
||||
|
||||
function registerRoutes(app, deps) {
|
||||
@@ -22,6 +24,22 @@ function registerRoutes(app, deps) {
|
||||
registerAuthAndAccountRoutes(app, deps);
|
||||
registerSignageRoutes(app, deps);
|
||||
registerSettingsAndContentRoutes(app, deps);
|
||||
registerInternalSyncRoutes(app, {
|
||||
uploadSyncService: deps.uploadSyncService,
|
||||
mediaDir: deps.mediaDir
|
||||
});
|
||||
registerScreensRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
mediaDir: deps.mediaDir,
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
buildDashboardState: deps.buildDashboardState,
|
||||
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
|
||||
requirePermission: deps.requirePermission,
|
||||
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||
});
|
||||
}
|
||||
|
||||
function registerAuthAndAccountRoutes(app, deps) {
|
||||
@@ -48,7 +66,7 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
buildDashboardState: deps.buildDashboardState,
|
||||
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
|
||||
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
||||
getScreenConnections: deps.playerActionService.getScreenConnections
|
||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||
});
|
||||
|
||||
registerAccountRoutes(app, {
|
||||
@@ -107,7 +125,6 @@ function registerSignageRoutes(app, deps) {
|
||||
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
||||
playerPublicBaseUrl: deps.playerPublicBaseUrl,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
@@ -125,11 +142,11 @@ function registerSignageRoutes(app, deps) {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
||||
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
|
||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||
isClientNameAvailable: deps.isClientNameAvailable,
|
||||
withClientNameReservation: deps.withClientNameReservation,
|
||||
broadcastDashboardState: deps.broadcastDashboardState,
|
||||
playerPublicBaseUrl: deps.playerPublicBaseUrl,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,6 +143,30 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
|
||||
return buildQueryString(nextQuery);
|
||||
}
|
||||
|
||||
function buildTaskPlayerLabel(metadata) {
|
||||
const playerLabel = String(metadata && metadata.playerLabel || '').trim();
|
||||
const playerIdentifier = String(metadata && metadata.playerIdentifier || '').trim();
|
||||
const playerPublicBaseUrl = String(metadata && metadata.playerPublicBaseUrl || '').trim();
|
||||
|
||||
if (playerLabel) {
|
||||
return playerLabel;
|
||||
}
|
||||
|
||||
if (playerIdentifier && playerPublicBaseUrl) {
|
||||
return `${playerIdentifier} (${playerPublicBaseUrl})`;
|
||||
}
|
||||
|
||||
if (playerIdentifier) {
|
||||
return playerIdentifier;
|
||||
}
|
||||
|
||||
if (playerPublicBaseUrl) {
|
||||
return playerPublicBaseUrl;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildQueuePageViewModel(data, message, currentUser) {
|
||||
const tasks = (data && data.tasks) || [];
|
||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
||||
@@ -171,7 +195,10 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
||||
'finishedAt',
|
||||
function (item) { return item && item.metadata && item.metadata.sourceName; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceType; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceId; }
|
||||
function (item) { return item && item.metadata && item.metadata.sourceId; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerLabel; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerIdentifier; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerPublicBaseUrl; }
|
||||
])(task);
|
||||
});
|
||||
const sortedTasks = sortRows(filteredTasks, function (task) {
|
||||
@@ -194,7 +221,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
||||
return task && task.finishedAt;
|
||||
}
|
||||
if (sort === 'source') {
|
||||
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) {
|
||||
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId, task && task.metadata && task.metadata.playerLabel, task && task.metadata && task.metadata.playerIdentifier, task && task.metadata && task.metadata.playerPublicBaseUrl].map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).join(' ');
|
||||
}
|
||||
@@ -234,11 +261,16 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
||||
});
|
||||
|
||||
const visibleTasksWithSources = visibleTasks.map(function (task) {
|
||||
const playerLabel = buildTaskPlayerLabel(task.metadata);
|
||||
const taskDescription = [playerLabel, String(task && task.key || '').trim()].filter(Boolean).join(':');
|
||||
|
||||
return Object.assign({}, task, {
|
||||
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
|
||||
sourceLabel: task.metadata && task.metadata.sourceName ? String(task.metadata.sourceName).trim() : '',
|
||||
sourceTypeLabel: task.metadata && task.metadata.sourceType ? String(task.metadata.sourceType).trim() : '',
|
||||
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : ''
|
||||
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : '',
|
||||
playerLabel: playerLabel,
|
||||
taskDescription: taskDescription
|
||||
});
|
||||
});
|
||||
const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId);
|
||||
@@ -287,7 +319,10 @@ function buildScheduledPageViewModel(data, message, currentUser) {
|
||||
'lastError',
|
||||
function (item) { return item && item.metadata && item.metadata.sourceName; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceType; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceId; }
|
||||
function (item) { return item && item.metadata && item.metadata.sourceId; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerLabel; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerIdentifier; },
|
||||
function (item) { return item && item.metadata && item.metadata.playerPublicBaseUrl; }
|
||||
])(task);
|
||||
});
|
||||
const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) {
|
||||
@@ -309,7 +344,7 @@ function buildScheduledPageViewModel(data, message, currentUser) {
|
||||
}).join(' ');
|
||||
}
|
||||
if (sort === 'source') {
|
||||
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) {
|
||||
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId, task && task.metadata && task.metadata.playerLabel, task && task.metadata && task.metadata.playerIdentifier, task && task.metadata && task.metadata.playerPublicBaseUrl].map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).join(' ');
|
||||
}
|
||||
@@ -325,7 +360,8 @@ function buildScheduledPageViewModel(data, message, currentUser) {
|
||||
backgroundTasksMenuOpen: true,
|
||||
recurringTasks: sortedRecurringTasks.map(function (task) {
|
||||
return Object.assign({}, task, {
|
||||
intervalLabel: formatIntervalLabel(task.intervalMs)
|
||||
intervalLabel: formatIntervalLabel(task.intervalMs),
|
||||
playerLabel: buildTaskPlayerLabel(task.metadata)
|
||||
});
|
||||
}),
|
||||
summary: summary,
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = function registerClientsRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const buildDashboardState = deps.buildDashboardState;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
|
||||
const { compareSortValues, createSearchMatcher, getComparableSortValue } = require('../../../lib/list-query');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
@@ -29,13 +29,32 @@ module.exports = function registerClientsRoutes(app, deps) {
|
||||
connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); }
|
||||
};
|
||||
|
||||
if (!accessors[normalizedSortKey]) {
|
||||
return Array.isArray(clients) ? clients.slice() : [];
|
||||
function compareValues(leftValue, rightValue) {
|
||||
return compareSortValues(getComparableSortValue(leftValue), getComparableSortValue(rightValue));
|
||||
}
|
||||
|
||||
return sortRows(clients, function (client) {
|
||||
return accessors[normalizedSortKey](client);
|
||||
}, normalizedDirection);
|
||||
const sortKeys = accessors[normalizedSortKey]
|
||||
? [normalizedSortKey]
|
||||
: ['client'];
|
||||
|
||||
if (sortKeys[0] === 'client') {
|
||||
sortKeys.push('ip');
|
||||
} else if (sortKeys[0] === 'ip') {
|
||||
sortKeys.push('client');
|
||||
}
|
||||
|
||||
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
|
||||
for (let index = 0; index < sortKeys.length; index += 1) {
|
||||
const sortKeyName = sortKeys[index];
|
||||
const comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
|
||||
|
||||
if (comparison !== 0) {
|
||||
return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
|
||||
|
||||
@@ -21,8 +21,10 @@ module.exports = function renderDashboardPage(data, message, currentUser) {
|
||||
playlists: data.playlists || [],
|
||||
screens: data.screens || [],
|
||||
clients: data.clients || [],
|
||||
kioskPlayers: data.kioskPlayers || [],
|
||||
slides: data.slides || [],
|
||||
connectedClientsCount: Number(data.connectedClientsCount || 0),
|
||||
connectedPlayersCount: Number(data.connectedPlayersCount || data.connectedClientsCount || 0),
|
||||
primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null,
|
||||
scripts: ['js/dashboard/dashboard-page.js']
|
||||
});
|
||||
|
||||
@@ -142,11 +142,14 @@ function getVideoDurationSeconds(slide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
|
||||
const videoRegions = Object.keys(parsed).map((key) => parsed[key]).filter((region) => {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||
});
|
||||
|
||||
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||
const duration = videoRegions.reduce((longest, region) => {
|
||||
const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000;
|
||||
return regionDuration > longest ? regionDuration : longest;
|
||||
}, 0);
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
|
||||
@@ -4,7 +4,8 @@ function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
|
||||
const viewScreen = Object.assign({
|
||||
name: '',
|
||||
slug: '',
|
||||
playlist_id: null
|
||||
playlist_id: null,
|
||||
player_urls: []
|
||||
}, screen || {});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Screen route registration and dashboard wiring.
|
||||
|
||||
const fs = require('fs');
|
||||
const { screenPlayerUrl } = require('../../../routes/common');
|
||||
|
||||
module.exports = function registerScreensRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
@@ -9,7 +12,32 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||
const lastSeenAt = player && player.last_seen_at;
|
||||
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
|
||||
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
|
||||
|
||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
||||
}
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const batTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.bat');
|
||||
const shTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.sh');
|
||||
const launcherDownloadPaths = {
|
||||
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
|
||||
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
|
||||
};
|
||||
|
||||
function normalizeTargetPlayerUrl(value) {
|
||||
const normalized = String(value || '').trim().replace(/\/$/, '');
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||
return function (req, res, next) {
|
||||
@@ -50,11 +78,60 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
|
||||
return Object.assign({}, screen, {
|
||||
player_connection_count: playerConnectionCount,
|
||||
player_url: dashboardScreen && dashboardScreen.player_url ? dashboardScreen.player_url : screen.player_url || null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function buildPlayerUrl() {
|
||||
if (typeof common.fetchPlayerPublicBaseUrl !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return common.fetchPlayerPublicBaseUrl(pool);
|
||||
}
|
||||
|
||||
async function buildScreenPlayerUrls(screen) {
|
||||
const playerRegistrations = typeof common.fetchPlayerRegistrations === 'function'
|
||||
? await common.fetchPlayerRegistrations(pool)
|
||||
: [];
|
||||
|
||||
return (Array.isArray(playerRegistrations) ? playerRegistrations : []).filter(function (player) {
|
||||
return isRecentPlayerRegistration(player, 60);
|
||||
}).map(function (player) {
|
||||
const baseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, '');
|
||||
const playerUrl = screenPlayerUrl(screen && screen.slug ? screen.slug : '', baseUrl);
|
||||
return {
|
||||
identifier: String(player && player.identifier || '').trim(),
|
||||
public_base_url: baseUrl || null,
|
||||
player_url: playerUrl || null
|
||||
};
|
||||
}).sort(function (left, right) {
|
||||
return String(left && left.identifier || '').localeCompare(String(right && right.identifier || ''), undefined, { sensitivity: 'base', numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
function buildLauncherContent(templatePath, playerUrl) {
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
const normalizedPlayerUrl = String(playerUrl || '').trim();
|
||||
if (!normalizedPlayerUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (template.indexOf('http://localhost:8081') === -1) {
|
||||
throw new Error(`Launcher template is missing the default player URL placeholder: ${templatePath}`);
|
||||
}
|
||||
|
||||
return template.replace('http://localhost:8081', normalizedPlayerUrl);
|
||||
}
|
||||
|
||||
function sendLauncherDownload(res, fileName, content) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.set('Pragma', 'no-cache');
|
||||
res.attachment(fileName);
|
||||
res.type('text/plain; charset=utf-8');
|
||||
res.send(content);
|
||||
}
|
||||
|
||||
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
if (req.query.edit) {
|
||||
@@ -62,6 +139,10 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
screen.player_urls = await buildScreenPlayerUrls(screen);
|
||||
if (screen.player_urls.length) {
|
||||
screen.launcher_downloads = launcherDownloadPaths;
|
||||
}
|
||||
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
@@ -71,18 +152,9 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const dashboardState = await buildDashboardState(pool);
|
||||
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
||||
? await common.fetchScreenPlayerUrls(pool)
|
||||
: {};
|
||||
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
res.send(pages.renderScreensPage({
|
||||
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []).map(function (screen) {
|
||||
const slug = String(screen && screen.slug || '').trim();
|
||||
const registryUrl = slug && playerUrlsBySlug[slug] ? String(playerUrlsBySlug[slug]).trim() : '';
|
||||
return Object.assign({}, screen, {
|
||||
player_url: screen.player_url || registryUrl || null
|
||||
});
|
||||
}),
|
||||
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []),
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
@@ -96,10 +168,10 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
||||
? await common.fetchScreenPlayerUrls(pool)
|
||||
: {};
|
||||
screen.player_url = screen.slug && playerUrlsBySlug[screen.slug] ? String(playerUrlsBySlug[screen.slug]).trim() : null;
|
||||
screen.player_urls = await buildScreenPlayerUrls(screen);
|
||||
if (screen.player_urls.length) {
|
||||
screen.launcher_downloads = launcherDownloadPaths;
|
||||
}
|
||||
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
@@ -107,5 +179,43 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/downloads/kiosk/pulse-signage-kiosk.bat', requirePermission('screens.update'), async function (_req, res, next) {
|
||||
try {
|
||||
const playerUrl = normalizeTargetPlayerUrl(_req.query && _req.query.playerUrl)
|
||||
|| await buildPlayerUrl();
|
||||
if (!playerUrl) {
|
||||
return res.status(404).send('Player URL is not available yet.');
|
||||
}
|
||||
|
||||
const content = buildLauncherContent(batTemplatePath, playerUrl);
|
||||
if (!content) {
|
||||
return res.status(404).send('Launcher template is not available.');
|
||||
}
|
||||
|
||||
sendLauncherDownload(res, 'pulse-signage-kiosk.bat', content);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/downloads/kiosk/pulse-signage-kiosk.sh', requirePermission('screens.update'), async function (_req, res, next) {
|
||||
try {
|
||||
const playerUrl = normalizeTargetPlayerUrl(_req.query && _req.query.playerUrl)
|
||||
|| await buildPlayerUrl();
|
||||
if (!playerUrl) {
|
||||
return res.status(404).send('Player URL is not available yet.');
|
||||
}
|
||||
|
||||
const content = buildLauncherContent(shTemplatePath, playerUrl);
|
||||
if (!content) {
|
||||
return res.status(404).send('Launcher template is not available.');
|
||||
}
|
||||
|
||||
sendLauncherDownload(res, 'pulse-signage-kiosk.sh', content);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
// Slide route registration and pagination wiring.
|
||||
|
||||
const { renderFragment } = require('../../../view');
|
||||
const path = require('path');
|
||||
const { verifyRequestAuth } = require('#src/request-auth');
|
||||
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
|
||||
const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnail-preview');
|
||||
|
||||
function safeJsonForScript(value) {
|
||||
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
|
||||
}
|
||||
|
||||
module.exports = function registerSlidesRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const common = deps.common;
|
||||
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 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;
|
||||
|
||||
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'
|
||||
}));
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
{{#if key}}
|
||||
<div class="text-muted small text-break">{{key}}</div>
|
||||
{{#if taskDescription}}
|
||||
<div class="text-muted small text-break">{{taskDescription}}</div>
|
||||
{{/if}}
|
||||
{{#if errorMessage}}
|
||||
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
{{#if playerLabel}}
|
||||
<div class="text-muted small text-break">Player: {{playerLabel}}</div>
|
||||
{{/if}}
|
||||
<div class="text-muted small text-break">{{key}}</div>
|
||||
</td>
|
||||
<td data-label="Interval">{{intervalLabel}}</td>
|
||||
|
||||
@@ -9,62 +9,64 @@
|
||||
<div class="card card-outline card-secondary mb-4 screen-command-card">
|
||||
<div class="card-header">
|
||||
<div class="dashboard-card-heading">
|
||||
<h3 class="card-title">Screen-level controls</h3>
|
||||
<h3 class="card-title">Screen Group controls</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{#if screens.length}}
|
||||
<div class="screen-command-panel">
|
||||
<div class="screen-command-panel-left">
|
||||
<div class="screen-command-selector-field">
|
||||
<label class="screen-command-selector-label" for="screen-command-select">Target screen group</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-broadcast"></i></span>
|
||||
<select
|
||||
id="screen-command-select"
|
||||
class="form-select"
|
||||
aria-label="Target screen group"
|
||||
data-screen-command-select
|
||||
>
|
||||
{{#each screens}}
|
||||
<option
|
||||
value="{{slug}}"
|
||||
data-screen-name="{{name}}"
|
||||
data-player-connection-count="{{player_connection_count}}"
|
||||
data-playlist-name="{{playlist_name}}"
|
||||
>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
{{#if screens.length}}
|
||||
<div class="screen-command-panel">
|
||||
<div class="screen-command-panel-left">
|
||||
<div class="screen-command-selector-field">
|
||||
<label class="screen-command-selector-label" for="screen-command-select">Target screen group</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-broadcast"></i></span>
|
||||
<select
|
||||
id="screen-command-select"
|
||||
class="form-select"
|
||||
aria-label="Target screen group"
|
||||
data-screen-command-select
|
||||
>
|
||||
<option value="" selected disabled>Select Screen Group</option>
|
||||
{{#each screens}}
|
||||
<option
|
||||
value="{{slug}}"
|
||||
data-screen-name="{{name}}"
|
||||
data-player-connection-count="{{player_connection_count}}"
|
||||
data-playlist-name="{{playlist_name}}"
|
||||
>{{name}}</option>
|
||||
{{/each}}
|
||||
<option value="__all__" data-screen-name="All screens" data-screen-target-all="true">All Screens</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="screen-command-actions">
|
||||
<form method="post" action="#" class="inline-form" data-confirm-message="Reload selected screen?" data-async-command data-screen-command-form data-screen-command-action="reload">
|
||||
<input type="hidden" name="command" value="reload" />
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen</button>
|
||||
</form>
|
||||
<form method="post" action="#" class="inline-form" data-confirm-message="Pause selected screen?" data-async-command data-screen-command-form data-screen-command-action="pause">
|
||||
<input type="hidden" name="command" value="pause" />
|
||||
<input type="hidden" name="paused" value="true" />
|
||||
<button type="submit" class="btn btn-sm btn-info"><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause screen</button>
|
||||
</form>
|
||||
<form method="post" action="#" class="inline-form" data-confirm-message="Blackout selected screen?" data-async-command data-screen-command-form data-screen-command-action="blackout">
|
||||
<input type="hidden" name="command" value="blackout" />
|
||||
<input type="hidden" name="blackout" value="true" />
|
||||
<button type="submit" class="btn btn-sm btn-secondary"><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>Blackout screen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="screen-command-actions">
|
||||
<form method="post" action="#" class="inline-form" data-confirm-message="Reload selected screen?" data-async-command data-screen-command-form data-screen-command-action="reload">
|
||||
<input type="hidden" name="command" value="reload" />
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen</button>
|
||||
</form>
|
||||
<form method="post" action="#" class="inline-form" data-confirm-message="Pause selected screen?" data-async-command data-screen-command-form data-screen-command-action="pause">
|
||||
<input type="hidden" name="command" value="pause" />
|
||||
<input type="hidden" name="paused" value="true" />
|
||||
<button type="submit" class="btn btn-sm btn-info"><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause screen</button>
|
||||
</form>
|
||||
<form method="post" action="#" class="inline-form" data-confirm-message="Blackout selected screen?" data-async-command data-screen-command-form data-screen-command-action="blackout">
|
||||
<input type="hidden" name="command" value="blackout" />
|
||||
<input type="hidden" name="blackout" value="true" />
|
||||
<button type="submit" class="btn btn-sm btn-secondary"><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>Blackout screen</button>
|
||||
</form>
|
||||
<div class="screen-command-panel-right">
|
||||
<div class="screen-command-summary">
|
||||
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
|
||||
<strong data-screen-command-name>Select a target screen group</strong>
|
||||
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen group.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="screen-command-panel-right">
|
||||
<div class="screen-command-summary">
|
||||
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
|
||||
<strong data-screen-command-name>Select a target screen group</strong>
|
||||
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen group.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty">No screen groups are available to target yet.</div>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div class="empty">No screen groups are available to target yet.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -95,7 +97,7 @@
|
||||
<tbody id="dashboard-clients-table-body">
|
||||
{{#if clients.length}}
|
||||
{{#each clients}}
|
||||
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}">
|
||||
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}" data-client-player-base-url="{{player_url}}">
|
||||
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
|
||||
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
|
||||
</td>
|
||||
@@ -127,33 +129,38 @@
|
||||
<span class="empty">Unknown</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
{{#if (hasPermission currentUser 'clients.allow')}}
|
||||
{{#if (hasPermission ../currentUser 'clients.allow')}}
|
||||
<td data-label="Actions" class="text-end">
|
||||
<div class="actions justify-content-end">
|
||||
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-confirm-message="Reloading will restart the player page. Continue?" data-async-command>
|
||||
<input type="hidden" name="command" value="reload" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
|
||||
<button type="submit" class="btn btn-sm btn-danger" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen group" title="Move client to another screen group"><i class="bi bi-display" aria-hidden="true"></i></button>
|
||||
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="previous" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
|
||||
<button type="submit" class="btn btn-sm btn-warning" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="next" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
|
||||
<button type="submit" class="btn btn-sm btn-warning" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="pause" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
|
||||
<button type="submit" class="btn btn-sm btn-info" aria-label="Pause client" title="Pause client"><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause</button>
|
||||
</form>
|
||||
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="blackout" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
|
||||
<button type="submit" class="btn btn-sm {{#if blackout}}btn-success{{else}}btn-secondary{{/if}}" aria-label="{{#if blackout}}Restore client{{else}}Blackout client{{/if}}" title="{{#if blackout}}Restore client{{else}}Blackout client{{/if}}"><i class="bi {{#if blackout}}bi-eye{{else}}bi-eye-slash{{/if}} me-1" aria-hidden="true"></i>{{#if blackout}}Restore{{else}}Blackout{{/if}}</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -183,6 +190,7 @@
|
||||
<input type="hidden" name="connectionId" value="" data-client-move-connection-id />
|
||||
<input type="hidden" name="deviceId" value="" data-client-move-device-id />
|
||||
<input type="hidden" name="clientName" value="" data-client-move-client-name />
|
||||
<input type="hidden" name="playerBaseUrl" value="" data-client-move-player-base-url />
|
||||
<label class="form-label" for="client-move-screen-target">Target screen group</label>
|
||||
<select class="form-select" id="client-move-screen-target" name="targetScreenSlug" data-client-move-target required>
|
||||
{{#if screens.length}}
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser "clients.read")}}
|
||||
<div class="dashboard-hero-stat">
|
||||
<span class="dashboard-hero-stat-value">{{connectedClientsCount}}</span>
|
||||
<span class="dashboard-hero-stat-label">clients</span>
|
||||
<span id="dashboard-player-count" class="dashboard-hero-stat-value">{{connectedPlayersCount}}</span>
|
||||
<span class="dashboard-hero-stat-label">players</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -43,9 +43,9 @@
|
||||
</section>
|
||||
|
||||
{{#if (hasPermission currentUser "dashboard.allow")}}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card mb-4 card-outline card-primary dashboard-actions-card">
|
||||
<div class="row pb-4">
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card card-outline card-primary dashboard-actions-card">
|
||||
<div class="card-header">
|
||||
<div class="dashboard-card-heading">
|
||||
<h3 class="card-title">Quick actions</h3>
|
||||
@@ -108,7 +108,80 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card card-outline card-secondary dashboard-launcher-card h-100">
|
||||
<div class="card-header">
|
||||
<div class="dashboard-card-heading">
|
||||
<h3 class="card-title">Kiosk launchers</h3>
|
||||
<p class="dashboard-card-subtitle">Open the player base URL on a device for onboarding.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="dashboard-launcher-row h-100">
|
||||
<p class="mb-0 text-muted small dashboard-launcher-copy">Download the Windows or Linux launcher to start a browser in kiosk mode and open the correct player page for onboarding.</p>
|
||||
<div class="dashboard-launcher-actions h-100">
|
||||
<button type="button" class="btn btn-outline-secondary btn-lg d-inline-flex align-items-center justify-content-center dashboard-launcher-open-button h-100" data-bs-toggle="modal" data-bs-target="#dashboard-kiosk-launcher-modal">
|
||||
<i class="bi bi-download" aria-hidden="true"></i>
|
||||
<span></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{#> modal-shell modalId="dashboard-kiosk-launcher-modal" modalLabelId="dashboard-kiosk-launcher-modal-label" modalDialogClass="modal-dialog-centered modal-lg"}}
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 class="modal-title fs-5" id="dashboard-kiosk-launcher-modal-label">Kiosk launcher downloads</h2>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body d-grid gap-4">
|
||||
<div class="alert alert-info mb-0">
|
||||
<p class="mb-2 fw-semibold">Kiosk mode is for dedicated signage screens.</p>
|
||||
<ul class="mb-0 ps-3">
|
||||
<li>The script looks for a supported browser on the device and launches the first one it finds.</li>
|
||||
<li>The launcher opens the correct player page automatically, so no manual URL entry is needed.</li>
|
||||
<li>It uses a kiosk-mode browser window, so the screen stays focused on signage instead of normal browsing.</li>
|
||||
<li>Exit with Alt+F4 on Windows or Linux. On Linux, that is usually the standard close-window shortcut too.</li>
|
||||
</ul>
|
||||
<div class="mt-3 pt-3 border-top">
|
||||
<p class="mb-0 text-muted small">These downloads are scripts, not applications: the Windows download is a .bat file and the Linux download is a .sh file.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-kiosk-launcher-confirmation p-3 border rounded-3 bg-body-tertiary">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" value="1" id="dashboard-kiosk-launcher-confirm" data-kiosk-launcher-confirm />
|
||||
<label class="form-check-label fw-semibold fs-5" for="dashboard-kiosk-launcher-confirm">I Understand</label>
|
||||
</div>
|
||||
<p class="mb-0 mt-2 text-muted small">Check this box to enable the Windows and Linux downloads.</p>
|
||||
</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">
|
||||
<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>
|
||||
<span>Download for Windows</span>
|
||||
</a>
|
||||
<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>
|
||||
<span>Download for Linux</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{/modal-shell}}
|
||||
{{/if}}
|
||||
|
||||
{{#if (hasPermission currentUser "screens.read")}}
|
||||
@@ -118,8 +191,7 @@
|
||||
<div class="card-header dashboard-screen-card-header">
|
||||
<div class="dashboard-card-heading">
|
||||
<h3 class="card-title">Screen group snapshot</h3>
|
||||
<p class="dashboard-card-subtitle">Player URL, playlist assignment,
|
||||
and live connection count without the spreadsheet feel.</p>
|
||||
<p class="dashboard-card-subtitle">Playlist assignment and live connection state without the spreadsheet feel.</p>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-primary" href="/screens">Manage screen groups</a>
|
||||
</div>
|
||||
@@ -131,11 +203,6 @@
|
||||
<div class="dashboard-screen-tile-top">
|
||||
<div class="dashboard-screen-tile-text">
|
||||
<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>
|
||||
<span class="dashboard-screen-pill {{#if player_connection_count}}is-live{{else}}is-idle{{/if}}">
|
||||
{{#if player_connection_count}}
|
||||
@@ -150,10 +217,6 @@
|
||||
<dt>Playlist</dt>
|
||||
<dd>{{#if playlist_name}}{{playlist_name}}{{else}}Unassigned{{/if}}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Connections</dt>
|
||||
<dd>{{#if player_connection_count}}{{player_connection_count}} connected{{else}}No clients connected{{/if}}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
{{/each}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
|
||||
<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-header">
|
||||
<h3 class="card-title">Screen group details</h3>
|
||||
@@ -40,16 +40,37 @@
|
||||
</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-header">
|
||||
<h3 class="card-title">Player URL</h3>
|
||||
<h3 class="card-title">Player URLs</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{#if screen.player_url}}
|
||||
<p class="mb-0"><a href="{{screen.player_url}}" target="_blank">{{screen.player_url}}</a></p>
|
||||
<div class="card-body table-responsive p-0">
|
||||
{{#if screen.player_urls.length}}
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each screen.player_urls}}
|
||||
<tr>
|
||||
<td data-label="Player" class="text-break">{{identifier}}</td>
|
||||
<td class="text-break">
|
||||
{{#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>
|
||||
{{else}}
|
||||
<p class="mb-0 text-muted">No player URL is available yet.</p>
|
||||
<p class="mb-0 p-3 text-muted">No player URLs are available yet.</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<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="player_connection_count">Connected</th>
|
||||
<th>Actions</th>
|
||||
@@ -34,13 +33,6 @@
|
||||
{{#each screens}}
|
||||
<tr data-table-search-row>
|
||||
<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="Connected clients">
|
||||
{{#if (gt player_connection_count 0)}}
|
||||
@@ -72,7 +64,7 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{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}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>
|
||||
</div>
|
||||
|
||||
{{#if popupPreviewPayloadJson}}
|
||||
<script>
|
||||
window.__pulsePopupPreviewPayload = {{{popupPreviewPayloadJson}}};
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function startPreviewVideoPlayback(root) {
|
||||
@@ -127,6 +133,10 @@
|
||||
var canvas = document.getElementById('popup-preview-canvas');
|
||||
|
||||
function readPayload() {
|
||||
if (window.__pulsePopupPreviewPayload) {
|
||||
return window.__pulsePopupPreviewPayload;
|
||||
}
|
||||
|
||||
var hash = String(window.location.hash || '').replace(/^#/, '');
|
||||
if (!hash) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerScreenCommandRoutes = require('../src/web/routes/admin/client-commands');
|
||||
|
||||
function createHandlers() {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
post(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
|
||||
return { app, handlers };
|
||||
}
|
||||
|
||||
test('move client rebinding redirects the live player to the target screen', async () => {
|
||||
const calls = [];
|
||||
const { app, handlers } = createHandlers();
|
||||
|
||||
registerScreenCommandRoutes(app, {
|
||||
pool: {
|
||||
async query(sql, params) {
|
||||
calls.push({ kind: 'query', sql, params });
|
||||
|
||||
if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
|
||||
return [[{ slug: 'source-screen' }, { slug: 'target-screen' }]];
|
||||
}
|
||||
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-screen') {
|
||||
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
|
||||
}
|
||||
if (sql.includes('SELECT d.client_name, s.slug AS current_screen_slug')) {
|
||||
return [[{ client_name: 'Lobby Client', current_screen_slug: 'source-screen' }]];
|
||||
}
|
||||
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'target-screen') {
|
||||
return [[{ id: 27, name: 'Target Screen', slug: 'target-screen' }]];
|
||||
}
|
||||
if (sql.includes('INSERT INTO d_onboarding_devices')) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {},
|
||||
forwardPlayerCommand(slug, payload, connectionId) {
|
||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
|
||||
return { ok: true };
|
||||
},
|
||||
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
|
||||
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
|
||||
return { ok: true };
|
||||
},
|
||||
getScreenConnections: async (slug) => {
|
||||
if (slug === 'source-screen') {
|
||||
return {
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
clientId: 'conn-1',
|
||||
deviceId: 'device-123',
|
||||
playerPublicBaseUrl: 'http://remote-player.example'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return { connections: [] };
|
||||
},
|
||||
isClientNameAvailable: async () => true,
|
||||
withClientNameReservation: async (_pool, _name, callback) => callback(),
|
||||
broadcastDashboardState: async () => {
|
||||
calls.push({ kind: 'broadcastDashboardState' });
|
||||
},
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const routeHandlers = handlers['/clients/:slug/commands'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await routeHandlers[1]({
|
||||
params: { slug: 'source-screen' },
|
||||
body: {
|
||||
command: 'moveclient',
|
||||
deviceId: 'device-123',
|
||||
clientName: 'Lobby Client',
|
||||
targetScreenSlug: 'target-screen',
|
||||
connectionId: 'conn-1',
|
||||
playerBaseUrl: 'http://remote-player.example'
|
||||
},
|
||||
query: {}
|
||||
}, response, () => {});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.ok, true);
|
||||
assert.equal(response.body.targetScreenSlug, 'target-screen');
|
||||
assert.equal(response.body.playerUrl, 'http://remote-player.example/screen/target-screen');
|
||||
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
||||
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example' && entry.payload && entry.payload.command === 'redirect'), true);
|
||||
});
|
||||
|
||||
test('screen control commands can target all screens', async () => {
|
||||
const calls = [];
|
||||
const { app, handlers } = createHandlers();
|
||||
|
||||
registerScreenCommandRoutes(app, {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
calls.push({ kind: 'query', sql });
|
||||
|
||||
if (String(sql || '').includes('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC')) {
|
||||
return [[
|
||||
{ id: 1, name: 'Alpha', slug: 'alpha' },
|
||||
{ id: 2, name: 'Beta', slug: 'beta' }
|
||||
]];
|
||||
}
|
||||
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {},
|
||||
forwardPlayerCommand(slug, payload) {
|
||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
||||
return { ok: true };
|
||||
},
|
||||
getScreenConnections: async () => ({ connections: [] }),
|
||||
isClientNameAvailable: async () => true,
|
||||
withClientNameReservation: async (_pool, _name, callback) => callback(),
|
||||
broadcastDashboardState: async () => {
|
||||
calls.push({ kind: 'broadcastDashboardState' });
|
||||
},
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const routeHandlers = handlers['/clients/:slug/commands'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await routeHandlers[1]({
|
||||
params: { slug: '__all__' },
|
||||
body: {
|
||||
command: 'pause',
|
||||
paused: 'true'
|
||||
},
|
||||
query: {}
|
||||
}, response, () => {});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.ok, true);
|
||||
assert.equal(response.body.allScreens, true);
|
||||
assert.equal(response.body.targetScreenCount, 2);
|
||||
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommand').map((entry) => entry.slug), ['alpha', 'beta']);
|
||||
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
||||
});
|
||||
|
||||
test('screen control commands use the live connection player url when available', async () => {
|
||||
const calls = [];
|
||||
const { app, handlers } = createHandlers();
|
||||
|
||||
registerScreenCommandRoutes(app, {
|
||||
pool: {
|
||||
async query(sql, params) {
|
||||
calls.push({ kind: 'query', sql, params });
|
||||
|
||||
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-screen') {
|
||||
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
|
||||
}
|
||||
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {},
|
||||
forwardPlayerCommand(slug, payload, connectionId) {
|
||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
|
||||
return { ok: true };
|
||||
},
|
||||
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
|
||||
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
|
||||
return { ok: true };
|
||||
},
|
||||
getScreenConnections: async (slug) => {
|
||||
if (slug === 'source-screen') {
|
||||
return {
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
clientId: 'conn-1',
|
||||
deviceId: 'device-123',
|
||||
playerPublicBaseUrl: 'http://local-player.example'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return { connections: [] };
|
||||
},
|
||||
isClientNameAvailable: async () => true,
|
||||
withClientNameReservation: async (_pool, _name, callback) => callback(),
|
||||
broadcastDashboardState: async () => {
|
||||
calls.push({ kind: 'broadcastDashboardState' });
|
||||
},
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const routeHandlers = handlers['/clients/:slug/commands'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await routeHandlers[1]({
|
||||
params: { slug: 'source-screen' },
|
||||
body: {
|
||||
command: 'pause',
|
||||
connectionId: 'conn-1'
|
||||
},
|
||||
query: {}
|
||||
}, response, () => {});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.ok, true);
|
||||
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://local-player.example'), true);
|
||||
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
|
||||
});
|
||||
|
||||
test('screen control commands fan out to every live player for the selected screen', async () => {
|
||||
const calls = [];
|
||||
const { app, handlers } = createHandlers();
|
||||
|
||||
registerScreenCommandRoutes(app, {
|
||||
pool: {
|
||||
async query(sql, params) {
|
||||
calls.push({ kind: 'query', sql, params });
|
||||
|
||||
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-screen') {
|
||||
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
|
||||
}
|
||||
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {},
|
||||
forwardPlayerCommand(slug, payload, connectionId) {
|
||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
|
||||
return { ok: true };
|
||||
},
|
||||
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
|
||||
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
|
||||
return { ok: true };
|
||||
},
|
||||
getScreenConnections: async (slug) => {
|
||||
if (slug === 'source-screen') {
|
||||
return {
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
clientId: 'conn-1',
|
||||
deviceId: 'device-123',
|
||||
playerPublicBaseUrl: 'http://local-player-a.example'
|
||||
},
|
||||
{
|
||||
id: 'conn-2',
|
||||
clientId: 'conn-2',
|
||||
deviceId: 'device-456',
|
||||
playerPublicBaseUrl: 'http://local-player-b.example'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return { connections: [] };
|
||||
},
|
||||
isClientNameAvailable: async () => true,
|
||||
withClientNameReservation: async (_pool, _name, callback) => callback(),
|
||||
broadcastDashboardState: async () => {
|
||||
calls.push({ kind: 'broadcastDashboardState' });
|
||||
},
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const routeHandlers = handlers['/clients/:slug/commands'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await routeHandlers[1]({
|
||||
params: { slug: 'source-screen' },
|
||||
body: {
|
||||
command: 'pause'
|
||||
},
|
||||
query: {}
|
||||
}, response, () => {});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.ok, true);
|
||||
assert.deepEqual(
|
||||
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
|
||||
['http://local-player-a.example', 'http://local-player-b.example']
|
||||
);
|
||||
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
|
||||
});
|
||||
|
||||
test('all screens commands fan out across the live player for each screen', async () => {
|
||||
const calls = [];
|
||||
const { app, handlers } = createHandlers();
|
||||
|
||||
registerScreenCommandRoutes(app, {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
calls.push({ kind: 'query', sql });
|
||||
|
||||
if (String(sql || '').includes('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC')) {
|
||||
return [[
|
||||
{ id: 1, name: 'Alpha', slug: 'alpha' },
|
||||
{ id: 2, name: 'Beta', slug: 'beta' }
|
||||
]];
|
||||
}
|
||||
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {},
|
||||
forwardPlayerCommand(slug, payload) {
|
||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
||||
return { ok: true };
|
||||
},
|
||||
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload) {
|
||||
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
|
||||
return { ok: true };
|
||||
},
|
||||
getScreenConnections: async (slug) => {
|
||||
if (slug === 'alpha') {
|
||||
return {
|
||||
connections: [
|
||||
{
|
||||
id: 'alpha-1',
|
||||
clientId: 'alpha-1',
|
||||
deviceId: 'alpha-device',
|
||||
playerPublicBaseUrl: 'http://player-a.example'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (slug === 'beta') {
|
||||
return {
|
||||
connections: [
|
||||
{
|
||||
id: 'beta-1',
|
||||
clientId: 'beta-1',
|
||||
deviceId: 'beta-device',
|
||||
playerPublicBaseUrl: 'http://player-b.example'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return { connections: [] };
|
||||
},
|
||||
isClientNameAvailable: async () => true,
|
||||
withClientNameReservation: async (_pool, _name, callback) => callback(),
|
||||
broadcastDashboardState: async () => {
|
||||
calls.push({ kind: 'broadcastDashboardState' });
|
||||
},
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const routeHandlers = handlers['/clients/:slug/commands'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await routeHandlers[1]({
|
||||
params: { slug: '__all__' },
|
||||
body: {
|
||||
command: 'pause'
|
||||
},
|
||||
query: {}
|
||||
}, response, () => {});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.ok, true);
|
||||
assert.equal(response.body.allScreens, true);
|
||||
assert.equal(response.body.targetScreenCount, 2);
|
||||
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
||||
assert.deepEqual(
|
||||
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
|
||||
['http://player-a.example', 'http://player-b.example']
|
||||
);
|
||||
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
|
||||
});
|
||||
@@ -18,7 +18,10 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
|
||||
status: 'completed',
|
||||
createdAt: '2026-08-04T10:15:00.000Z',
|
||||
startedAt: '2026-08-04T10:16:00.000Z',
|
||||
finishedAt: '2026-08-04T10:17:00.000Z'
|
||||
finishedAt: '2026-08-04T10:17:00.000Z',
|
||||
metadata: {
|
||||
playerLabel: 'Player Alpha'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Older task',
|
||||
@@ -53,7 +56,10 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
|
||||
key: 'refresh',
|
||||
intervalMs: 60000,
|
||||
nextRunAt: '2026-08-04T10:15:00.000Z',
|
||||
lastRunAt: '2026-08-04T09:15:00.000Z'
|
||||
lastRunAt: '2026-08-04T09:15:00.000Z',
|
||||
metadata: {
|
||||
playerIdentifier: 'Player Beta'
|
||||
}
|
||||
}
|
||||
],
|
||||
summary: { counts: {}, total: 1, activeCount: 0, scheduledCount: 1 }
|
||||
@@ -66,7 +72,9 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
|
||||
|
||||
assert.match(queueHtml, /data-confirm-message="Clear finished background tasks\?"/);
|
||||
assert.match(queueHtml, /data-local-datetime-format="24h"/);
|
||||
assert.match(queueHtml, /Player Alpha:secret-key/);
|
||||
assert.doesNotMatch(queueKeySearchHtml, /Example task/);
|
||||
assert.match(queueDateSearchHtml, /Example task/);
|
||||
assert.match(scheduledHtml, /data-local-datetime-format="24h"/);
|
||||
assert.match(scheduledHtml, /Player:\s+Player Beta/);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerClientsRoutes = require('../src/web/routes/signage/clients/routes');
|
||||
const renderConnectedClientsPage = require('../src/web/routes/signage/clients/list');
|
||||
|
||||
function createHandlers() {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
|
||||
return { app, handlers };
|
||||
}
|
||||
|
||||
test('clients list defaults to client then ip ordering', async () => {
|
||||
const { app, handlers } = createHandlers();
|
||||
|
||||
registerClientsRoutes(app, {
|
||||
pool: {},
|
||||
common: {
|
||||
getSearchQuery() {
|
||||
return '';
|
||||
},
|
||||
getSortQuery() {
|
||||
return '';
|
||||
},
|
||||
getSortDirectionQuery() {
|
||||
return 'asc';
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
renderConnectedClientsPage(data) {
|
||||
return JSON.stringify(data.clients.map(function (client) {
|
||||
return {
|
||||
client_name: client.client_name,
|
||||
clientIp: client.clientIp
|
||||
};
|
||||
}));
|
||||
}
|
||||
},
|
||||
buildDashboardState: async () => ({
|
||||
screens: [],
|
||||
clients: [
|
||||
{ client_name: 'Beta', clientIp: '10.0.0.9' },
|
||||
{ client_name: 'Alpha', clientIp: '10.0.0.20' },
|
||||
{ client_name: 'Alpha', clientIp: '10.0.0.2' }
|
||||
]
|
||||
}),
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const routeHandlers = handlers['/clients'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
send(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await routeHandlers[1]({
|
||||
query: {},
|
||||
currentUser: { id: 1 }
|
||||
}, response, () => {});
|
||||
|
||||
assert.deepEqual(JSON.parse(response.body), [
|
||||
{ client_name: 'Alpha', clientIp: '10.0.0.2' },
|
||||
{ client_name: 'Alpha', clientIp: '10.0.0.20' },
|
||||
{ client_name: 'Beta', clientIp: '10.0.0.9' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('clients page renders action cells for clients with permission', () => {
|
||||
const html = renderConnectedClientsPage(
|
||||
{
|
||||
screens: [],
|
||||
clients: [
|
||||
{
|
||||
id: 'row-1',
|
||||
clientId: 'client-1',
|
||||
screen_slug: 'demo-lobby',
|
||||
screen_name: 'Demo Lobby Screen',
|
||||
client_name: 'Local Test',
|
||||
clientIp: '192.168.0.1',
|
||||
player_url: 'http://player.local',
|
||||
connectedAt: new Date('2026-08-07T20:00:00Z'),
|
||||
connectedAtLabel: 'Aug 07, 2026, 8:00:00 PM'
|
||||
}
|
||||
],
|
||||
pagination: null
|
||||
},
|
||||
'',
|
||||
{ id: 1, permissions: ['clients.read', 'clients.allow'] }
|
||||
);
|
||||
|
||||
assert.match(html, /data-label="Actions"/);
|
||||
assert.match(html, /Pause client/);
|
||||
assert.match(html, /Blackout client/);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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'
|
||||
});
|
||||
});
|
||||
|
||||
test('dashboard state keeps other screens when one subscription fails', async () => {
|
||||
const service = createDashboardStateService({
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (String(sql || '').includes('COUNT(*) AS connected_count')) {
|
||||
return [[{ connected_count: 0 }]];
|
||||
}
|
||||
if (String(sql || '').includes('SELECT s.slug, pod.device_id, pod.client_name')) {
|
||||
return [[]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {
|
||||
async fetchAdminData() {
|
||||
return {
|
||||
screens: [
|
||||
{ slug: 'alpha', name: 'Alpha' },
|
||||
{ slug: 'beta', name: 'Beta' }
|
||||
],
|
||||
playlists: [],
|
||||
slides: []
|
||||
};
|
||||
},
|
||||
async fetchScreenPlayerUrls() {
|
||||
return {};
|
||||
},
|
||||
async fetchPlayerRegistrations() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
playerSnapshotCache: new Map([
|
||||
['beta', { count: 1, connections: [{ id: 'beta-conn', clientId: 'beta-conn' }] }]
|
||||
]),
|
||||
playerSnapshotSockets: new Map(),
|
||||
ensurePlayerSnapshotSubscription(slug) {
|
||||
if (slug === 'alpha') {
|
||||
throw new Error('subscription failed');
|
||||
}
|
||||
},
|
||||
formatDashboardDate(value) {
|
||||
return String(value || '');
|
||||
}
|
||||
});
|
||||
|
||||
const state = await service.buildDashboardState();
|
||||
|
||||
assert.equal(state.screens.length, 2);
|
||||
assert.equal(state.clients.length, 1);
|
||||
assert.equal(state.clients[0].screen_slug, 'beta');
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { createPlayerActionService } = require('../src/web/lib/player-actions');
|
||||
|
||||
test('player actions prefer the exact configured player registration over a remote FQDN row', async () => {
|
||||
const fetchCalls = [];
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async function (url, init) {
|
||||
fetchCalls.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async json() {
|
||||
return { ok: true };
|
||||
},
|
||||
async text() {
|
||||
return JSON.stringify({ ok: true });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const playerActionService = createPlayerActionService({
|
||||
common: {},
|
||||
pool: {
|
||||
async query() {
|
||||
return [[
|
||||
{
|
||||
identifier: 'player-local',
|
||||
internal_base_url: 'http://player:8081'
|
||||
},
|
||||
{
|
||||
identifier: 'player-remote',
|
||||
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
|
||||
}
|
||||
]];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const originalIdentifier = process.env.PLAYER_IDENTIFIER;
|
||||
process.env.PLAYER_IDENTIFIER = 'player-local';
|
||||
|
||||
try {
|
||||
const response = await playerActionService.forwardPlayerCommand('demo', { command: 'refresh' });
|
||||
|
||||
assert.deepEqual(response, { ok: true });
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0].url, 'http://player:8081/api/screens/demo/commands');
|
||||
} finally {
|
||||
process.env.PLAYER_IDENTIFIER = originalIdentifier;
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('player actions merge screen connections from every recent player registration', async () => {
|
||||
const fetchCalls = [];
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async function (url, init) {
|
||||
fetchCalls.push({ url, init });
|
||||
if (String(url).includes('player-a.example')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get() { return null; } },
|
||||
async json() {
|
||||
return {
|
||||
screenSlug: 'demo',
|
||||
connections: [
|
||||
{ id: 'a-1', playerPublicBaseUrl: 'http://player-a.example' }
|
||||
]
|
||||
};
|
||||
},
|
||||
async text() {
|
||||
return JSON.stringify({ ok: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get() { return null; } },
|
||||
async json() {
|
||||
return {
|
||||
screenSlug: 'demo',
|
||||
connections: [
|
||||
{ id: 'b-1', playerPublicBaseUrl: 'http://player-b.example' }
|
||||
]
|
||||
};
|
||||
},
|
||||
async text() {
|
||||
return JSON.stringify({ ok: true });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const playerActionService = createPlayerActionService({
|
||||
common: {},
|
||||
pool: {
|
||||
async query() {
|
||||
return [[
|
||||
{
|
||||
identifier: 'player-a',
|
||||
public_base_url: 'http://player-a.example',
|
||||
last_seen_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
identifier: 'player-b',
|
||||
public_base_url: 'http://player-b.example',
|
||||
last_seen_at: new Date().toISOString()
|
||||
}
|
||||
]];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await playerActionService.getScreenConnections('demo');
|
||||
|
||||
assert.equal(response.count, 2);
|
||||
assert.deepEqual(response.connections.map(function (connection) { return connection.id; }), ['a-1', 'b-1']);
|
||||
assert.equal(fetchCalls.length, 2);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const originalWebBaseUrl = process.env.WEB_BASE_URL;
|
||||
const { resolveWebBaseUrl, resolveScreenCommandTargets } = require('../src/player-bridge/index');
|
||||
|
||||
test.after(() => {
|
||||
if (originalWebBaseUrl === undefined) {
|
||||
delete process.env.WEB_BASE_URL;
|
||||
} else {
|
||||
process.env.WEB_BASE_URL = originalWebBaseUrl;
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveWebBaseUrl prefers WEB_BASE_URL', () => {
|
||||
process.env.WEB_BASE_URL = 'https://web.example.test/app/';
|
||||
|
||||
const resolved = resolveWebBaseUrl({
|
||||
headers: {
|
||||
host: 'bridge.example.test',
|
||||
'x-forwarded-host': 'bridge.example.test',
|
||||
'x-forwarded-proto': 'https'
|
||||
},
|
||||
socket: { encrypted: true }
|
||||
});
|
||||
|
||||
assert.equal(resolved, 'https://web.example.test/app');
|
||||
});
|
||||
|
||||
test('resolveWebBaseUrl keeps external https hosts on the default port', () => {
|
||||
delete process.env.WEB_BASE_URL;
|
||||
|
||||
const resolved = resolveWebBaseUrl({
|
||||
headers: {
|
||||
host: 'bridge.example.test',
|
||||
'x-forwarded-host': 'bridge.example.test',
|
||||
'x-forwarded-proto': 'https'
|
||||
},
|
||||
socket: { encrypted: true }
|
||||
});
|
||||
|
||||
assert.equal(resolved, 'https://bridge.example.test');
|
||||
});
|
||||
|
||||
test('resolveScreenCommandTargets only returns open sockets for the requested screen', () => {
|
||||
const playerSockets = new Map([
|
||||
['player-a', { readyState: 1 }],
|
||||
['player-b', { readyState: 3 }],
|
||||
['player-c', { readyState: 1 }]
|
||||
]);
|
||||
const screenPlayerDeviceIds = new Map([
|
||||
['demo-conference', ['player-a', 'player-b', 'player-a', 'player-missing']],
|
||||
['other-screen', ['player-c']]
|
||||
]);
|
||||
|
||||
const targets = resolveScreenCommandTargets('demo-conference', playerSockets, screenPlayerDeviceIds);
|
||||
|
||||
assert.deepEqual(targets.map(function (target) {
|
||||
return target.deviceId;
|
||||
}), ['player-a']);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
function loadScript(scriptPath, sandbox) {
|
||||
const source = fs.readFileSync(scriptPath, 'utf8');
|
||||
vm.runInNewContext(source, sandbox, { filename: scriptPath });
|
||||
}
|
||||
|
||||
function createStyleStore() {
|
||||
const values = Object.create(null);
|
||||
return {
|
||||
values,
|
||||
getPropertyValue(name) {
|
||||
return values[name] || '';
|
||||
},
|
||||
setProperty(name, value) {
|
||||
values[name] = String(value || '');
|
||||
},
|
||||
removeProperty(name) {
|
||||
delete values[name];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('primeSlideMarkup restores the current canvas dimensions', () => {
|
||||
const style = createStyleStore();
|
||||
style.setProperty('--player-canvas-width', '1920px');
|
||||
style.setProperty('--player-canvas-height', '1080px');
|
||||
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: { style }
|
||||
},
|
||||
Date,
|
||||
JSON,
|
||||
Math,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Array,
|
||||
Object,
|
||||
Promise,
|
||||
currentPlaylistSignature: 'signature',
|
||||
currentPlaylistEtag: '',
|
||||
currentPlaylistFadeBetweenSlides: false,
|
||||
currentPlaylistSkipUnavailableRtmp: false,
|
||||
videoRegionRenderVersion: 0,
|
||||
lastRenderedSlide: { id: 1 },
|
||||
slideMarkupCache: Object.create(null),
|
||||
templateLayoutCache: Object.create(null),
|
||||
templateRenderPlanCache: Object.create(null),
|
||||
renderCacheViewportKey: '',
|
||||
window: null,
|
||||
innerWidth: 1280,
|
||||
innerHeight: 720,
|
||||
pulsePlayerRegionTypes: {
|
||||
get() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
syncRenderCacheViewport() {},
|
||||
syncBlackoutState() {},
|
||||
setPlayerCanvasDimensions() {},
|
||||
escapeHtml(value) {
|
||||
return String(value || '');
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
|
||||
const renderingPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-rendering.js');
|
||||
loadScript(renderingPath, sandbox);
|
||||
|
||||
const slide = {
|
||||
id: 2,
|
||||
kind: 'image',
|
||||
media_url: 'https://example.com/next.jpg',
|
||||
modified_at: '2026-08-07T00:00:00.000Z'
|
||||
};
|
||||
|
||||
const markup = sandbox.primeSlideMarkup(slide);
|
||||
|
||||
assert.match(markup, /<img src="https:\/\/example\.com\/next\.jpg"/);
|
||||
assert.equal(style.getPropertyValue('--player-canvas-width'), '1920px');
|
||||
assert.equal(style.getPropertyValue('--player-canvas-height'), '1080px');
|
||||
assert.deepEqual(sandbox.lastRenderedSlide, { id: 1 });
|
||||
});
|
||||
|
||||
test('scheduleSlideMarkupPreload warms the next slide only', () => {
|
||||
const timers = [];
|
||||
const calls = [];
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: { style: createStyleStore() }
|
||||
},
|
||||
Date,
|
||||
JSON,
|
||||
Math,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Array,
|
||||
Object,
|
||||
Promise,
|
||||
currentPlaylistSignature: 'signature',
|
||||
currentPlaylistEtag: '',
|
||||
currentPlaylistFadeBetweenSlides: false,
|
||||
currentPlaylistSkipUnavailableRtmp: false,
|
||||
videoRegionRenderVersion: 0,
|
||||
innerWidth: 1280,
|
||||
innerHeight: 720,
|
||||
slideMarkupCache: Object.create(null),
|
||||
templateLayoutCache: Object.create(null),
|
||||
templateRenderPlanCache: Object.create(null),
|
||||
renderCacheViewportKey: '',
|
||||
escapeHtml(value) {
|
||||
return String(value || '');
|
||||
},
|
||||
syncRenderCacheViewport() {},
|
||||
syncBlackoutState() {},
|
||||
setPlayerCanvasDimensions() {},
|
||||
primeSlideMarkup(slide) {
|
||||
calls.push(slide.id);
|
||||
return 'markup:' + slide.id;
|
||||
},
|
||||
setTimeout(fn) {
|
||||
timers.push(fn);
|
||||
return timers.length;
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
|
||||
const playlistPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js');
|
||||
loadScript(playlistPath, sandbox);
|
||||
|
||||
const current = { id: 1 };
|
||||
const next = { id: 2 };
|
||||
const later = { id: 3 };
|
||||
|
||||
sandbox.scheduleSlideMarkupPreload([current, next, later], 0);
|
||||
assert.equal(timers.length, 1);
|
||||
timers.shift()();
|
||||
assert.deepEqual(calls, [2]);
|
||||
});
|
||||
@@ -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,130 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
function loadCommandsScript(sandbox) {
|
||||
const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
|
||||
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
|
||||
vm.runInNewContext(commandsScript, sandbox, { filename: commandsPath });
|
||||
}
|
||||
|
||||
function createSandbox() {
|
||||
const rafCallbacks = [];
|
||||
const calls = {
|
||||
syncRtmpRegions: 0,
|
||||
initRegion: 0,
|
||||
playRegionAnimations: 0
|
||||
};
|
||||
|
||||
const app = {
|
||||
children: [],
|
||||
firstElementChild: null,
|
||||
innerHTMLValue: '',
|
||||
classList: {
|
||||
contains() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
set innerHTML(value) {
|
||||
this.innerHTMLValue = String(value || '');
|
||||
this.firstElementChild = this.innerHTMLValue ? { isConnected: true } : null;
|
||||
},
|
||||
get innerHTML() {
|
||||
return this.innerHTMLValue;
|
||||
},
|
||||
appendChild(node) {
|
||||
if (node) {
|
||||
node.isConnected = true;
|
||||
this.children.push(node);
|
||||
this.firstElementChild = this.firstElementChild || node;
|
||||
}
|
||||
return node;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
window: null,
|
||||
Date,
|
||||
JSON,
|
||||
Math,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Array,
|
||||
Object,
|
||||
Promise,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
console,
|
||||
requestAnimationFrame(callback) {
|
||||
rafCallbacks.push(callback);
|
||||
return rafCallbacks.length;
|
||||
},
|
||||
app,
|
||||
slides: [],
|
||||
index: 0,
|
||||
currentPlaylistSignature: 'signature',
|
||||
currentPlaylistFadeBetweenSlides: false,
|
||||
currentPlaylistSkipUnavailableRtmp: false,
|
||||
pendingPlaylistUpdate: null,
|
||||
slideMarkupCache: Object.create(null),
|
||||
templateLayoutCache: Object.create(null),
|
||||
templateRenderPlanCache: Object.create(null),
|
||||
renderCacheViewportKey: '',
|
||||
slideTransitionTimer: null,
|
||||
slideFadeDurationMs: 560,
|
||||
slideExpiresAt: null,
|
||||
pausedRemainingMs: null,
|
||||
timer: null,
|
||||
lastRenderedSlide: null,
|
||||
destroyRtmpRegions() {},
|
||||
syncRtmpRegions() {
|
||||
calls.syncRtmpRegions += 1;
|
||||
},
|
||||
initializeRegionInstances() {
|
||||
calls.initializeRegionInstances += 1;
|
||||
},
|
||||
playRegionAnimations() {
|
||||
calls.playRegionAnimations += 1;
|
||||
},
|
||||
pulsePlayerRegionTypes: {
|
||||
list() {
|
||||
return [
|
||||
{
|
||||
definition: {
|
||||
initRegion() {
|
||||
calls.initRegion += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
},
|
||||
isThumbnailPreview() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.requestAnimationFrame = sandbox.requestAnimationFrame;
|
||||
return { sandbox, calls, rafCallbacks, app };
|
||||
}
|
||||
|
||||
test('renderSlideMarkup runs post-render setup immediately', () => {
|
||||
const { sandbox, calls, rafCallbacks, app } = createSandbox();
|
||||
loadCommandsScript(sandbox);
|
||||
|
||||
const returned = sandbox.renderSlideMarkup('<div class="slide">visible</div>', false);
|
||||
|
||||
assert.equal(app.innerHTMLValue, '<div class="slide">visible</div>');
|
||||
assert.equal(calls.syncRtmpRegions, 1);
|
||||
assert.equal(calls.initRegion, 1);
|
||||
assert.equal(calls.playRegionAnimations, 1);
|
||||
assert.equal(rafCallbacks.length, 0);
|
||||
assert.equal(returned, app.firstElementChild);
|
||||
});
|
||||
@@ -56,7 +56,7 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
||||
id: 101,
|
||||
title: 'Intro',
|
||||
template_id: 33,
|
||||
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"textRegion":{"type":"text","value":"Hello"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
|
||||
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"backupVideo":{"type":"video","duration_seconds":20.1111},"textRegion":{"type":"text","value":"Hello"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
|
||||
modified_at: '2026-08-03T00:00:01.000Z',
|
||||
position: 1,
|
||||
duration_seconds: 9,
|
||||
@@ -123,9 +123,10 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
||||
|
||||
assert.equal(payload.screen.slug, 'test2');
|
||||
assert.equal(payload.playlist.name, 'Playlist 22');
|
||||
assert.equal(payload.slides[0].duration_seconds, 12.346);
|
||||
assert.equal(payload.slides[0].duration_seconds, 20.111);
|
||||
assert.equal(payload.slides[0].disable_audio, true);
|
||||
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
|
||||
assert.equal(payload.slides[0].content.backupVideo.disable_audio, true);
|
||||
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
|
||||
assert.equal(payload.slides[1].disable_audio, true);
|
||||
assert.equal(payload.slides[1].content.rtmpRegion.disable_audio, true);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
function loadScript(scriptPath, sandbox) {
|
||||
const source = fs.readFileSync(scriptPath, 'utf8');
|
||||
vm.runInNewContext(source, sandbox, { filename: scriptPath });
|
||||
}
|
||||
|
||||
test('webpage preloading only targets the next slide', () => {
|
||||
const sandbox = {
|
||||
window: null,
|
||||
Date,
|
||||
Array,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Object,
|
||||
Math,
|
||||
console,
|
||||
escapeHtml(value) {
|
||||
return String(value || '');
|
||||
},
|
||||
currentPlaylistSignature: 'signature',
|
||||
slides: [],
|
||||
index: 0,
|
||||
activeSlidesCacheKey: '',
|
||||
activeSlidesCacheValue: [],
|
||||
renderCacheViewportKey: '',
|
||||
preloadSignature: '',
|
||||
preloadContainer: null
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
|
||||
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
|
||||
|
||||
const current = { id: 1 };
|
||||
const next = { id: 2 };
|
||||
const later = { id: 3 };
|
||||
|
||||
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 0).map((slide) => slide.id).join(','), '2');
|
||||
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 1).map((slide) => slide.id).join(','), '3');
|
||||
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 2).map((slide) => slide.id).join(','), '');
|
||||
});
|
||||
|
||||
test('rtmp warmups only target the next slide', () => {
|
||||
const sandbox = {
|
||||
window: null,
|
||||
Date,
|
||||
Array,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Object,
|
||||
Math,
|
||||
console,
|
||||
fetch() {
|
||||
throw new Error('not expected');
|
||||
},
|
||||
currentPlaylistSkipUnavailableRtmp: false,
|
||||
videoRegionRenderVersion: 0,
|
||||
slideMarkupCache: Object.create(null),
|
||||
slides: [],
|
||||
index: 0,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
document: {
|
||||
createElement() {
|
||||
return {
|
||||
className: '',
|
||||
setAttribute() {},
|
||||
appendChild() {},
|
||||
parentNode: null,
|
||||
addEventListener() {}
|
||||
};
|
||||
},
|
||||
body: {
|
||||
appendChild() {}
|
||||
}
|
||||
},
|
||||
pulsePlayerRegionTypes: {
|
||||
register() {}
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
|
||||
loadScript(path.join(__dirname, '..', 'src', 'player', 'regions', 'rtmp.js'), sandbox);
|
||||
|
||||
const current = { id: 1 };
|
||||
const next = { id: 2 };
|
||||
const later = { id: 3 };
|
||||
|
||||
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 0).map((slide) => slide.id).join(','), '2');
|
||||
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 1).map((slide) => slide.id).join(','), '3');
|
||||
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 2).map((slide) => slide.id).join(','), '');
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -54,4 +54,33 @@ test('playlist rows show mute controls for RTMP slides', () => {
|
||||
assert.equal(model.playlistSlides[0].showMuteButton, true);
|
||||
assert.equal(model.playlistSlides[1].showMuteButton, true);
|
||||
assert.equal(model.playlistSlides[2].showMuteButton, false);
|
||||
});
|
||||
|
||||
test('playlist rows use the longest video region duration', () => {
|
||||
const model = buildPlaylistFormViewModel(
|
||||
{ id: 23, name: 'Playlist 23' },
|
||||
{
|
||||
playlistSlides: [
|
||||
{
|
||||
id: 4,
|
||||
playlist_id: 23,
|
||||
slide_id: 104,
|
||||
position: 1,
|
||||
title: 'Multi-video slide',
|
||||
content_json: '{"firstVideo":{"type":"video","value":"/media/first.mp4","duration_seconds":12.5},"secondVideo":{"type":"video","value":"/media/second.mp4","duration_seconds":27.75},"textRegion":{"type":"text","value":"Hello"}}',
|
||||
duration_seconds: 10,
|
||||
use_video_duration: 1,
|
||||
disable_audio: 1
|
||||
}
|
||||
],
|
||||
slides: []
|
||||
},
|
||||
'',
|
||||
null,
|
||||
{ isEdit: true }
|
||||
);
|
||||
|
||||
assert.equal(model.playlistSlides[0].showVideoDurationButton, true);
|
||||
assert.equal(model.playlistSlides[0].videoDurationSeconds, 27.75);
|
||||
assert.equal(model.playlistSlides[0].durationSeconds, 27.75);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
test('table pagination cards skip the minimum height for short content', () => {
|
||||
const cardStyle = {
|
||||
properties: {},
|
||||
setProperty(name, value) {
|
||||
this.properties[name] = String(value);
|
||||
},
|
||||
removeProperty(name) {
|
||||
delete this.properties[name];
|
||||
}
|
||||
};
|
||||
|
||||
const card = {
|
||||
style: cardStyle,
|
||||
scrollHeight: 200,
|
||||
getBoundingClientRect() {
|
||||
return { top: 100 };
|
||||
}
|
||||
};
|
||||
|
||||
const bodyClassList = {
|
||||
toggled: null,
|
||||
toggle(name, value) {
|
||||
this.toggled = { name, value };
|
||||
}
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
document: {
|
||||
body: {
|
||||
classList: bodyClassList
|
||||
},
|
||||
querySelector(selector) {
|
||||
return selector === '[data-table-pagination-card]' ? card : null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
return selector === '[data-table-pagination-card]' ? [card] : [];
|
||||
}
|
||||
},
|
||||
window: null,
|
||||
URL,
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
};
|
||||
|
||||
sandbox.window = Object.assign(sandbox, {
|
||||
innerHeight: 280,
|
||||
addEventListener() {},
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
requestAnimationFrame(callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'table', 'table-search.js');
|
||||
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||
vm.runInNewContext(script, sandbox, { filename: scriptPath });
|
||||
|
||||
assert.equal(cardStyle.properties['--table-pagination-card-min-height'], undefined);
|
||||
assert.equal(cardStyle.properties['--table-pagination-card-max-height'], '320px');
|
||||
assert.deepEqual(bodyClassList.toggled, { name: 'table-pagination-page', value: true });
|
||||
});
|
||||
|
||||
test('table pagination cards keep the minimum height for tall content', () => {
|
||||
const cardStyle = {
|
||||
properties: {},
|
||||
setProperty(name, value) {
|
||||
this.properties[name] = String(value);
|
||||
},
|
||||
removeProperty(name) {
|
||||
delete this.properties[name];
|
||||
}
|
||||
};
|
||||
|
||||
const card = {
|
||||
style: cardStyle,
|
||||
scrollHeight: 640,
|
||||
getBoundingClientRect() {
|
||||
return { top: 100 };
|
||||
}
|
||||
};
|
||||
|
||||
const bodyClassList = {
|
||||
toggled: null,
|
||||
toggle(name, value) {
|
||||
this.toggled = { name, value };
|
||||
}
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
document: {
|
||||
body: {
|
||||
classList: bodyClassList
|
||||
},
|
||||
querySelector(selector) {
|
||||
return selector === '[data-table-pagination-card]' ? card : null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
return selector === '[data-table-pagination-card]' ? [card] : [];
|
||||
}
|
||||
},
|
||||
window: null,
|
||||
URL,
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
};
|
||||
|
||||
sandbox.window = Object.assign(sandbox, {
|
||||
innerHeight: 280,
|
||||
addEventListener() {},
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
requestAnimationFrame(callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'table', 'table-search.js');
|
||||
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||
vm.runInNewContext(script, sandbox, { filename: scriptPath });
|
||||
|
||||
assert.equal(cardStyle.properties['--table-pagination-card-min-height'], '320px');
|
||||
assert.equal(cardStyle.properties['--table-pagination-card-max-height'], '320px');
|
||||
assert.deepEqual(bodyClassList.toggled, { name: 'table-pagination-page', value: true });
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const express = require('express');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { createUploadSyncService } = require('../src/web/lib/media');
|
||||
|
||||
test('multipart uploads accept large text fields used by slide and template forms', async () => {
|
||||
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-'));
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
common: {},
|
||||
playerSnapshotCache: new Map(),
|
||||
notifyPlayerScreens: async () => {}
|
||||
});
|
||||
const upload = uploadSyncService.createUploadMiddleware(uploadDir);
|
||||
const app = express();
|
||||
|
||||
app.post('/upload', upload.any(), function (_req, res) {
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
app.use(function (error, _req, res, _next) {
|
||||
res.status(error.statusCode || 500).send(String(error && error.message ? error.message : error));
|
||||
});
|
||||
|
||||
const server = app.listen(0);
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
const largeValue = 'x'.repeat(1_100_000);
|
||||
const formData = new FormData();
|
||||
formData.set('region_text_6', largeValue);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
assert.equal(response.status, 204);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fqdn player registration wins over a local configured player target for media sync', async () => {
|
||||
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-'));
|
||||
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
|
||||
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
|
||||
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
|
||||
|
||||
const fetchCalls = [];
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async function (url, init) {
|
||||
fetchCalls.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
get() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async text() {
|
||||
return JSON.stringify({ ok: true });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
common: {},
|
||||
pool: {
|
||||
async query() {
|
||||
return [[
|
||||
{
|
||||
identifier: 'player-local',
|
||||
internal_base_url: 'http://player:8081',
|
||||
last_seen_at: activeLastSeenAt
|
||||
},
|
||||
{
|
||||
identifier: 'player-remote',
|
||||
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
|
||||
last_seen_at: activeLastSeenAt
|
||||
}
|
||||
]];
|
||||
}
|
||||
},
|
||||
playerSnapshotCache: new Map(),
|
||||
notifyPlayerScreens: async () => {}
|
||||
});
|
||||
|
||||
try {
|
||||
const success = await uploadSyncService.pushUploadFileToPlayer('/media/uploads/sample.bin', uploadDir);
|
||||
|
||||
assert.equal(success, true);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin');
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('media sync retry warnings include the resolved player label', async () => {
|
||||
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-log-'));
|
||||
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
|
||||
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
|
||||
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const originalWarn = console.warn;
|
||||
const warned = [];
|
||||
console.warn = function () {
|
||||
warned.push(Array.from(arguments).join(' '));
|
||||
};
|
||||
global.fetch = async function () {
|
||||
const error = new Error('getaddrinfo ENOTFOUND player-remote');
|
||||
error.code = 'ENOTFOUND';
|
||||
throw error;
|
||||
};
|
||||
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
common: {},
|
||||
pool: {
|
||||
async query() {
|
||||
return [[
|
||||
{
|
||||
identifier: 'player-remote',
|
||||
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
|
||||
last_seen_at: activeLastSeenAt
|
||||
}
|
||||
]];
|
||||
}
|
||||
},
|
||||
playerSnapshotCache: new Map(),
|
||||
notifyPlayerScreens: async () => {}
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
|
||||
await uploadSyncService.flushPendingPlayerUploadSyncs();
|
||||
|
||||
assert.ok(warned.some(function (message) {
|
||||
return message.includes('[media-sync] Player unavailable, retry queued for 1 upload for player-remote');
|
||||
}));
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
console.warn = originalWarn;
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('media sync treats 503 responses as unavailable without logging a per-upload warning', async () => {
|
||||
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-503-'));
|
||||
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
|
||||
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
|
||||
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const originalWarn = console.warn;
|
||||
const warned = [];
|
||||
console.warn = function () {
|
||||
warned.push(Array.from(arguments).join(' '));
|
||||
};
|
||||
global.fetch = async function () {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: {
|
||||
get() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async text() {
|
||||
return 'Service Unavailable';
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
common: {},
|
||||
pool: {
|
||||
async query() {
|
||||
return [[
|
||||
{
|
||||
identifier: 'player-remote',
|
||||
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
|
||||
last_seen_at: activeLastSeenAt
|
||||
}
|
||||
]];
|
||||
}
|
||||
},
|
||||
playerSnapshotCache: new Map(),
|
||||
notifyPlayerScreens: async () => {}
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
|
||||
await uploadSyncService.flushPendingPlayerUploadSyncs();
|
||||
|
||||
assert.ok(warned.some(function (message) {
|
||||
return message.includes('[media-sync] Player unavailable, retry queued for 1 upload for player-remote');
|
||||
}));
|
||||
assert.ok(warned.every(function (message) {
|
||||
return !message.includes('Unable to sync upload to player: sample.bin 503 Service Unavailable');
|
||||
}));
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
console.warn = originalWarn;
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('stale player registrations stop media sync retries and warnings', async () => {
|
||||
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-stale-'));
|
||||
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
|
||||
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const originalWarn = console.warn;
|
||||
const fetchCalls = [];
|
||||
const warned = [];
|
||||
console.warn = function () {
|
||||
warned.push(Array.from(arguments).join(' '));
|
||||
};
|
||||
global.fetch = async function (url, init) {
|
||||
fetchCalls.push({ url, init });
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: {
|
||||
get() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async text() {
|
||||
return 'Service Unavailable';
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
common: {},
|
||||
pool: {
|
||||
async query() {
|
||||
return [[
|
||||
{
|
||||
identifier: 'player-remote',
|
||||
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
|
||||
last_seen_at: new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
||||
}
|
||||
]];
|
||||
}
|
||||
},
|
||||
playerSnapshotCache: new Map(),
|
||||
notifyPlayerScreens: async () => {}
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
|
||||
await uploadSyncService.flushPendingPlayerUploadSyncs();
|
||||
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
assert.equal(warned.length, 0);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
console.warn = originalWarn;
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { createWebBootstrap } = require('../src/web/bootstrap');
|
||||
|
||||
test('dashboard websocket broadcasts keep the last successful snapshot when refreshes fail', async () => {
|
||||
let adminDataCalls = 0;
|
||||
|
||||
const bootstrap = createWebBootstrap({
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (String(sql || '').includes('COUNT(*) AS connected_count')) {
|
||||
return [[{ connected_count: 1 }]];
|
||||
}
|
||||
if (String(sql || '').includes('SELECT s.slug, pod.device_id, pod.client_name')) {
|
||||
return [[]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {
|
||||
async fetchAdminData() {
|
||||
adminDataCalls += 1;
|
||||
if (adminDataCalls === 1) {
|
||||
return {
|
||||
screens: [
|
||||
{
|
||||
slug: 'alpha',
|
||||
name: 'Alpha',
|
||||
playlist_name: 'Main Loop'
|
||||
}
|
||||
],
|
||||
playlists: [],
|
||||
slides: []
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('dashboard refresh failed');
|
||||
},
|
||||
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()
|
||||
}
|
||||
];
|
||||
}
|
||||
},
|
||||
playerSnapshotCache: new Map([
|
||||
['alpha', {
|
||||
count: 1,
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
playerPublicBaseUrl: 'http://player.local'
|
||||
}
|
||||
]
|
||||
}]
|
||||
]),
|
||||
playerSnapshotSockets: new Map(),
|
||||
ensurePlayerSnapshotSubscription() {},
|
||||
formatDashboardDate(value) {
|
||||
return String(value || '');
|
||||
},
|
||||
notifyPlayerScreens() {},
|
||||
backgroundTaskQueue: null,
|
||||
uploadDir: 'media/uploads',
|
||||
playerInternalBaseUrl: 'http://player.local'
|
||||
});
|
||||
|
||||
const firstState = await bootstrap.broadcastDashboardState();
|
||||
const secondState = await bootstrap.broadcastDashboardState();
|
||||
|
||||
assert.equal(firstState.screens[0].slug, 'alpha');
|
||||
assert.equal(firstState.clients.length, 1);
|
||||
assert.equal(secondState.screens[0].slug, 'alpha');
|
||||
assert.equal(secondState.clients.length, 1);
|
||||
assert.equal(adminDataCalls, 2);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
@@ -16,12 +18,734 @@ test('dashboard onboarding link uses the player base url', () => {
|
||||
playlists: [],
|
||||
clients: [],
|
||||
slides: [],
|
||||
connectedClientsCount: 0
|
||||
connectedClientsCount: 0,
|
||||
connectedPlayersCount: 0
|
||||
},
|
||||
'',
|
||||
null
|
||||
{ id: 1, permissions: ['screens.read', 'clients.read'] }
|
||||
);
|
||||
|
||||
assert.match(html, /href="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');
|
||||
});
|
||||
|
||||
test('screen controls include an all screens option and update the target summary', () => {
|
||||
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
|
||||
const select = {
|
||||
value: '__all__',
|
||||
selectedIndex: 2,
|
||||
options: [
|
||||
{ value: 'alpha', textContent: 'Alpha', getAttribute() { return null; } },
|
||||
{ value: 'beta', textContent: 'Beta', getAttribute() { return null; } },
|
||||
{ value: '__all__', textContent: 'All screens', getAttribute(name) { return name === 'data-screen-target-all' ? 'true' : null; } }
|
||||
],
|
||||
listeners: {},
|
||||
addEventListener(type, handler) {
|
||||
this.listeners[type] = handler;
|
||||
}
|
||||
};
|
||||
const commandInput = { value: '' };
|
||||
const pausedInput = { value: 'true' };
|
||||
const button = {
|
||||
innerHTML: '',
|
||||
disabled: false
|
||||
};
|
||||
const form = {
|
||||
dataset: {},
|
||||
getAttribute(name) {
|
||||
if (name === 'data-screen-command-action') {
|
||||
return 'pause';
|
||||
}
|
||||
return this[name] || '';
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === 'input[name="command"]') {
|
||||
return commandInput;
|
||||
}
|
||||
if (selector === 'input[name="paused"]') {
|
||||
return pausedInput;
|
||||
}
|
||||
if (selector === 'button[type="submit"]') {
|
||||
return button;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [button, commandInput, pausedInput];
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
},
|
||||
action: ''
|
||||
};
|
||||
const pill = { classList: { toggle() {}, add() {}, remove() {} }, textContent: '' };
|
||||
const nameNode = { textContent: '' };
|
||||
const metaNode = { textContent: '' };
|
||||
const context = {
|
||||
document: {
|
||||
getElementById(id) {
|
||||
if (id === 'screen-command-select') {
|
||||
return select;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-screen-command-pill]') {
|
||||
return pill;
|
||||
}
|
||||
if (selector === '[data-screen-command-name]') {
|
||||
return nameNode;
|
||||
}
|
||||
if (selector === '[data-screen-command-meta]') {
|
||||
return metaNode;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
return selector === '[data-screen-command-form]' ? [form] : [];
|
||||
}
|
||||
},
|
||||
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);
|
||||
|
||||
context.window.webHandleDashboardState({
|
||||
screens: [
|
||||
{ slug: 'alpha', name: 'Alpha' },
|
||||
{ slug: 'beta', name: 'Beta' }
|
||||
],
|
||||
clients: [
|
||||
{ screen_slug: 'alpha', paused: true },
|
||||
{ screen_slug: 'beta', paused: true }
|
||||
]
|
||||
});
|
||||
|
||||
assert.equal(form.action, '/clients/__all__/commands');
|
||||
assert.equal(nameNode.textContent, 'All screens');
|
||||
assert.equal(metaNode.textContent, 'Commands sent here target every client across every screen group.');
|
||||
assert.match(button.innerHTML, /Resume all screens/);
|
||||
assert.equal(commandInput.value, 'pause');
|
||||
assert.equal(pausedInput.value, 'false');
|
||||
});
|
||||
|
||||
test('dashboard client refresh respects the active search filter', () => {
|
||||
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
|
||||
const tbody = {
|
||||
innerHTML: '<tr><td>stale</td></tr>',
|
||||
addEventListener() {}
|
||||
};
|
||||
const table = {
|
||||
getAttribute(name) {
|
||||
if (name === 'data-has-actions-column') {
|
||||
return 'false';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const context = {
|
||||
document: {
|
||||
body: {
|
||||
classList: {
|
||||
toggle() {},
|
||||
add() {},
|
||||
remove() {}
|
||||
}
|
||||
},
|
||||
getElementById(id) {
|
||||
if (id === 'dashboard-clients-table') {
|
||||
return table;
|
||||
}
|
||||
if (id === 'dashboard-clients-table-body') {
|
||||
return tbody;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
window: {
|
||||
location: {
|
||||
search: '?search=beta',
|
||||
protocol: 'http:',
|
||||
host: 'example.test'
|
||||
},
|
||||
webUiHelpers: {
|
||||
escapeHtml(value) { return String(value); },
|
||||
formatDashboardDate(value) { return String(value); },
|
||||
getClientRowKey(client) { return String(client && client.id || ''); },
|
||||
getClientDisplayName(client) { return String(client && (client.client_name || client.name || client.clientId) || ''); },
|
||||
setButtonVariant() {},
|
||||
normalizeDisplayIp(value) { return String(value); }
|
||||
},
|
||||
WebSocket: null,
|
||||
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);
|
||||
|
||||
context.window.webHandleDashboardState({
|
||||
clients: [
|
||||
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' },
|
||||
{ id: 'beta', client_name: 'Beta', clientId: 'beta', screen_slug: 'beta', clientIp: '10.0.0.2' }
|
||||
]
|
||||
});
|
||||
|
||||
assert.match(tbody.innerHTML, /Beta/);
|
||||
assert.doesNotMatch(tbody.innerHTML, /Alpha/);
|
||||
});
|
||||
|
||||
test('dashboard client refresh respects the live search input before the URL updates', () => {
|
||||
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
|
||||
const searchInput = {
|
||||
value: 'beta'
|
||||
};
|
||||
const tbody = {
|
||||
innerHTML: '<tr><td>stale</td></tr>',
|
||||
addEventListener() {}
|
||||
};
|
||||
const table = {
|
||||
getAttribute(name) {
|
||||
if (name === 'data-has-actions-column') {
|
||||
return 'false';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const context = {
|
||||
document: {
|
||||
body: {
|
||||
classList: {
|
||||
toggle() {},
|
||||
add() {},
|
||||
remove() {}
|
||||
}
|
||||
},
|
||||
getElementById(id) {
|
||||
if (id === 'dashboard-clients-table') {
|
||||
return table;
|
||||
}
|
||||
if (id === 'dashboard-clients-table-body') {
|
||||
return tbody;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-table-search]') {
|
||||
return searchInput;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
window: {
|
||||
location: {
|
||||
search: '',
|
||||
protocol: 'http:',
|
||||
host: 'example.test'
|
||||
},
|
||||
webUiHelpers: {
|
||||
escapeHtml(value) { return String(value); },
|
||||
formatDashboardDate(value) { return String(value); },
|
||||
getClientRowKey(client) { return String(client && client.id || ''); },
|
||||
getClientDisplayName(client) { return String(client && (client.client_name || client.name || client.clientId) || ''); },
|
||||
setButtonVariant() {},
|
||||
normalizeDisplayIp(value) { return String(value); }
|
||||
},
|
||||
WebSocket: null,
|
||||
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);
|
||||
|
||||
context.window.webHandleDashboardState({
|
||||
clients: [
|
||||
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' },
|
||||
{ id: 'beta', client_name: 'Beta', clientId: 'beta', screen_slug: 'beta', clientIp: '10.0.0.2' }
|
||||
]
|
||||
});
|
||||
|
||||
assert.match(tbody.innerHTML, /Beta/);
|
||||
assert.doesNotMatch(tbody.innerHTML, /Alpha/);
|
||||
});
|
||||
|
||||
test('dashboard client refresh does not rerender while a table search is loading', () => {
|
||||
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
|
||||
const tbody = {
|
||||
innerHTML: '<tr><td>stale</td></tr>',
|
||||
addEventListener() {}
|
||||
};
|
||||
const table = {
|
||||
getAttribute(name) {
|
||||
if (name === 'data-has-actions-column') {
|
||||
return 'false';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const loadingMarker = {};
|
||||
const context = {
|
||||
document: {
|
||||
body: {
|
||||
classList: {
|
||||
toggle() {},
|
||||
add() {},
|
||||
remove() {}
|
||||
}
|
||||
},
|
||||
getElementById(id) {
|
||||
if (id === 'dashboard-clients-table') {
|
||||
return table;
|
||||
}
|
||||
if (id === 'dashboard-clients-table-body') {
|
||||
return tbody;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-table-search-loading="true"]') {
|
||||
return loadingMarker;
|
||||
}
|
||||
if (selector === '[data-table-search]') {
|
||||
return { value: 'beta' };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
window: {
|
||||
location: {
|
||||
search: '?search=beta',
|
||||
protocol: 'http:',
|
||||
host: 'example.test'
|
||||
},
|
||||
webUiHelpers: {
|
||||
escapeHtml(value) { return String(value); },
|
||||
formatDashboardDate(value) { return String(value); },
|
||||
getClientRowKey(client) { return String(client && client.id || ''); },
|
||||
getClientDisplayName(client) { return String(client && (client.client_name || client.name || client.clientId) || ''); },
|
||||
setButtonVariant() {},
|
||||
normalizeDisplayIp(value) { return String(value); }
|
||||
},
|
||||
WebSocket: null,
|
||||
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);
|
||||
|
||||
context.window.webHandleDashboardState({
|
||||
clients: [
|
||||
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' },
|
||||
{ id: 'beta', client_name: 'Beta', clientId: 'beta', screen_slug: 'beta', clientIp: '10.0.0.2' }
|
||||
]
|
||||
});
|
||||
|
||||
assert.equal(tbody.innerHTML, '<tr><td>stale</td></tr>');
|
||||
});
|
||||
|
||||
test('dashboard client table can rehydrate actions after a search swap', () => {
|
||||
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
|
||||
const tbody = {
|
||||
innerHTML: '<tr><td>stale</td></tr>',
|
||||
addEventListener() {}
|
||||
};
|
||||
const table = {
|
||||
getAttribute(name) {
|
||||
if (name === 'data-has-actions-column') {
|
||||
return 'true';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const context = {
|
||||
document: {
|
||||
body: {
|
||||
classList: {
|
||||
toggle() {},
|
||||
add() {},
|
||||
remove() {}
|
||||
}
|
||||
},
|
||||
getElementById(id) {
|
||||
if (id === 'dashboard-clients-table') {
|
||||
return table;
|
||||
}
|
||||
if (id === 'dashboard-clients-table-body') {
|
||||
return tbody;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-table-search]') {
|
||||
return { value: '48' };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
window: {
|
||||
location: {
|
||||
search: '?search=48',
|
||||
protocol: 'http:',
|
||||
host: 'example.test'
|
||||
},
|
||||
webUiHelpers: {
|
||||
escapeHtml(value) { return String(value); },
|
||||
formatDashboardDate(value) { return String(value); },
|
||||
getClientRowKey(client) { return String(client && client.id || ''); },
|
||||
getClientDisplayName(client) { return String(client && (client.client_name || client.name || client.clientId) || ''); },
|
||||
setButtonVariant() {},
|
||||
normalizeDisplayIp(value) { return String(value); }
|
||||
},
|
||||
WebSocket: null,
|
||||
setTimeout() { return 1; },
|
||||
clearTimeout() {},
|
||||
requestAnimationFrame(callback) {
|
||||
callback();
|
||||
},
|
||||
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);
|
||||
|
||||
context.window.webHandleDashboardState({
|
||||
clients: [
|
||||
{ id: '48f94e8d-a307-45f8-99a5-dafd3f0e2282', client_name: 'Beta', clientId: '48f94e8d-a307-45f8-99a5-dafd3f0e2282', screen_slug: 'beta', clientIp: '10.0.0.2' },
|
||||
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' }
|
||||
]
|
||||
});
|
||||
|
||||
tbody.innerHTML = '<tr data-client-key="48f94e8d-a307-45f8-99a5-dafd3f0e2282"><td data-label="Client"><div>Beta</div></td></tr>';
|
||||
context.window.webRefreshClientTableFromLatestState();
|
||||
|
||||
assert.match(tbody.innerHTML, /data-label="Actions"/);
|
||||
assert.match(tbody.innerHTML, /Pause/);
|
||||
assert.match(tbody.innerHTML, /Blackout/);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,8 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
||||
uniqueScreenSlug: async () => 'beta',
|
||||
fetchDuplicateName: async () => 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,
|
||||
getAuditUserId() { return 7; },
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerScreensRoutes = require('../src/web/routes/signage/screens/routes');
|
||||
const { renderScreenEditPage } = require('../src/web/pages');
|
||||
|
||||
test('screen edit page includes shared launcher downloads and base player url', 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', last_seen_at: new Date().toISOString() },
|
||||
{ identifier: 'player-beta', public_base_url: 'http://beta.example/', last_seen_at: new Date().toISOString() }
|
||||
];
|
||||
},
|
||||
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/:id/edit'];
|
||||
assert.equal(Array.isArray(handler), true);
|
||||
|
||||
const res = {
|
||||
send(value) {
|
||||
this.body = value;
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await handler[1]({ params: { id: '7' }, query: {}, 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'
|
||||
},
|
||||
{
|
||||
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', last_seen_at: new Date().toISOString() }
|
||||
];
|
||||
},
|
||||
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, {
|
||||
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
|
||||
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
|
||||
});
|
||||
});
|
||||
|
||||
test('screen edit page hides stale player registrations', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
|
||||
let renderedArgs = null;
|
||||
const now = Date.now();
|
||||
const recentSeenAt = new Date(now - 10 * 1000).toISOString();
|
||||
const staleSeenAt = new Date(now - 5 * 60 * 1000).toISOString();
|
||||
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-stale', public_base_url: 'http://stale.example', last_seen_at: staleSeenAt },
|
||||
{ identifier: 'player-recent', public_base_url: 'http://recent.example', last_seen_at: recentSeenAt }
|
||||
];
|
||||
},
|
||||
async fetchPlayerPublicBaseUrl() {
|
||||
return 'http://player.example';
|
||||
}
|
||||
},
|
||||
pages,
|
||||
buildDashboardState: async () => ({ screens: [] }),
|
||||
getScreenDeleteBlockMessage: async () => '',
|
||||
getScreenConnections: async () => [],
|
||||
formatDashboardDate(value) {
|
||||
return `formatted:${String(value)}`;
|
||||
},
|
||||
playerPublicBaseUrl: 'http://player.example',
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const handler = handlers['/screens/:id/edit'];
|
||||
assert.equal(Array.isArray(handler), true);
|
||||
|
||||
const res = {
|
||||
send(value) {
|
||||
this.body = value;
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await handler[1]({ params: { id: '7' }, query: {}, currentUser: { id: 1 } }, res, () => {});
|
||||
|
||||
assert.equal(res.body, 'ok');
|
||||
assert.deepEqual(renderedArgs.screen.player_urls.map(function (player) {
|
||||
return {
|
||||
identifier: player.identifier,
|
||||
public_base_url: player.public_base_url,
|
||||
player_url: player.player_url
|
||||
};
|
||||
}), [
|
||||
{
|
||||
identifier: 'player-recent',
|
||||
public_base_url: 'http://recent.example',
|
||||
player_url: 'http://recent.example/screen/demo-conference'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('screen edit page renders player urls as an adminlte table', async () => {
|
||||
const html = renderScreenEditPage(
|
||||
{
|
||||
id: 7,
|
||||
name: 'Demo Conference',
|
||||
slug: 'demo-conference',
|
||||
playlist_id: null,
|
||||
player_urls: [
|
||||
{
|
||||
identifier: 'player-alpha',
|
||||
public_base_url: 'http://alpha.example',
|
||||
player_url: 'http://alpha.example/screen/demo-conference'
|
||||
}
|
||||
]
|
||||
},
|
||||
{ playlists: [] },
|
||||
'',
|
||||
{ id: 1 }
|
||||
);
|
||||
|
||||
assert.match(html, /Player URLs/);
|
||||
assert.match(html, /<table/i);
|
||||
assert.match(html, /card-body table-responsive p-0/);
|
||||
assert.match(html, /table table-striped w-100 mb-0/);
|
||||
assert.match(html, /player-alpha/);
|
||||
});
|
||||
|
||||
test('screen launcher downloads are shared and attached', 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 fetchScreenPlayerUrls() {
|
||||
return {
|
||||
'demo-conference': 'http://player.example/screen/demo-conference'
|
||||
};
|
||||
},
|
||||
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]({ params: { id: '7' } }, res, () => {});
|
||||
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(res.headers['content-disposition'], /pulse-signage-kiosk\.bat/);
|
||||
assert.match(res.body, /TARGET_URL=http:\/\/player\.example/);
|
||||
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