Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c72923b4 | ||
|
|
6c28a1c028 | ||
|
|
c5172af62d | ||
|
|
cd8e333afd | ||
|
|
c3b5a0053c | ||
|
|
38565a533d | ||
|
|
5a08ccddbb | ||
|
|
2c97fe81d1 | ||
|
|
ee3b1b51bf | ||
|
|
4e5a1bc393 | ||
|
|
7e46fffc34 | ||
|
|
b20beb250b | ||
|
|
9d93634382 | ||
|
|
3cba68e256 |
@@ -1,5 +1,7 @@
|
|||||||
*
|
*
|
||||||
!package.json
|
!package.json
|
||||||
|
!build/
|
||||||
|
!build/**
|
||||||
!src/
|
!src/
|
||||||
!src/**
|
!src/**
|
||||||
!scripts/
|
!scripts/
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ jobs:
|
|||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: web
|
||||||
|
image: git.lzstealth.com/lzstealth/pulse-signage-web
|
||||||
|
dockerfile: ./build/Dockerfile
|
||||||
|
- name: player
|
||||||
|
image: git.lzstealth.com/lzstealth/pulse-signage-player
|
||||||
|
dockerfile: ./build/Dockerfile.player
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -28,18 +38,18 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: git.lzstealth.com/LZStealth/pulse-signage
|
images: ${{ matrix.image }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
type=semver,pattern=v{{major}}.{{minor}}
|
|
||||||
type=semver,pattern=v{{major}}
|
type=semver,pattern=v{{major}}
|
||||||
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ${{ matrix.dockerfile }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -2,6 +2,70 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 2.6.15 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Slide update media sync on the player now removes uploads that were removed from the slide, so player storage stays aligned with the current slide content.
|
||||||
|
- Routine player and player-bridge media upload/delete logs were removed to keep remote player and bridge operation quieter.
|
||||||
|
- Background task descriptions no longer repeat the player slug when the task key already ends with that player name.
|
||||||
|
|
||||||
|
## 2.6.14 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Slide updates and template deletes now fan out media sync work across all live players instead of targeting only one player.
|
||||||
|
|
||||||
|
## 2.6.13 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The player bundle now keeps the browser-only QR export dependencies lazy-loaded, so the remote player image no longer needs `puppeteer-core` or `@sparticuz/chromium` at startup.
|
||||||
|
- Remote player startup sync now falls back to `WEB_INTERNAL_URL` when it is configured, which avoids 404s when the bridge is not the correct sync target.
|
||||||
|
- The player startup sync and media-write info logs were removed to keep normal remote-player operation quieter.
|
||||||
|
|
||||||
|
## 2.6.12 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The Docker build inputs now live under `build/`, with separate web and player package manifests so the player image no longer carries the web browser tooling bundle.
|
||||||
|
- The Docker publish workflow now restores `v2` and `v2.2` style image tags alongside `latest` and the full release tag.
|
||||||
|
- Remote player sync and upload routing now carry the exact connected player device id through the bridge, so startup media and font jobs target the correct player instance in split-device deployments.
|
||||||
|
- Player websocket registration now learns the public base URL from the actual screen request origin, so localhost and 127.0.0.1 aliases both keep websocket commands working.
|
||||||
|
|
||||||
|
## 2.6.11 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Role edits now save selected permissions from the shared RBAC form, so changes on the role page persist when you submit the form.
|
||||||
|
- RBAC permission sections now stay open independently in the accordion, so opening one section no longer closes the others.
|
||||||
|
|
||||||
|
## 2.6.10 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The onboarding landing page and form no longer restore a previously selected screen, so the screen picker always starts clean while still keeping the saved client name.
|
||||||
|
|
||||||
|
## 2.6.9 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The connected-clients view no longer exposes or sorts by client IP, so the table stays focused on the player identity, screen, and playback state.
|
||||||
|
- The connected-clients dashboard row renderer now keeps the actions column aligned after removing the IP column, so row updates no longer append a duplicate actions cell.
|
||||||
|
|
||||||
|
## 2.6.8 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The screen-group command buttons stay disabled until a real target group is selected, so the placeholder "Select Screen Group" state cannot send commands.
|
||||||
|
|
||||||
|
## 2.6.7 - 2026-08-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Admin client commands now stay on the bridge for remote players, so screen control no longer depends on a public player address.
|
||||||
|
- The connected-clients screen-group controls now use the same async bulk-command path as the dashboard, including the All Screens option and live pause/blackout toggles.
|
||||||
|
|
||||||
## 2.6.6 - 2026-08-07
|
## 2.6.6 - 2026-08-07
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
FROM node:24-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg chromium nss freetype harfbuzz ttf-freefont
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
RUN mkdir -p /app/media
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["npm", "run", "start:web"]
|
|
||||||
@@ -32,6 +32,7 @@ If you want the details, start with the [Compose guide](docker-compose/README.md
|
|||||||
- [Database schema](docs/schema.md) - the tables and data model the app maintains.
|
- [Database schema](docs/schema.md) - the tables and data model the app maintains.
|
||||||
- [WebSocket reference](docs/websocket.md) - the live player and snapshot channels.
|
- [WebSocket reference](docs/websocket.md) - the live player and snapshot channels.
|
||||||
- [Compose guide](docker-compose/README.md) - deployment options and service layout.
|
- [Compose guide](docker-compose/README.md) - deployment options and service layout.
|
||||||
|
- [Changelog](CHANGELOG.md) - release history and notable changes.
|
||||||
|
|
||||||
## Explore The Docs
|
## Explore The Docs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:24-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache chromium nss freetype harfbuzz ttf-freefont
|
||||||
|
|
||||||
|
COPY build/package.web.json ./package.json
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
COPY scripts ./scripts
|
||||||
|
|
||||||
|
RUN mkdir -p /app/media
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start:web"]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:24-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
|
COPY build/package.player.json ./package.json
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
COPY scripts ./scripts
|
||||||
|
|
||||||
|
RUN mkdir -p /app/media
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start:player"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "pulse-signage-player",
|
||||||
|
"version": "2.6.15",
|
||||||
|
"private": false,
|
||||||
|
"description": "Pulse Signage player application bundle",
|
||||||
|
"main": "src/common.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node -r dotenv/config src/player.js",
|
||||||
|
"start:player": "node -r dotenv/config src/player.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
|
"hls.js": "^1.5.15",
|
||||||
|
"mysql2": "^3.14.3",
|
||||||
|
"ws": "^8.21.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "pulse-signage-web",
|
||||||
|
"version": "2.6.15",
|
||||||
|
"private": false,
|
||||||
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
|
"main": "src/common.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node -r dotenv/config src/web.js",
|
||||||
|
"start:web": "node -r dotenv/config src/web.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@sparticuz/chromium": "^137.0.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
|
"multer": "^2.2.0",
|
||||||
|
"mysql2": "^3.14.3",
|
||||||
|
"puppeteer-core": "^24.16.0",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
|
"ws": "^8.21.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# Shared application settings
|
# Shared application settings
|
||||||
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest"
|
PULSE_SIGNAGE_WEB_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-web:latest"
|
||||||
|
PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:latest"
|
||||||
PULSE_SIGNAGE_SHARED_SECRET=""
|
PULSE_SIGNAGE_SHARED_SECRET=""
|
||||||
|
|
||||||
# Database settings for the web, player, and bridge services
|
# Database settings for the web, player, and bridge services
|
||||||
@@ -12,15 +13,15 @@ MYSQL_ROOT_PASSWORD="root_password"
|
|||||||
|
|
||||||
# Player settings
|
# Player settings
|
||||||
PLAYER_IDENTIFIER="player-local"
|
PLAYER_IDENTIFIER="player-local"
|
||||||
PLAYER_PUBLIC_BASE_URL="http://localhost:8081"
|
PLAYER_PUBLIC_URL="http://localhost:8081"
|
||||||
PLAYER_INTERNAL_BASE_URL="http://player:8081"
|
PLAYER_INTERNAL_URL="http://player:8081"
|
||||||
|
|
||||||
# Web app bootstrap settings
|
# Web app bootstrap settings
|
||||||
SESSION_MAX_AGE_DAYS=14
|
SESSION_MAX_AGE_DAYS=14
|
||||||
DEFAULT_ADMIN_USERNAME="admin"
|
DEFAULT_ADMIN_USERNAME="admin"
|
||||||
DEFAULT_ADMIN_NAME="Admin"
|
DEFAULT_ADMIN_NAME="Admin"
|
||||||
DEFAULT_ADMIN_PASSWORD="admin"
|
DEFAULT_ADMIN_PASSWORD="password123"
|
||||||
PASSWORD_HASH_ITERATIONS=310000
|
|
||||||
|
|
||||||
# Bridge settings for the player-bridge service
|
# Bridge settings for the player-bridge service
|
||||||
WEB_BASE_URL="http://web:8080"
|
WEB_INTERNAL_URL="http://web:8080"
|
||||||
|
BRIDGE_INTERNAL_URL="http://player-bridge:8090"
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# Shared application settings
|
# Shared application settings
|
||||||
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest"
|
PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:latest"
|
||||||
PULSE_SIGNAGE_SHARED_SECRET=""
|
PULSE_SIGNAGE_SHARED_SECRET=""
|
||||||
|
|
||||||
# Player settings
|
# Player settings
|
||||||
PLAYER_IDENTIFIER="player-remote"
|
PLAYER_IDENTIFIER="player-remote"
|
||||||
PLAYER_PUBLIC_BASE_URL="http://localhost:8081"
|
PLAYER_PUBLIC_URL="http://localhost:8081"
|
||||||
PLAYER_AGENT_RECONNECT_DELAY_MS=5000
|
PLAYER_AGENT_RECONNECT_DELAY_MS=5000
|
||||||
|
|
||||||
# Remote player connectivity settings
|
# Remote player connectivity settings
|
||||||
THIN_CLIENT_BASE_URL="http://192.168.0.80:8090"
|
BRIDGE_PUBLIC_URL="http://player-bridge.example.com:8090"
|
||||||
+28
-17
@@ -59,15 +59,16 @@ Responsibilities:
|
|||||||
|
|
||||||
- serves the player UI on port `8081`
|
- serves the player UI on port `8081`
|
||||||
- connects to MySQL in local mode
|
- connects to MySQL in local mode
|
||||||
- connects to the bridge in remote mode through `THIN_CLIENT_BASE_URL`
|
- connects to the bridge in remote mode through `BRIDGE_PUBLIC_URL`
|
||||||
- registers live connections and accepts control commands
|
- registers live connections and accepts control commands
|
||||||
|
|
||||||
Key configuration:
|
Key configuration:
|
||||||
|
|
||||||
- `PLAYER_PUBLIC_BASE_URL`
|
- `PLAYER_PUBLIC_URL`
|
||||||
- `PLAYER_INTERNAL_BASE_URL`
|
- `PLAYER_INTERNAL_URL`
|
||||||
|
- `BRIDGE_INTERNAL_URL`
|
||||||
- `PLAYER_IDENTIFIER`
|
- `PLAYER_IDENTIFIER`
|
||||||
- `THIN_CLIENT_BASE_URL` in remote mode
|
- `BRIDGE_PUBLIC_URL` in remote mode
|
||||||
- `PULSE_SIGNAGE_SHARED_SECRET`
|
- `PULSE_SIGNAGE_SHARED_SECRET`
|
||||||
- database settings in local mode
|
- database settings in local mode
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ Responsibilities:
|
|||||||
Key configuration:
|
Key configuration:
|
||||||
|
|
||||||
- `PULSE_SIGNAGE_SHARED_SECRET`
|
- `PULSE_SIGNAGE_SHARED_SECRET`
|
||||||
- `WEB_BASE_URL` for the bridge when it should call the web app directly instead of inferring from request headers
|
- `WEB_INTERNAL_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`
|
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
|
||||||
|
|
||||||
### `mysql`
|
### `mysql`
|
||||||
@@ -112,15 +113,19 @@ Use this file as a starting point for the public compose stack.
|
|||||||
|
|
||||||
Important values:
|
Important values:
|
||||||
|
|
||||||
- `PULSE_SIGNAGE_IMAGE` - image to run for all app services
|
- `PULSE_SIGNAGE_WEB_IMAGE` - image to run for the web app and bridge services, typically `.../pulse-signage-web:latest`
|
||||||
|
- `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run for the player services, typically `.../pulse-signage-player:latest`
|
||||||
- `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests
|
- `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests
|
||||||
- `PLAYER_IDENTIFIER` - unique local player identifier
|
- `PLAYER_IDENTIFIER` - unique local player identifier
|
||||||
- `DB_*` - MySQL credentials and database name for the stack
|
- `DB_*` - MySQL credentials and database name for the stack
|
||||||
- `PLAYER_PUBLIC_BASE_URL` - public URL the player advertises
|
- `PLAYER_PUBLIC_URL` - public URL the player advertises
|
||||||
- `PLAYER_INTERNAL_BASE_URL` - internal URL the web app uses for local player calls
|
- `PLAYER_INTERNAL_URL` - internal URL the web app uses for local player calls
|
||||||
|
- `BRIDGE_INTERNAL_URL` - bridge URL the web app uses for player snapshot and command forwarding
|
||||||
|
- `WEB_INTERNAL_URL` - internal URL the bridge uses to call the web app directly
|
||||||
- `SESSION_MAX_AGE_DAYS` - dashboard session lifetime
|
- `SESSION_MAX_AGE_DAYS` - dashboard session lifetime
|
||||||
- `DEFAULT_ADMIN_*` - bootstrap admin account values
|
- `DEFAULT_ADMIN_*` - bootstrap admin account values
|
||||||
- `PASSWORD_HASH_ITERATIONS` - password hashing cost
|
- `PASSWORD_HASH_ITERATIONS` - password hashing cost
|
||||||
|
- `MYSQL_ROOT_PASSWORD` - root password for the local MySQL container
|
||||||
|
|
||||||
### `.env.remote.example`
|
### `.env.remote.example`
|
||||||
|
|
||||||
@@ -128,11 +133,11 @@ Use this file on a remote player device.
|
|||||||
|
|
||||||
Important values:
|
Important values:
|
||||||
|
|
||||||
- `PULSE_SIGNAGE_IMAGE` - image to run on the device
|
- `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run on the device, typically `.../pulse-signage-player:latest`
|
||||||
- `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment
|
- `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_IDENTIFIER` - unique remote player identifier
|
||||||
- `PLAYER_PUBLIC_BASE_URL` - public URL for the remote player
|
- `PLAYER_PUBLIC_URL` - public URL for the remote player
|
||||||
- `THIN_CLIENT_BASE_URL` - bridge URL the player connects back to
|
- `BRIDGE_PUBLIC_URL` - bridge URL the player connects back to
|
||||||
- `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent
|
- `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent
|
||||||
|
|
||||||
### `PULSE_SIGNAGE_SHARED_SECRET`
|
### `PULSE_SIGNAGE_SHARED_SECRET`
|
||||||
@@ -153,7 +158,8 @@ Leave it blank only if you intentionally want to run without request signing in
|
|||||||
|
|
||||||
| Variable | Used By | Purpose |
|
| Variable | Used By | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `PULSE_SIGNAGE_IMAGE` | web, player, bridge, remote player | Docker image to run for the app services. |
|
| `PULSE_SIGNAGE_WEB_IMAGE` | web, bridge | Docker image to run for the web app and bridge services. |
|
||||||
|
| `PULSE_SIGNAGE_PLAYER_IMAGE` | player, remote player | Docker image to run for the player services. |
|
||||||
| `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge, remote player | Shared secret for authenticated requests between 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_HOST` | web, player, bridge | Database host name. |
|
||||||
| `DB_PORT` | web, player, bridge | Database port. |
|
| `DB_PORT` | web, player, bridge | Database port. |
|
||||||
@@ -166,11 +172,16 @@ Leave it blank only if you intentionally want to run without request signing in
|
|||||||
| `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. |
|
| `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. |
|
||||||
| `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. |
|
| `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. |
|
||||||
| `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. |
|
| `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. |
|
||||||
| `PLAYER_INTERNAL_BASE_URL` | web, player | Internal player URL used by the dashboard and player runtime. |
|
| `PLAYER_INTERNAL_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. |
|
| `BRIDGE_INTERNAL_URL` | web | Bridge URL used by the web app for player snapshot and command forwarding. |
|
||||||
| `PLAYER_PUBLIC_BASE_URL` | player, remote player | Public URL advertised by the player. |
|
| `WEB_INTERNAL_URL` | player-bridge | Internal web URL used by the bridge to call the dashboard app directly. |
|
||||||
|
| `PLAYER_PUBLIC_URL` | player, remote player | Public URL advertised by the player. |
|
||||||
|
| `BRIDGE_PUBLIC_URL` | player, remote player | URL of the bridge service. |
|
||||||
| `PLAYER_IDENTIFIER` | player | Stable player identifier. |
|
| `PLAYER_IDENTIFIER` | player | Stable player identifier. |
|
||||||
| `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. |
|
| `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. |
|
||||||
|
| `MYSQL_DATABASE` | mysql | Database name used by the local MySQL container. |
|
||||||
|
| `MYSQL_USER` | mysql | Database user used by the local MySQL container. |
|
||||||
|
| `MYSQL_PASSWORD` | mysql | Database password used by the local MySQL container. |
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|
||||||
@@ -208,8 +219,8 @@ Each compose file creates its own named network:
|
|||||||
- The public stack expects the app services and MySQL to share the same `PULSE_SIGNAGE_SHARED_SECRET`.
|
- 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.
|
- 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 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 remote player should point `BRIDGE_PUBLIC_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.
|
- The `PULSE_SIGNAGE_WEB_IMAGE` and `PULSE_SIGNAGE_PLAYER_IMAGE` tags default to the published `pulse-signage-web` and `pulse-signage-player` repositories with `latest` and `v1.2.3` style tags, but they can be overridden for local builds or custom releases.
|
||||||
|
|
||||||
## Recommended Setup
|
## Recommended Setup
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,21 @@ name: pulse-signage-remote
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
player:
|
player:
|
||||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- pulse_signage
|
||||||
ports:
|
ports:
|
||||||
- "8081:8081"
|
- "8081:8081"
|
||||||
environment:
|
environment:
|
||||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
|
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-remote}
|
||||||
THIN_CLIENT_BASE_URL: ${THIN_CLIENT_BASE_URL:-}
|
PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
|
||||||
|
BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:-}
|
||||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||||
|
PLAYER_AGENT_RECONNECT_DELAY_MS: ${PLAYER_AGENT_RECONNECT_DELAY_MS:-5000}
|
||||||
volumes:
|
volumes:
|
||||||
- pulse-signage:/app/media
|
- pulse-signage:/app/media
|
||||||
command: ["node", "src/player.js"]
|
command: ["node", "src/player.js"]
|
||||||
networks:
|
|
||||||
- pulse_signage
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pulse-signage:
|
pulse-signage:
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ name: pulse-signage
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- pulse_signage
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
environment:
|
environment:
|
||||||
@@ -13,28 +15,28 @@ services:
|
|||||||
DB_USER: ${DB_USER:-pulse-signage}
|
DB_USER: ${DB_USER:-pulse-signage}
|
||||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||||
|
BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090}
|
||||||
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
||||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||||
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
|
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
|
||||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
|
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-password123}
|
||||||
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
|
|
||||||
volumes:
|
volumes:
|
||||||
- pulse-signage:/app/media
|
- pulse-signage:/app/media
|
||||||
command: ["node", "src/web.js"]
|
command: ["node", "src/web.js"]
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
|
||||||
- pulse_signage
|
|
||||||
|
|
||||||
player:
|
player:
|
||||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- pulse_signage
|
||||||
ports:
|
ports:
|
||||||
- "8081:8081"
|
- "8081:8081"
|
||||||
environment:
|
environment:
|
||||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
|
PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
|
||||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081}
|
PLAYER_INTERNAL_URL: ${PLAYER_INTERNAL_URL:-http://player:8081}
|
||||||
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local}
|
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local}
|
||||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||||
DB_HOST: ${DB_HOST:-mysql}
|
DB_HOST: ${DB_HOST:-mysql}
|
||||||
@@ -48,16 +50,16 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
|
||||||
- pulse_signage
|
|
||||||
|
|
||||||
player-bridge:
|
player-bridge:
|
||||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- pulse_signage
|
||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "8090:8090"
|
||||||
environment:
|
environment:
|
||||||
WEB_BASE_URL: ${WEB_BASE_URL:-http://web:8080}
|
WEB_INTERNAL_URL: ${WEB_INTERNAL_URL:-http://web:8080}
|
||||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||||
DB_HOST: ${DB_HOST:-mysql}
|
DB_HOST: ${DB_HOST:-mysql}
|
||||||
DB_PORT: ${DB_PORT:-3306}
|
DB_PORT: ${DB_PORT:-3306}
|
||||||
@@ -68,8 +70,6 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
|
||||||
- pulse_signage
|
|
||||||
|
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8.4
|
image: mysql:8.4
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.5.9",
|
"version": "2.6.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.5.9",
|
"version": "2.6.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^137.0.0",
|
"@sparticuz/chromium": "^137.0.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.6.6",
|
"version": "2.6.15",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+7
-7
@@ -1,8 +1,6 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
|
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
|
||||||
const puppeteer = require('puppeteer-core');
|
|
||||||
const chromiumModule = require('@sparticuz/chromium');
|
|
||||||
const QR_PNG_WIDTH = 2048;
|
const QR_PNG_WIDTH = 2048;
|
||||||
const QR_STYLING_SCRIPT_PATH = path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js');
|
const QR_STYLING_SCRIPT_PATH = path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js');
|
||||||
const SYSTEM_CHROMIUM_PATHS = [
|
const SYSTEM_CHROMIUM_PATHS = [
|
||||||
@@ -13,11 +11,6 @@ const SYSTEM_CHROMIUM_PATHS = [
|
|||||||
'/usr/local/bin/chromium',
|
'/usr/local/bin/chromium',
|
||||||
'/snap/bin/chromium'
|
'/snap/bin/chromium'
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
|
||||||
? chromiumModule
|
|
||||||
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
|
||||||
? chromiumModule.default
|
|
||||||
: chromiumModule;
|
|
||||||
let qrBrowserPromise = null;
|
let qrBrowserPromise = null;
|
||||||
|
|
||||||
function escapeXml(value) {
|
function escapeXml(value) {
|
||||||
@@ -353,6 +346,13 @@ function getQrBrowser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
qrBrowserPromise = (async function () {
|
qrBrowserPromise = (async function () {
|
||||||
|
const puppeteer = require('puppeteer-core');
|
||||||
|
const chromiumModule = require('@sparticuz/chromium');
|
||||||
|
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
||||||
|
? chromiumModule
|
||||||
|
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
||||||
|
? chromiumModule.default
|
||||||
|
: chromiumModule;
|
||||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||||
return fs.existsSync(candidate);
|
return fs.existsSync(candidate);
|
||||||
}) || '';
|
}) || '';
|
||||||
|
|||||||
+334
-64
@@ -40,8 +40,70 @@ function normalizeRemoteAddress(value) {
|
|||||||
|
|
||||||
function formatPlayerConnectionLabel(deviceId, remoteAddress) {
|
function formatPlayerConnectionLabel(deviceId, remoteAddress) {
|
||||||
const normalizedDeviceId = String(deviceId || '').trim() || 'unknown-player';
|
const normalizedDeviceId = String(deviceId || '').trim() || 'unknown-player';
|
||||||
const normalizedRemoteAddress = normalizeRemoteAddress(remoteAddress);
|
return normalizedDeviceId;
|
||||||
return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : normalizedDeviceId;
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === 'player-dev'
|
||||||
|
|| host === 'player-local'
|
||||||
|
|| host === 'player-bridge-dev'
|
||||||
|
|| host === 'web'
|
||||||
|
|| host === 'player-bridge'
|
||||||
|
|| host.endsWith('.local')
|
||||||
|
|| host.endsWith('.internal')
|
||||||
|
|| host.endsWith('.docker.internal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSnapshotUpstreamBaseUrl(player) {
|
||||||
|
const internalBaseUrl = normalizeProxyBaseUrl(player && player.internal_base_url);
|
||||||
|
if (internalBaseUrl && isLocalLikeBaseUrl(internalBaseUrl)) {
|
||||||
|
return internalBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeProxyBaseUrl(player && player.public_base_url) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePlayerSocketForDeviceId(playerSockets, deviceId) {
|
||||||
|
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
||||||
|
if (!normalizedDeviceId || !playerSockets || typeof playerSockets.get !== 'function') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = playerSockets.get(normalizedDeviceId);
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) {
|
function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) {
|
||||||
@@ -51,11 +113,8 @@ function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deviceIds = screenPlayerDeviceIds.get(key);
|
const deviceIds = screenPlayerDeviceIds.get(key);
|
||||||
if (!Array.isArray(deviceIds) || !deviceIds.length) {
|
const targets = Array.isArray(deviceIds)
|
||||||
return [];
|
? Array.from(new Set(deviceIds.map(function (value) {
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(new Set(deviceIds.map(function (value) {
|
|
||||||
return normalizeDeviceId(value);
|
return normalizeDeviceId(value);
|
||||||
}).filter(Boolean))).map(function (deviceId) {
|
}).filter(Boolean))).map(function (deviceId) {
|
||||||
const socket = playerSockets.get(deviceId);
|
const socket = playerSockets.get(deviceId);
|
||||||
@@ -64,11 +123,28 @@ function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { deviceId: deviceId, socket: socket };
|
return { deviceId: deviceId, socket: socket };
|
||||||
}).filter(Boolean);
|
}).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (targets.length) {
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTargets = Array.from(playerSockets.values()).filter(function (socket) {
|
||||||
|
return socket && socket.readyState === WebSocket.OPEN;
|
||||||
|
}).map(function (socket) {
|
||||||
|
return {
|
||||||
|
deviceId: String(socket.playerDeviceId || '').trim(),
|
||||||
|
socket: socket
|
||||||
|
};
|
||||||
|
}).filter(function (target) {
|
||||||
|
return Boolean(target.deviceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return fallbackTargets.length === 1 ? fallbackTargets : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWebBaseUrl(req) {
|
function resolveWebBaseUrl(req) {
|
||||||
const configuredWebBaseUrl = String(process.env.WEB_BASE_URL || '').trim().replace(/\/$/, '');
|
const configuredWebBaseUrl = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, '');
|
||||||
if (configuredWebBaseUrl) {
|
if (configuredWebBaseUrl) {
|
||||||
return configuredWebBaseUrl;
|
return configuredWebBaseUrl;
|
||||||
}
|
}
|
||||||
@@ -118,24 +194,130 @@ async function start() {
|
|||||||
const screenSnapshotsWs = new WebSocketServer({ noServer: true });
|
const screenSnapshotsWs = new WebSocketServer({ noServer: true });
|
||||||
const playerSockets = new Map();
|
const playerSockets = new Map();
|
||||||
const screenSnapshotCache = new Map();
|
const screenSnapshotCache = new Map();
|
||||||
|
const screenSnapshotSourcesBySlug = new Map();
|
||||||
|
const screenSnapshotSubscribersBySlug = new Map();
|
||||||
const screenPlayerDeviceIds = new Map();
|
const screenPlayerDeviceIds = new Map();
|
||||||
const pendingPlayerCommands = new Map();
|
const pendingPlayerCommands = new Map();
|
||||||
|
|
||||||
function normalizeProxyBaseUrl(value) {
|
function getScreenSnapshotSourceBucket(slug) {
|
||||||
const normalized = String(value || '').trim().replace(/\/$/, '');
|
const key = String(slug || '').trim();
|
||||||
if (!normalized) {
|
if (!key) {
|
||||||
return '';
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (!screenSnapshotSourcesBySlug.has(key)) {
|
||||||
const url = new URL(normalized);
|
screenSnapshotSourcesBySlug.set(key, new Map());
|
||||||
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 screenSnapshotSourcesBySlug.get(key);
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getScreenSnapshotSubscriberBucket(slug) {
|
||||||
|
const key = String(slug || '').trim();
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!screenSnapshotSubscribersBySlug.has(key)) {
|
||||||
|
screenSnapshotSubscribersBySlug.set(key, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
return screenSnapshotSubscribersBySlug.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMergedScreenSnapshot(slug) {
|
||||||
|
const key = String(slug || '').trim();
|
||||||
|
const sourceBucket = screenSnapshotSourcesBySlug.get(key);
|
||||||
|
const connections = [];
|
||||||
|
const deviceIds = [];
|
||||||
|
|
||||||
|
if (sourceBucket && typeof sourceBucket.forEach === 'function') {
|
||||||
|
sourceBucket.forEach(function (payload) {
|
||||||
|
if (payload && Array.isArray(payload.connections)) {
|
||||||
|
connections.push.apply(connections, payload.connections);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sourceBucket.forEach(function (_payload, sourceKey) {
|
||||||
|
deviceIds.push(sourceKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: key,
|
||||||
|
count: connections.length,
|
||||||
|
connections: connections,
|
||||||
|
deviceIds: deviceIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastScreenSnapshot(slug) {
|
||||||
|
const key = String(slug || '').trim();
|
||||||
|
const snapshot = buildMergedScreenSnapshot(key);
|
||||||
|
storeScreenSnapshot(key, snapshot.connections, snapshot.deviceIds);
|
||||||
|
|
||||||
|
const bucket = screenSnapshotSubscribersBySlug.get(key);
|
||||||
|
if (!bucket || !bucket.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
type: 'snapshot',
|
||||||
|
slug: key,
|
||||||
|
connections: snapshot.connections,
|
||||||
|
sentAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
bucket.forEach(function (socket) {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScreenSnapshotSource(slug, sourceKey, connections) {
|
||||||
|
const key = String(slug || '').trim();
|
||||||
|
const normalizedSourceKey = String(sourceKey || '').trim();
|
||||||
|
if (!key || !normalizedSourceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = getScreenSnapshotSourceBucket(key);
|
||||||
|
if (!bucket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.set(normalizedSourceKey, {
|
||||||
|
slug: key,
|
||||||
|
connections: Array.isArray(connections) ? connections : []
|
||||||
|
});
|
||||||
|
broadcastScreenSnapshot(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScreenSnapshotSource(slug, sourceKey) {
|
||||||
|
const key = String(slug || '').trim();
|
||||||
|
const normalizedSourceKey = String(sourceKey || '').trim();
|
||||||
|
const bucket = screenSnapshotSourcesBySlug.get(key);
|
||||||
|
if (!bucket || !normalizedSourceKey || !bucket.has(normalizedSourceKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.delete(normalizedSourceKey);
|
||||||
|
if (!bucket.size) {
|
||||||
|
screenSnapshotSourcesBySlug.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastScreenSnapshot(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPlayerSnapshotSources(sourceKey) {
|
||||||
|
const normalizedSourceKey = String(sourceKey || '').trim();
|
||||||
|
if (!normalizedSourceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.from(screenSnapshotSourcesBySlug.keys()).forEach(function (slug) {
|
||||||
|
clearScreenSnapshotSource(slug, normalizedSourceKey);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlayerSnapshotRegistrations() {
|
async function fetchPlayerSnapshotRegistrations() {
|
||||||
@@ -190,7 +372,7 @@ async function start() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logBridge(`Player ${formatPlayerConnectionLabel(socket.playerDeviceId, socket.bridgeRemoteAddress)} has disconnected`);
|
logBridge(`Player ${formatPlayerConnectionLabel(socket.playerDeviceId)} has disconnected`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMediaPath(fileName) {
|
function resolveMediaPath(fileName) {
|
||||||
@@ -201,8 +383,8 @@ async function start() {
|
|||||||
return relativePath;
|
return relativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendPlayerCommand(commandPayload) {
|
function sendPlayerCommand(commandPayload, deviceId) {
|
||||||
const socket = getConnectedPlayerSocket();
|
const socket = resolvePlayerSocketForDeviceId(playerSockets, deviceId);
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' });
|
return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' });
|
||||||
}
|
}
|
||||||
@@ -345,15 +527,24 @@ async function start() {
|
|||||||
return res.status(400).json({ error: 'Filename is required' });
|
return res.status(400).json({ error: 'Filename is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deviceId = normalizeDeviceId(req.query.deviceId || req.query.playerIdentifier || req.headers['x-pulse-player-device-id']);
|
||||||
|
if (!deviceId) {
|
||||||
|
return res.status(400).json({ error: 'Device ID is required.' });
|
||||||
|
}
|
||||||
|
|
||||||
const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
||||||
const response = await sendPlayerCommand({
|
const response = await sendPlayerCommand({
|
||||||
command: 'media-put',
|
command: 'media-put',
|
||||||
relativePath: relativePath,
|
relativePath: relativePath,
|
||||||
bodyBase64: bodyBuffer.toString('base64')
|
bodyBase64: bodyBuffer.toString('base64')
|
||||||
});
|
}, deviceId);
|
||||||
|
|
||||||
res.status(response.status || (response.ok ? 200 : 502)).json(response);
|
res.status(response.status || (response.ok ? 200 : 502)).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logBridge('Player media upload failed', {
|
||||||
|
relativePath: req.params && req.params.filename ? String(req.params.filename).trim() : '',
|
||||||
|
error: error && error.message ? error.message : String(error)
|
||||||
|
});
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -365,13 +556,22 @@ async function start() {
|
|||||||
return res.status(400).json({ error: 'Filename is required' });
|
return res.status(400).json({ error: 'Filename is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deviceId = normalizeDeviceId(req.query.deviceId || req.query.playerIdentifier || req.headers['x-pulse-player-device-id']);
|
||||||
|
if (!deviceId) {
|
||||||
|
return res.status(400).json({ error: 'Device ID is required.' });
|
||||||
|
}
|
||||||
|
|
||||||
const response = await sendPlayerCommand({
|
const response = await sendPlayerCommand({
|
||||||
command: 'media-delete',
|
command: 'media-delete',
|
||||||
relativePath: relativePath
|
relativePath: relativePath
|
||||||
});
|
}, deviceId);
|
||||||
|
|
||||||
res.status(response.status || (response.ok ? 200 : 502)).json(response);
|
res.status(response.status || (response.ok ? 200 : 502)).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logBridge('Player media delete failed', {
|
||||||
|
relativePath: req.params && req.params.filename ? String(req.params.filename).trim() : '',
|
||||||
|
error: error && error.message ? error.message : String(error)
|
||||||
|
});
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -641,6 +841,16 @@ async function start() {
|
|||||||
|
|
||||||
socket.playerDeviceId = deviceId;
|
socket.playerDeviceId = deviceId;
|
||||||
|
|
||||||
|
if (messageType === 'snapshot') {
|
||||||
|
const slug = String(payload.slug || '').trim();
|
||||||
|
if (!slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScreenSnapshotSource(slug, deviceId, Array.isArray(payload.connections) ? payload.connections : []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (messageType === 'register') {
|
if (messageType === 'register') {
|
||||||
const player = await upsertPlayerRegistration(pool, {
|
const player = await upsertPlayerRegistration(pool, {
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
@@ -649,7 +859,7 @@ async function start() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
playerSockets.set(deviceId, socket);
|
playerSockets.set(deviceId, socket);
|
||||||
logBridge(`Player ${formatPlayerConnectionLabel(deviceId, socket.bridgeRemoteAddress)} has connected`);
|
logBridge(`Player ${formatPlayerConnectionLabel(deviceId)} has connected`);
|
||||||
socket.send(JSON.stringify({ type: 'registered', ok: true, player: player }));
|
socket.send(JSON.stringify({ type: 'registered', ok: true, player: player }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -696,14 +906,12 @@ async function start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!verifyRequestAuth(request)) {
|
if (!verifyRequestAuth(request)) {
|
||||||
const remoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
|
logBridge('Player denied with wrong shared secret');
|
||||||
logBridge(remoteAddress ? `Player (ip ${remoteAddress}) denied with wrong shared secret` : 'Player denied with wrong shared secret');
|
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
playersWs.handleUpgrade(request, socket, head, function (ws) {
|
playersWs.handleUpgrade(request, socket, head, function (ws) {
|
||||||
ws.bridgeRemoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
|
|
||||||
playersWs.emit('connection', ws, request);
|
playersWs.emit('connection', ws, request);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -719,12 +927,14 @@ async function start() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('close', function () {
|
socket.on('close', function () {
|
||||||
|
clearPlayerSnapshotSources(socket.playerDeviceId);
|
||||||
if (removeConnectedPlayerSocket(socket)) {
|
if (removeConnectedPlayerSocket(socket)) {
|
||||||
logPlayerDisconnect(socket);
|
logPlayerDisconnect(socket);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', function () {
|
socket.on('error', function () {
|
||||||
|
clearPlayerSnapshotSources(socket.playerDeviceId);
|
||||||
if (removeConnectedPlayerSocket(socket)) {
|
if (removeConnectedPlayerSocket(socket)) {
|
||||||
logPlayerDisconnect(socket);
|
logPlayerDisconnect(socket);
|
||||||
}
|
}
|
||||||
@@ -734,32 +944,17 @@ async function start() {
|
|||||||
screenSnapshotsWs.on('connection', function (socket, request, slug) {
|
screenSnapshotsWs.on('connection', function (socket, request, slug) {
|
||||||
const normalizedSlug = String(slug || '').trim();
|
const normalizedSlug = String(slug || '').trim();
|
||||||
const upstreamSockets = new Map();
|
const upstreamSockets = new Map();
|
||||||
const upstreamSnapshots = new Map();
|
|
||||||
const upstreamDeviceIds = new Set();
|
const upstreamDeviceIds = new Set();
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
|
||||||
function sendMergedSnapshot() {
|
const subscriberBucket = getScreenSnapshotSubscriberBucket(normalizedSlug);
|
||||||
if (!normalizedSlug || socket.readyState !== WebSocket.OPEN) {
|
if (!subscriberBucket) {
|
||||||
|
socket.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const connections = [];
|
subscriberBucket.add(socket);
|
||||||
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() {
|
function closeUpstreamSockets() {
|
||||||
upstreamSockets.forEach(function (upstreamSocket) {
|
upstreamSockets.forEach(function (upstreamSocket) {
|
||||||
@@ -769,7 +964,6 @@ async function start() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
upstreamSockets.clear();
|
upstreamSockets.clear();
|
||||||
upstreamSnapshots.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshUpstreams() {
|
async function refreshUpstreams() {
|
||||||
@@ -786,12 +980,19 @@ async function start() {
|
|||||||
|
|
||||||
const seenKeys = new Set();
|
const seenKeys = new Set();
|
||||||
players.forEach(function (player) {
|
players.forEach(function (player) {
|
||||||
const baseUrl = normalizeProxyBaseUrl(player && player.public_base_url);
|
const sourceKey = String(player && player.identifier || player && player.id || '').trim();
|
||||||
|
const connectedSocket = sourceKey ? playerSockets.get(sourceKey) : null;
|
||||||
|
if (connectedSocket && connectedSocket.readyState === WebSocket.OPEN) {
|
||||||
|
seenKeys.add(sourceKey);
|
||||||
|
upstreamDeviceIds.add(sourceKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = resolveSnapshotUpstreamBaseUrl(player);
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceKey = String(player && player.identifier || player && player.id || baseUrl);
|
|
||||||
seenKeys.add(sourceKey);
|
seenKeys.add(sourceKey);
|
||||||
upstreamDeviceIds.add(sourceKey);
|
upstreamDeviceIds.add(sourceKey);
|
||||||
if (upstreamSockets.has(sourceKey)) {
|
if (upstreamSockets.has(sourceKey)) {
|
||||||
@@ -814,20 +1015,15 @@ async function start() {
|
|||||||
if (!payload || payload.type !== 'snapshot' || String(payload.slug || '').trim() !== normalizedSlug) {
|
if (!payload || payload.type !== 'snapshot' || String(payload.slug || '').trim() !== normalizedSlug) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
upstreamSnapshots.set(sourceKey, {
|
setScreenSnapshotSource(normalizedSlug, sourceKey, Array.isArray(payload.connections) ? payload.connections : []);
|
||||||
slug: normalizedSlug,
|
|
||||||
connections: Array.isArray(payload.connections) ? payload.connections : []
|
|
||||||
});
|
|
||||||
sendMergedSnapshot();
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
upstreamSocket.onclose = function () {
|
upstreamSocket.onclose = function () {
|
||||||
upstreamSockets.delete(sourceKey);
|
upstreamSockets.delete(sourceKey);
|
||||||
upstreamSnapshots.delete(sourceKey);
|
if (!closed && !(playerSockets.get(sourceKey) && playerSockets.get(sourceKey).readyState === WebSocket.OPEN)) {
|
||||||
if (!closed) {
|
clearScreenSnapshotSource(normalizedSlug, sourceKey);
|
||||||
sendMergedSnapshot();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -852,7 +1048,7 @@ async function start() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sendMergedSnapshot();
|
broadcastScreenSnapshot(normalizedSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshUpstreams();
|
refreshUpstreams();
|
||||||
@@ -863,6 +1059,10 @@ async function start() {
|
|||||||
|
|
||||||
socket.on('close', function () {
|
socket.on('close', function () {
|
||||||
closed = true;
|
closed = true;
|
||||||
|
subscriberBucket.delete(socket);
|
||||||
|
if (!subscriberBucket.size) {
|
||||||
|
screenSnapshotSubscribersBySlug.delete(normalizedSlug);
|
||||||
|
}
|
||||||
if (refreshTimer) {
|
if (refreshTimer) {
|
||||||
clearInterval(refreshTimer);
|
clearInterval(refreshTimer);
|
||||||
refreshTimer = null;
|
refreshTimer = null;
|
||||||
@@ -872,6 +1072,10 @@ async function start() {
|
|||||||
|
|
||||||
socket.on('error', function () {
|
socket.on('error', function () {
|
||||||
closed = true;
|
closed = true;
|
||||||
|
subscriberBucket.delete(socket);
|
||||||
|
if (!subscriberBucket.size) {
|
||||||
|
screenSnapshotSubscribersBySlug.delete(normalizedSlug);
|
||||||
|
}
|
||||||
if (refreshTimer) {
|
if (refreshTimer) {
|
||||||
clearInterval(refreshTimer);
|
clearInterval(refreshTimer);
|
||||||
refreshTimer = null;
|
refreshTimer = null;
|
||||||
@@ -882,19 +1086,32 @@ async function start() {
|
|||||||
|
|
||||||
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
|
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
|
||||||
try {
|
try {
|
||||||
|
logBridge('Relaying player media sync request to web');
|
||||||
const webBaseUrl = resolveWebBaseUrl(_req);
|
const webBaseUrl = resolveWebBaseUrl(_req);
|
||||||
if (!webBaseUrl) {
|
if (!webBaseUrl) {
|
||||||
return res.status(502).json({ error: 'Web base URL is not configured.' });
|
return res.status(502).json({ error: 'Web base URL is not configured.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
|
||||||
|
? Object.assign({}, _req.body)
|
||||||
|
: {};
|
||||||
|
|
||||||
const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, {
|
const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: Object.assign({
|
headers: Object.assign({
|
||||||
Accept: 'application/json'
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
}, createRequestAuthHeaders({
|
}, createRequestAuthHeaders({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
pathname: '/api/internal/sync/player-media'
|
pathname: '/api/internal/sync/player-media',
|
||||||
}))
|
body: requestBody
|
||||||
|
})),
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
logBridge('Web player media sync response received', {
|
||||||
|
ok: Boolean(response && response.ok),
|
||||||
|
status: response && response.status ? response.status : null
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(response.status);
|
res.status(response.status);
|
||||||
@@ -904,6 +1121,53 @@ async function start() {
|
|||||||
}
|
}
|
||||||
res.send(await response.text());
|
res.send(await response.text());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logBridge('Player media sync relay failed', {
|
||||||
|
error: error && error.message ? error.message : String(error)
|
||||||
|
});
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/internal/sync/player-font', requireRequestAuth, async function (_req, res, next) {
|
||||||
|
try {
|
||||||
|
logBridge('Relaying player font sync request to web');
|
||||||
|
const webBaseUrl = resolveWebBaseUrl(_req);
|
||||||
|
if (!webBaseUrl) {
|
||||||
|
return res.status(502).json({ error: 'Web base URL is not configured.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
|
||||||
|
? Object.assign({}, _req.body)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const response = await fetch(`${webBaseUrl}/api/internal/sync/player-font`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: Object.assign({
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}, createRequestAuthHeaders({
|
||||||
|
method: 'POST',
|
||||||
|
pathname: '/api/internal/sync/player-font',
|
||||||
|
body: requestBody
|
||||||
|
})),
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
logBridge('Web player font sync response received', {
|
||||||
|
ok: Boolean(response && response.ok),
|
||||||
|
status: response && response.status ? response.status : null
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(response.status);
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType) {
|
||||||
|
res.type(contentType);
|
||||||
|
}
|
||||||
|
res.send(await response.text());
|
||||||
|
} catch (error) {
|
||||||
|
logBridge('Player font sync relay failed', {
|
||||||
|
error: error && error.message ? error.message : String(error)
|
||||||
|
});
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -915,7 +1179,13 @@ async function start() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets };
|
module.exports = {
|
||||||
|
start: start,
|
||||||
|
resolveWebBaseUrl: resolveWebBaseUrl,
|
||||||
|
resolveScreenCommandTargets: resolveScreenCommandTargets,
|
||||||
|
resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl,
|
||||||
|
resolvePlayerSocketForDeviceId: resolvePlayerSocketForDeviceId
|
||||||
|
};
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
start().catch(function (error) {
|
start().catch(function (error) {
|
||||||
|
|||||||
+230
-54
@@ -18,21 +18,43 @@ const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/d
|
|||||||
// Player runtime, media API, and websocket wiring.
|
// Player runtime, media API, and websocket wiring.
|
||||||
async function start() {
|
async function start() {
|
||||||
const app = express();
|
const app = express();
|
||||||
const pool = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '') ? null : common.createPool();
|
const pool = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '') ? null : common.createPool();
|
||||||
const PORT = Number(process.env.PLAYER_PORT || 8081);
|
const PORT = Number(process.env.PLAYER_PORT || 8081);
|
||||||
const PLAYER_PUBLIC_BASE_URL = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
const PLAYER_PUBLIC_URL = String(process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||||
const THIN_CLIENT_BASE_URL = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
|
const BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
||||||
const isRemotePlayer = Boolean(THIN_CLIENT_BASE_URL);
|
const WEB_INTERNAL_URL = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, '');
|
||||||
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 isRemotePlayer = Boolean(BRIDGE_PUBLIC_URL);
|
||||||
|
const PLAYER_INTERNAL_URL = String(isRemotePlayer ? BRIDGE_PUBLIC_URL : (process.env.PLAYER_INTERNAL_URL || PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, '');
|
||||||
const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier();
|
const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier();
|
||||||
|
const PLAYER_AGENT_RECONNECT_DELAY_MS = Number(process.env.PLAYER_AGENT_RECONNECT_DELAY_MS || 5000);
|
||||||
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
||||||
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||||
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
|
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
|
||||||
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
|
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
|
||||||
|
const RECONNECT_SYNC_STALE_MS = 60 * 1000;
|
||||||
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
|
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
|
||||||
|
let thinClientSocket = null;
|
||||||
|
let lastDisconnectAt = 0;
|
||||||
|
let playerPublicBaseUrl = PLAYER_PUBLIC_URL || null;
|
||||||
|
let refreshThinClientRegistration = null;
|
||||||
const playerRuntime = createPlayerRuntime({
|
const playerRuntime = createPlayerRuntime({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
normalizeDeviceId: normalizeDeviceId
|
normalizeDeviceId: normalizeDeviceId,
|
||||||
|
notifySnapshot: function (snapshot) {
|
||||||
|
if (!thinClientSocket || thinClientSocket.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
thinClientSocket.send(JSON.stringify({
|
||||||
|
type: 'snapshot',
|
||||||
|
deviceId: PLAYER_DEVICE_ID,
|
||||||
|
slug: snapshot && snapshot.slug ? String(snapshot.slug).trim() : '',
|
||||||
|
connections: Array.isArray(snapshot && snapshot.connections) ? snapshot.connections : []
|
||||||
|
}));
|
||||||
|
} catch (_error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const playerPlaylistService = isRemotePlayer
|
const playerPlaylistService = isRemotePlayer
|
||||||
? null
|
? null
|
||||||
@@ -50,6 +72,35 @@ async function start() {
|
|||||||
|
|
||||||
let hasLoggedPlayerStartup = false;
|
let hasLoggedPlayerStartup = false;
|
||||||
|
|
||||||
|
function normalizePlayerPublicBaseUrl(value) {
|
||||||
|
const normalized = String(value || '').trim().replace(/\/$/, '');
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(normalized).origin.replace(/\/$/, '');
|
||||||
|
} catch (_error) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlayerPublicBaseUrl(value) {
|
||||||
|
const nextBaseUrl = normalizePlayerPublicBaseUrl(value);
|
||||||
|
if (!nextBaseUrl || nextBaseUrl === playerPublicBaseUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playerPublicBaseUrl = nextBaseUrl;
|
||||||
|
if (typeof refreshThinClientRegistration === 'function') {
|
||||||
|
refreshThinClientRegistration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerPublicBaseUrl() {
|
||||||
|
return playerPublicBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
function logPlayerStartup(connectionState) {
|
function logPlayerStartup(connectionState) {
|
||||||
if (hasLoggedPlayerStartup) {
|
if (hasLoggedPlayerStartup) {
|
||||||
return;
|
return;
|
||||||
@@ -59,9 +110,9 @@ async function start() {
|
|||||||
console.info('[player] startup', {
|
console.info('[player] startup', {
|
||||||
mode: isRemotePlayer ? 'bridge client' : 'local',
|
mode: isRemotePlayer ? 'bridge client' : 'local',
|
||||||
connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false,
|
connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false,
|
||||||
publicBaseUrl: PLAYER_PUBLIC_BASE_URL || null,
|
publicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
bridgeBaseUrl: PLAYER_INTERNAL_BASE_URL || null,
|
bridgeBaseUrl: PLAYER_INTERNAL_URL || null,
|
||||||
bridgeWebSocketUrl: THIN_CLIENT_BASE_URL ? createThinClientWebSocketUrl() : null
|
bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,28 +134,84 @@ async function start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function triggerWebMediaSync() {
|
async function triggerWebMediaSync() {
|
||||||
if (!isRemotePlayer || !THIN_CLIENT_BASE_URL) {
|
const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL;
|
||||||
|
if (!isRemotePlayer || !syncBaseUrl) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
playerIdentifier: PLAYER_DEVICE_ID,
|
||||||
|
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
|
playerInternalBaseUrl: PLAYER_INTERNAL_URL
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authHeaders = createRequestAuthHeaders({
|
const authHeaders = createRequestAuthHeaders({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
pathname: '/api/internal/sync/player-media'
|
pathname: '/api/internal/sync/player-media',
|
||||||
|
body: requestBody
|
||||||
});
|
});
|
||||||
const response = await fetch(`${THIN_CLIENT_BASE_URL}/api/internal/sync/player-media`, {
|
const response = await fetch(`${syncBaseUrl}/api/internal/sync/player-media`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: Object.assign({
|
headers: Object.assign({
|
||||||
Accept: 'application/json'
|
Accept: 'application/json',
|
||||||
}, authHeaders)
|
'Content-Type': 'application/json'
|
||||||
|
}, authHeaders),
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
});
|
});
|
||||||
|
|
||||||
return Boolean(response && response.ok);
|
return Boolean(response && response.ok);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
|
console.warn('[player] Startup media sync failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerWebFontSync() {
|
||||||
|
const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL;
|
||||||
|
if (!isRemotePlayer || !syncBaseUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
playerIdentifier: PLAYER_DEVICE_ID,
|
||||||
|
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
|
playerInternalBaseUrl: PLAYER_INTERNAL_URL
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authHeaders = createRequestAuthHeaders({
|
||||||
|
method: 'POST',
|
||||||
|
pathname: '/api/internal/sync/player-font',
|
||||||
|
body: requestBody
|
||||||
|
});
|
||||||
|
const response = await fetch(`${syncBaseUrl}/api/internal/sync/player-font`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: Object.assign({
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}, authHeaders),
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
return Boolean(response && response.ok);
|
||||||
|
} catch (_error) {
|
||||||
|
console.warn('[player] Startup font sync failed');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let webMediaSyncCompleted = false;
|
let webMediaSyncCompleted = false;
|
||||||
|
let webFontSyncCompleted = false;
|
||||||
|
let webFontSyncTriggered = false;
|
||||||
|
|
||||||
|
function shouldTriggerReconnectSync() {
|
||||||
|
if (!lastDisconnectAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Date.now() - lastDisconnectAt >= RECONNECT_SYNC_STALE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleThinClientCommand(socket, rawMessage) {
|
async function handleThinClientCommand(socket, rawMessage) {
|
||||||
let payload = null;
|
let payload = null;
|
||||||
@@ -194,9 +301,9 @@ async function start() {
|
|||||||
common: common,
|
common: common,
|
||||||
playerRuntime: playerRuntime,
|
playerRuntime: playerRuntime,
|
||||||
onboardingStore: onboardingStore,
|
onboardingStore: onboardingStore,
|
||||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
|
||||||
thinClientBaseUrl: THIN_CLIENT_BASE_URL,
|
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
|
||||||
playerDeviceId: PLAYER_DEVICE_ID
|
playerDeviceId: PLAYER_DEVICE_ID
|
||||||
});
|
});
|
||||||
registerPlayerRoutes(app, {
|
registerPlayerRoutes(app, {
|
||||||
@@ -207,18 +314,18 @@ async function start() {
|
|||||||
playerRuntime: playerRuntime,
|
playerRuntime: playerRuntime,
|
||||||
playerPlaylistService: playerPlaylistService,
|
playerPlaylistService: playerPlaylistService,
|
||||||
rtmpStreamService: rtmpStreamService,
|
rtmpStreamService: rtmpStreamService,
|
||||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
|
||||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
|
||||||
thinClientBaseUrl: THIN_CLIENT_BASE_URL,
|
playerDeviceId: PLAYER_DEVICE_ID,
|
||||||
playerDeviceId: PLAYER_DEVICE_ID
|
onPlayerPublicBaseUrl: setPlayerPublicBaseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
function createThinClientWebSocketUrl() {
|
function createThinClientWebSocketUrl() {
|
||||||
if (!THIN_CLIENT_BASE_URL) {
|
if (!BRIDGE_PUBLIC_URL) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return THIN_CLIENT_BASE_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players';
|
return BRIDGE_PUBLIC_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players';
|
||||||
}
|
}
|
||||||
|
|
||||||
function startThinClientRegistration() {
|
function startThinClientRegistration() {
|
||||||
@@ -256,6 +363,71 @@ async function start() {
|
|||||||
'x-pulse-request-timestamp': timestamp
|
'x-pulse-request-timestamp': timestamp
|
||||||
}, authHeaders)
|
}, authHeaders)
|
||||||
});
|
});
|
||||||
|
thinClientSocket = socket;
|
||||||
|
|
||||||
|
function sendHeartbeat() {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'heartbeat',
|
||||||
|
deviceId: PLAYER_DEVICE_ID,
|
||||||
|
publicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
|
internalBaseUrl: PLAYER_INTERNAL_URL
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshThinClientRegistration = sendHeartbeat;
|
||||||
|
|
||||||
|
function triggerMediaSyncIfNeeded() {
|
||||||
|
if (webMediaSyncTriggered || webMediaSyncCompleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
webMediaSyncTriggered = true;
|
||||||
|
triggerWebMediaSync().then(function (success) {
|
||||||
|
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
|
||||||
|
if (!success) {
|
||||||
|
webMediaSyncTriggered = false;
|
||||||
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
webMediaSyncTriggered = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFontSyncIfNeeded() {
|
||||||
|
if (webFontSyncTriggered || webFontSyncCompleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
webFontSyncTriggered = true;
|
||||||
|
triggerWebFontSync().then(function (success) {
|
||||||
|
webFontSyncCompleted = Boolean(success) || webFontSyncCompleted;
|
||||||
|
if (!success) {
|
||||||
|
webFontSyncTriggered = false;
|
||||||
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
webFontSyncTriggered = false;
|
||||||
|
webFontSyncCompleted = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSnapshot(slug) {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'snapshot',
|
||||||
|
deviceId: PLAYER_DEVICE_ID,
|
||||||
|
slug: String(slug || '').trim(),
|
||||||
|
connections: playerRuntime.snapshotConnections(slug)
|
||||||
|
}));
|
||||||
|
} catch (_error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('open', function () {
|
socket.on('open', function () {
|
||||||
logPlayerStartup({
|
logPlayerStartup({
|
||||||
@@ -265,42 +437,40 @@ async function start() {
|
|||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'register',
|
type: 'register',
|
||||||
deviceId: PLAYER_DEVICE_ID,
|
deviceId: PLAYER_DEVICE_ID,
|
||||||
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
publicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
internalBaseUrl: PLAYER_INTERNAL_URL
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!webMediaSyncCompleted) {
|
playerRuntime.snapshotSlugs().forEach(function (slug) {
|
||||||
triggerWebMediaSync().then(function (success) {
|
sendSnapshot(slug);
|
||||||
webMediaSyncTriggered = Boolean(success);
|
|
||||||
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
|
|
||||||
}).catch(function () {
|
|
||||||
webMediaSyncTriggered = false;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
heartbeatTimer = setInterval(function () {
|
heartbeatTimer = setInterval(function () {
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
sendHeartbeat();
|
||||||
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);
|
}, DB_SYNC_INTERVAL_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('message', function (rawMessage) {
|
socket.on('message', function (rawMessage) {
|
||||||
|
let parsedMessage = null;
|
||||||
|
try {
|
||||||
|
parsedMessage = JSON.parse(String(rawMessage || '{}'));
|
||||||
|
} catch (_error) {
|
||||||
|
parsedMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedMessage && String(parsedMessage.type || '').trim() === 'registered') {
|
||||||
|
sendHeartbeat();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedMessage && String(parsedMessage.type || '').trim() === 'heartbeat-ack') {
|
||||||
|
if (shouldTriggerReconnectSync()) {
|
||||||
|
triggerMediaSyncIfNeeded();
|
||||||
|
triggerFontSyncIfNeeded();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
handleThinClientCommand(socket, rawMessage).catch(function (error) {
|
handleThinClientCommand(socket, rawMessage).catch(function (error) {
|
||||||
try {
|
try {
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
@@ -316,8 +486,14 @@ async function start() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('close', function () {
|
socket.on('close', function () {
|
||||||
|
lastDisconnectAt = Date.now();
|
||||||
|
webFontSyncTriggered = false;
|
||||||
|
webFontSyncCompleted = false;
|
||||||
|
webMediaSyncCompleted = false;
|
||||||
clearTimers();
|
clearTimers();
|
||||||
reconnectTimer = setTimeout(connect, 5000);
|
thinClientSocket = null;
|
||||||
|
refreshThinClientRegistration = null;
|
||||||
|
reconnectTimer = setTimeout(connect, PLAYER_AGENT_RECONNECT_DELAY_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', function () {
|
socket.on('error', function () {
|
||||||
@@ -363,8 +539,8 @@ async function start() {
|
|||||||
try {
|
try {
|
||||||
await recordPlayerHeartbeat(pool, {
|
await recordPlayerHeartbeat(pool, {
|
||||||
deviceId: PLAYER_DEVICE_ID,
|
deviceId: PLAYER_DEVICE_ID,
|
||||||
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
publicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
internalBaseUrl: PLAYER_INTERNAL_URL
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
@@ -392,7 +568,7 @@ async function start() {
|
|||||||
|
|
||||||
if (PLAYER_DEVICE_ID && !isRemotePlayer) {
|
if (PLAYER_DEVICE_ID && !isRemotePlayer) {
|
||||||
const { upsertPlayerRegistration } = require('./player/onboarding');
|
const { upsertPlayerRegistration } = require('./player/onboarding');
|
||||||
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL).catch(function (error) {
|
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,17 +16,18 @@ function normalizeDeviceId(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPublicBaseUrl(req, configuredUrl) {
|
function getPublicBaseUrl(req, configuredUrl) {
|
||||||
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
|
||||||
if (configured) {
|
|
||||||
return configured;
|
|
||||||
}
|
|
||||||
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
||||||
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
|
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
|
||||||
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0];
|
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0];
|
||||||
const host = forwardedHost || String(req.headers.host || '').trim();
|
const host = forwardedHost || String(req.headers.host || '').trim();
|
||||||
|
if (host) {
|
||||||
return `${protocol}://${host}`.replace(/\/$/, '');
|
return `${protocol}://${host}`.replace(/\/$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||||
|
return configured || null;
|
||||||
|
}
|
||||||
|
|
||||||
function getRequestIp(req) {
|
function getRequestIp(req) {
|
||||||
const forwardedFor = String(req && req.headers && req.headers['x-forwarded-for'] || '').trim().split(',')[0];
|
const forwardedFor = String(req && req.headers && req.headers['x-forwarded-for'] || '').trim().split(',')[0];
|
||||||
if (forwardedFor) {
|
if (forwardedFor) {
|
||||||
@@ -50,7 +51,7 @@ function getPlayerPublicBaseUrl(req, configuredUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPlayerInternalBaseUrl(configuredUrl) {
|
function getPlayerInternalBaseUrl(configuredUrl) {
|
||||||
const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||||
return configured || null;
|
return configured || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,22 +204,22 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
const common = options && options.common ? options.common : null;
|
const common = options && options.common ? options.common : null;
|
||||||
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||||
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
|
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
|
||||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||||
const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
|
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
||||||
const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
|
const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
|
||||||
|
|
||||||
if (!app || !common) {
|
if (!app || !common) {
|
||||||
throw new Error('registerPlayerOnboardingRoutes requires app and common.');
|
throw new Error('registerPlayerOnboardingRoutes requires app and common.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!thinClientBaseUrl && (!pool || !playerRuntime)) {
|
if (!bridgeBaseUrl && (!pool || !playerRuntime)) {
|
||||||
throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless thinClientBaseUrl is configured.');
|
throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless bridgeBaseUrl is configured.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedSecret = getSharedSecret();
|
const sharedSecret = getSharedSecret();
|
||||||
|
|
||||||
async function fetchThinClient(req, pathname, options) {
|
async function fetchThinClient(req, pathname, options) {
|
||||||
if (!thinClientBaseUrl) {
|
if (!bridgeBaseUrl) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +240,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
headers['content-type'] = requestOptions.contentType;
|
headers['content-type'] = requestOptions.contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(new URL(pathname, thinClientBaseUrl).toString(), {
|
return fetch(new URL(pathname, bridgeBaseUrl).toString(), {
|
||||||
method: method,
|
method: method,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
|
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
|
||||||
@@ -305,7 +306,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
}, async function (req, res, next) {
|
}, async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
|
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
|
||||||
if (thinClientBaseUrl) {
|
if (bridgeBaseUrl) {
|
||||||
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), {
|
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), {
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
});
|
});
|
||||||
@@ -317,7 +318,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
if (!payload) {
|
if (!payload) {
|
||||||
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
|
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;
|
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null;
|
||||||
return res.json(payload);
|
return res.json(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +330,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
screenId: status ? status.screen_id : null,
|
screenId: status ? status.screen_id : null,
|
||||||
screenSlug: status ? status.screen_slug : null,
|
screenSlug: status ? status.screen_slug : null,
|
||||||
screenName: status ? status.screen_name : null,
|
screenName: status ? status.screen_name : null,
|
||||||
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null
|
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -338,7 +339,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
|
|
||||||
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
|
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
|
||||||
try {
|
try {
|
||||||
if (thinClientBaseUrl) {
|
if (bridgeBaseUrl) {
|
||||||
const response = await fetchThinClient(_req, '/api/onboarding/screens', {
|
const response = await fetchThinClient(_req, '/api/onboarding/screens', {
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
});
|
});
|
||||||
@@ -361,7 +362,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
return res.status(400).json({ error: 'Device ID is required' });
|
return res.status(400).json({ error: 'Device ID is required' });
|
||||||
}
|
}
|
||||||
const onboardingUrl = `${getPublicBaseUrl(req, playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
|
const onboardingUrl = `${getPublicBaseUrl(req, playerPublicUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
|
||||||
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
|
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
|
||||||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||||
res.set('Cache-Control', 'no-store');
|
res.set('Cache-Control', 'no-store');
|
||||||
@@ -382,11 +383,11 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
|
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thinClientBaseUrl) {
|
if (bridgeBaseUrl) {
|
||||||
const forwardedBody = Object.assign({}, req.body || {}, {
|
const forwardedBody = Object.assign({}, req.body || {}, {
|
||||||
deviceId: deviceId
|
deviceId: deviceId
|
||||||
});
|
});
|
||||||
const response = await fetch(new URL('/api/onboarding', thinClientBaseUrl).toString(), {
|
const response = await fetch(new URL('/api/onboarding', bridgeBaseUrl).toString(), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: Object.assign({
|
headers: Object.assign({
|
||||||
'content-type': 'application/json'
|
'content-type': 'application/json'
|
||||||
@@ -402,7 +403,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
if (!payload) {
|
if (!payload) {
|
||||||
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
|
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)}`;
|
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`;
|
||||||
return res.json(payload);
|
return res.json(payload);
|
||||||
}
|
}
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
@@ -423,7 +424,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
screenId: status && status.screen_id ? status.screen_id : null,
|
screenId: status && status.screen_id ? status.screen_id : null,
|
||||||
screenSlug: status ? status.screen_slug : screenSlug,
|
screenSlug: status ? status.screen_slug : screenSlug,
|
||||||
screenName: status && status.screen_name ? status.screen_name : null,
|
screenName: status && status.screen_name ? status.screen_name : null,
|
||||||
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`,
|
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`,
|
||||||
queued: Boolean(status && status.queued)
|
queued: Boolean(status && status.queued)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
var form = document.getElementById("onboarding-form");
|
var form = document.getElementById("onboarding-form");
|
||||||
var message = document.getElementById("onboarding-message");
|
var message = document.getElementById("onboarding-message");
|
||||||
var screenSelect = document.getElementById("onboarding-screen-select");
|
var screenSelect = document.getElementById("onboarding-screen-select");
|
||||||
|
function getSessionStorageItem(key) {
|
||||||
|
try { return window.sessionStorage.getItem(key) || ""; } catch (_error) { return ""; }
|
||||||
|
}
|
||||||
|
function setSessionStorageItem(key, value) {
|
||||||
|
try { window.sessionStorage.setItem(key, value); } catch (_error) {}
|
||||||
|
}
|
||||||
function setMessage(value) { if (message) { message.textContent = value || ""; } }
|
function setMessage(value) { if (message) { message.textContent = value || ""; } }
|
||||||
function parseResponseError(response) {
|
function parseResponseError(response) {
|
||||||
return response.text().then(function (text) {
|
return response.text().then(function (text) {
|
||||||
@@ -41,19 +47,17 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
deviceId = window.localStorage.getItem(deviceKey) || "";
|
deviceId = window.sessionStorage.getItem(deviceKey) || "";
|
||||||
}
|
}
|
||||||
if (deviceId) {
|
if (deviceId) {
|
||||||
window.localStorage.setItem(deviceKey, deviceId);
|
window.sessionStorage.setItem(deviceKey, deviceId);
|
||||||
}
|
}
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
loadScreens().then(function () {
|
loadScreens().then(function () {
|
||||||
try {
|
try {
|
||||||
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
|
var storedClientName = getSessionStorageItem(clientNameKey) || "";
|
||||||
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
|
|
||||||
var clientNameInput = form.querySelector("input[name=\"clientName\"]");
|
var clientNameInput = form.querySelector("input[name=\"clientName\"]");
|
||||||
if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; }
|
if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; }
|
||||||
if (screenSelect && storedScreenSlug) { screenSelect.value = storedScreenSlug; }
|
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
});
|
});
|
||||||
form.addEventListener("submit", function (event) {
|
form.addEventListener("submit", function (event) {
|
||||||
@@ -83,7 +87,7 @@
|
|||||||
})
|
})
|
||||||
.then(function (payload) {
|
.then(function (payload) {
|
||||||
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
|
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
|
||||||
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
|
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
|
||||||
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||||
setMessage("Onboarding complete.");
|
setMessage("Onboarding complete.");
|
||||||
if (form) {
|
if (form) {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var deviceKey = "pulse-signage-player-device-id";
|
var deviceKey = "pulse-signage-player-device-id";
|
||||||
var clientNameKey = "pulse-signage-player-client-name";
|
var clientNameKey = "pulse-signage-player-client-name";
|
||||||
|
function getSessionStorageItem(key) {
|
||||||
|
try { return window.sessionStorage.getItem(key) || ""; } catch (_error) { return ""; }
|
||||||
|
}
|
||||||
|
function setSessionStorageItem(key, value) {
|
||||||
|
try { window.sessionStorage.setItem(key, value); } catch (_error) {}
|
||||||
|
}
|
||||||
function getClientNameStorageKey(_screenSlug) {
|
function getClientNameStorageKey(_screenSlug) {
|
||||||
return clientNameKey;
|
return clientNameKey;
|
||||||
}
|
}
|
||||||
@@ -25,10 +31,10 @@
|
|||||||
}
|
}
|
||||||
function getDeviceId() {
|
function getDeviceId() {
|
||||||
var stored = "";
|
var stored = "";
|
||||||
try { stored = window.localStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
|
try { stored = window.sessionStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
|
||||||
if (stored) { return stored; }
|
if (stored) { return stored; }
|
||||||
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "device-" + Date.now() + "-" + Math.random().toString(16).slice(2));
|
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "device-" + Date.now() + "-" + Math.random().toString(16).slice(2));
|
||||||
try { window.localStorage.setItem(deviceKey, next); } catch (_error2) {}
|
try { window.sessionStorage.setItem(deviceKey, next); } catch (_error2) {}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
function setStatus(message) { if (status) { status.textContent = message; } }
|
function setStatus(message) { if (status) { status.textContent = message; } }
|
||||||
@@ -83,8 +89,8 @@
|
|||||||
})
|
})
|
||||||
.then(function (payload) {
|
.then(function (payload) {
|
||||||
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
|
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
|
||||||
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
|
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
|
||||||
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
|
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
|
||||||
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||||
setLocalMessage("Onboarding complete.");
|
setLocalMessage("Onboarding complete.");
|
||||||
if (localForm) {
|
if (localForm) {
|
||||||
@@ -99,8 +105,8 @@
|
|||||||
.then(function (response) { return response.ok ? response.json() : null; })
|
.then(function (response) { return response.ok ? response.json() : null; })
|
||||||
.then(function (payload) {
|
.then(function (payload) {
|
||||||
if (payload && payload.onboarded && payload.screenSlug) {
|
if (payload && payload.onboarded && payload.screenSlug) {
|
||||||
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
|
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
|
||||||
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
|
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
|
||||||
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||||
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
|
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
|
||||||
return true;
|
return true;
|
||||||
@@ -126,14 +132,11 @@
|
|||||||
}
|
}
|
||||||
loadScreens().then(function () {
|
loadScreens().then(function () {
|
||||||
try {
|
try {
|
||||||
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
|
var storedClientName = getSessionStorageItem(clientNameKey) || "";
|
||||||
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
|
|
||||||
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
|
|
||||||
if (storedClientName && localForm) {
|
if (storedClientName && localForm) {
|
||||||
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
|
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
|
||||||
if (clientNameInput) { clientNameInput.value = storedClientName; }
|
if (clientNameInput) { clientNameInput.value = storedClientName; }
|
||||||
}
|
}
|
||||||
if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; }
|
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
});
|
});
|
||||||
redirectIfOnboarded(deviceId).then(function (redirected) {
|
redirectIfOnboarded(deviceId).then(function (redirected) {
|
||||||
|
|||||||
@@ -4,9 +4,25 @@
|
|||||||
const onboardingClientNameStorageKey = 'pulse-signage-player-client-name';
|
const onboardingClientNameStorageKey = 'pulse-signage-player-client-name';
|
||||||
const onboardingDeviceIdStorageKey = 'pulse-signage-player-device-id';
|
const onboardingDeviceIdStorageKey = 'pulse-signage-player-device-id';
|
||||||
|
|
||||||
|
function getSessionStorageItem(key) {
|
||||||
|
try {
|
||||||
|
return window.sessionStorage.getItem(key) || '';
|
||||||
|
} catch (_error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionStorageItem(key, value) {
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(key, value);
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getOnboardingDeviceId() {
|
function getOnboardingDeviceId() {
|
||||||
try {
|
try {
|
||||||
var storedDeviceId = window.localStorage.getItem(onboardingDeviceIdStorageKey) || '';
|
var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey);
|
||||||
return String(storedDeviceId || '').trim();
|
return String(storedDeviceId || '').trim();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return '';
|
return '';
|
||||||
@@ -19,24 +35,14 @@
|
|||||||
return onboardingClientName;
|
return onboardingClientName;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
var storedClientName = window.localStorage.getItem(onboardingClientNameStorageKey);
|
var storedClientName = getSessionStorageItem(onboardingClientNameStorageKey);
|
||||||
if (storedClientName) {
|
if (storedClientName) {
|
||||||
onboardingClientName = storedClientName;
|
onboardingClientName = storedClientName;
|
||||||
try {
|
|
||||||
window.localStorage.setItem('pulse-signage-player-client-name', storedClientName);
|
|
||||||
} catch (_mirrorError) {
|
|
||||||
// ignore storage errors
|
|
||||||
}
|
|
||||||
return onboardingClientName;
|
return onboardingClientName;
|
||||||
}
|
}
|
||||||
var genericClientName = window.localStorage.getItem('pulse-signage-player-client-name');
|
var genericClientName = getSessionStorageItem('pulse-signage-player-client-name');
|
||||||
if (genericClientName) {
|
if (genericClientName) {
|
||||||
onboardingClientName = genericClientName;
|
onboardingClientName = genericClientName;
|
||||||
try {
|
|
||||||
window.localStorage.setItem(onboardingClientNameStorageKey, genericClientName);
|
|
||||||
} catch (_error) {
|
|
||||||
// ignore storage errors
|
|
||||||
}
|
|
||||||
return onboardingClientName;
|
return onboardingClientName;
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
@@ -51,12 +57,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onboardingClientName = normalizedName;
|
onboardingClientName = normalizedName;
|
||||||
try {
|
setSessionStorageItem('pulse-signage-player-client-name', normalizedName);
|
||||||
window.localStorage.setItem('pulse-signage-player-client-name', normalizedName);
|
setSessionStorageItem(onboardingClientNameStorageKey, normalizedName);
|
||||||
window.localStorage.setItem(onboardingClientNameStorageKey, normalizedName);
|
|
||||||
} catch (_error) {
|
|
||||||
// ignore storage errors
|
|
||||||
}
|
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
sendCommandState(socket);
|
sendCommandState(socket);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,13 +188,13 @@ function syncWebpagePreloads(sourceSlides, targetIndex) {
|
|||||||
preloadSignature = signature;
|
preloadSignature = signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a stable client id for this browser session.
|
// Return a stable client id for this screen session.
|
||||||
function getCommandClientId() {
|
function getCommandClientId() {
|
||||||
if (commandClientId) {
|
if (commandClientId) {
|
||||||
return commandClientId;
|
return commandClientId;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
var storedClientId = window.localStorage.getItem(commandClientStorageKey);
|
var storedClientId = window.sessionStorage.getItem(commandClientStorageKey);
|
||||||
if (storedClientId) {
|
if (storedClientId) {
|
||||||
commandClientId = storedClientId;
|
commandClientId = storedClientId;
|
||||||
return commandClientId;
|
return commandClientId;
|
||||||
@@ -204,7 +204,7 @@ function getCommandClientId() {
|
|||||||
}
|
}
|
||||||
commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2));
|
commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2));
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(commandClientStorageKey, commandClientId);
|
window.sessionStorage.setItem(commandClientStorageKey, commandClientId);
|
||||||
} catch (_error2) {
|
} catch (_error2) {
|
||||||
// ignore storage errors
|
// ignore storage errors
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-18
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||||
|
const { getPlayerPublicBaseUrl } = require('./onboarding');
|
||||||
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
||||||
|
|
||||||
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
||||||
@@ -31,23 +32,23 @@ function registerPlayerRoutes(app, options) {
|
|||||||
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||||
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
|
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
|
||||||
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
|
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
|
||||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_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 bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
||||||
const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
|
|
||||||
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
|
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
|
||||||
|
const onPlayerPublicBaseUrl = typeof options.onPlayerPublicBaseUrl === 'function' ? options.onPlayerPublicBaseUrl : null;
|
||||||
|
|
||||||
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
|
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
|
||||||
throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.');
|
throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!thinClientBaseUrl && (!pool || !playerPlaylistService)) {
|
if (!bridgeBaseUrl && (!pool || !playerPlaylistService)) {
|
||||||
throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless thinClientBaseUrl is configured.');
|
throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless bridgeBaseUrl is configured.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedSecret = getSharedSecret();
|
const sharedSecret = getSharedSecret();
|
||||||
|
|
||||||
async function fetchThinClient(req, pathname, options) {
|
async function fetchBridge(req, pathname, options) {
|
||||||
if (!thinClientBaseUrl) {
|
if (!bridgeBaseUrl) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ function registerPlayerRoutes(app, options) {
|
|||||||
headers['content-type'] = requestOptions.contentType;
|
headers['content-type'] = requestOptions.contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(new URL(pathname, thinClientBaseUrl).toString(), {
|
return fetch(new URL(pathname, bridgeBaseUrl).toString(), {
|
||||||
method: method,
|
method: method,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
|
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
|
||||||
@@ -179,8 +180,8 @@ function registerPlayerRoutes(app, options) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
|
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
|
||||||
if (thinClientBaseUrl) {
|
if (bridgeBaseUrl) {
|
||||||
void fetch(new URL('/api/media/config', thinClientBaseUrl).toString(), {
|
void fetch(new URL('/api/media/config', bridgeBaseUrl).toString(), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: createRequestAuthHeaders({
|
headers: createRequestAuthHeaders({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -296,11 +297,18 @@ function registerPlayerRoutes(app, options) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/screen/:slug', function (req, res) {
|
app.get('/screen/:slug', function (req, res) {
|
||||||
|
if (onPlayerPublicBaseUrl) {
|
||||||
|
try {
|
||||||
|
onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null));
|
||||||
|
} catch (_error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
res.set('Pragma', 'no-cache');
|
res.set('Pragma', 'no-cache');
|
||||||
if (thinClientBaseUrl) {
|
if (bridgeBaseUrl) {
|
||||||
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(req.params.slug || '').trim() }).token;
|
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(), {
|
void fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
|
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
|
||||||
}).then(async function (response) {
|
}).then(async function (response) {
|
||||||
@@ -342,8 +350,8 @@ function registerPlayerRoutes(app, options) {
|
|||||||
|
|
||||||
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
if (thinClientBaseUrl) {
|
if (bridgeBaseUrl) {
|
||||||
const response = await fetchThinClient(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', {
|
const response = await fetchBridge(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', {
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
});
|
});
|
||||||
if (!response) {
|
if (!response) {
|
||||||
@@ -397,8 +405,8 @@ function registerPlayerRoutes(app, options) {
|
|||||||
|
|
||||||
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
if (thinClientBaseUrl) {
|
if (bridgeBaseUrl) {
|
||||||
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
|
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
});
|
});
|
||||||
if (!response) {
|
if (!response) {
|
||||||
@@ -440,8 +448,8 @@ function registerPlayerRoutes(app, options) {
|
|||||||
|
|
||||||
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
|
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
if (thinClientBaseUrl) {
|
if (bridgeBaseUrl) {
|
||||||
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
|
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
});
|
});
|
||||||
if (!response) {
|
if (!response) {
|
||||||
|
|||||||
+88
-15
@@ -21,6 +21,7 @@ function normalizePlayerPublicBaseUrl(pageUrl) {
|
|||||||
|
|
||||||
function createPlayerRuntime(options) {
|
function createPlayerRuntime(options) {
|
||||||
const pool = options && options.pool ? options.pool : null;
|
const pool = options && options.pool ? options.pool : null;
|
||||||
|
const notifySnapshot = typeof options.notifySnapshot === 'function' ? options.notifySnapshot : null;
|
||||||
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
|
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
|
||||||
? options.normalizeDeviceId
|
? options.normalizeDeviceId
|
||||||
: function (value) {
|
: function (value) {
|
||||||
@@ -44,6 +45,76 @@ function createPlayerRuntime(options) {
|
|||||||
return ip;
|
return ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstHeaderValue(value) {
|
||||||
|
return String(value || '').trim().split(',')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateOrReservedIp(ip) {
|
||||||
|
const normalized = normalizeClientIp(ip);
|
||||||
|
if (!normalized) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = normalized.toLowerCase();
|
||||||
|
if (lower === 'localhost' || lower === '::1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^10\./.test(lower) || /^192\.168\./.test(lower) || /^127\./.test(lower) || /^169\.254\./.test(lower)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(lower)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80:') || lower.startsWith('::ffff:127.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickForwardedIp(candidates) {
|
||||||
|
const normalizedCandidates = Array.isArray(candidates)
|
||||||
|
? candidates.map(function (candidate) {
|
||||||
|
return normalizeClientIp(candidate);
|
||||||
|
}).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const publicCandidate = normalizedCandidates.find(function (candidate) {
|
||||||
|
return !isPrivateOrReservedIp(candidate);
|
||||||
|
});
|
||||||
|
|
||||||
|
return publicCandidate || normalizedCandidates[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequestIp(request) {
|
||||||
|
const forwardedFor = String(request && request.headers && request.headers['x-forwarded-for'] || '').split(',');
|
||||||
|
const forwardedForIp = pickForwardedIp(forwardedFor);
|
||||||
|
if (forwardedForIp) {
|
||||||
|
return forwardedForIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIp = pickForwardedIp([firstHeaderValue(request && request.headers && request.headers['x-real-ip'])]);
|
||||||
|
if (realIp) {
|
||||||
|
return realIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwarded = firstHeaderValue(request && request.headers && request.headers.forwarded);
|
||||||
|
if (forwarded) {
|
||||||
|
const forwardedMatches = Array.from(forwarded.matchAll(/(?:^|,\s*|;\s*)for=(?:"?\[?)([^"\];,\s]+)/gi)).map(function (match) {
|
||||||
|
return match[1];
|
||||||
|
});
|
||||||
|
const forwardedIp = pickForwardedIp(forwardedMatches);
|
||||||
|
if (forwardedIp) {
|
||||||
|
return forwardedIp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeClientIp(request && request.socket && request.socket.remoteAddress);
|
||||||
|
}
|
||||||
|
|
||||||
function parseCookies(cookieHeader) {
|
function parseCookies(cookieHeader) {
|
||||||
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
|
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
|
||||||
if (!pair) {
|
if (!pair) {
|
||||||
@@ -144,7 +215,6 @@ function createPlayerRuntime(options) {
|
|||||||
const clientName = String(connection.clientName || '').trim();
|
const clientName = String(connection.clientName || '').trim();
|
||||||
const clientId = String(connection.clientId || '').trim();
|
const clientId = String(connection.clientId || '').trim();
|
||||||
const userAgent = String(connection.userAgent || '').trim();
|
const userAgent = String(connection.userAgent || '').trim();
|
||||||
const clientIp = String(connection.clientIp || '').trim();
|
|
||||||
const viewport = connection.viewport && typeof connection.viewport === 'object'
|
const viewport = connection.viewport && typeof connection.viewport === 'object'
|
||||||
? connection.viewport
|
? connection.viewport
|
||||||
: null;
|
: null;
|
||||||
@@ -160,10 +230,6 @@ function createPlayerRuntime(options) {
|
|||||||
labelParts.push(`id ${clientId.slice(-6)}`);
|
labelParts.push(`id ${clientId.slice(-6)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clientIp) {
|
|
||||||
labelParts.push(clientIp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
|
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
|
||||||
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
|
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
|
||||||
}
|
}
|
||||||
@@ -197,8 +263,6 @@ function createPlayerRuntime(options) {
|
|||||||
blackout: Boolean(connection.blackout),
|
blackout: Boolean(connection.blackout),
|
||||||
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
|
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
|
||||||
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null,
|
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null,
|
||||||
clientIp: connection.clientIp || null,
|
|
||||||
remoteAddress: connection.remoteAddress || null,
|
|
||||||
connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
|
connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
|
||||||
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
|
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
|
||||||
};
|
};
|
||||||
@@ -222,6 +286,10 @@ function createPlayerRuntime(options) {
|
|||||||
return allConnections;
|
return allConnections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function snapshotSlugs() {
|
||||||
|
return Array.from(connectionsBySlug.keys());
|
||||||
|
}
|
||||||
|
|
||||||
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
|
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
|
||||||
return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections());
|
return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections());
|
||||||
}
|
}
|
||||||
@@ -229,6 +297,16 @@ function createPlayerRuntime(options) {
|
|||||||
function broadcastConnectionSnapshot(slug) {
|
function broadcastConnectionSnapshot(slug) {
|
||||||
const key = String(slug || '').trim();
|
const key = String(slug || '').trim();
|
||||||
const bucket = dashboardListenersBySlug.get(key);
|
const bucket = dashboardListenersBySlug.get(key);
|
||||||
|
const connections = snapshotConnections(slug);
|
||||||
|
if (notifySnapshot) {
|
||||||
|
try {
|
||||||
|
notifySnapshot({
|
||||||
|
slug: key,
|
||||||
|
connections: connections
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!bucket || !bucket.size) {
|
if (!bucket || !bucket.size) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -236,7 +314,7 @@ function createPlayerRuntime(options) {
|
|||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
type: 'snapshot',
|
type: 'snapshot',
|
||||||
slug: key,
|
slug: key,
|
||||||
connections: snapshotConnections(slug),
|
connections: connections,
|
||||||
sentAt: new Date().toISOString()
|
sentAt: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -416,9 +494,6 @@ function createPlayerRuntime(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
|
|
||||||
const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]);
|
|
||||||
const normalizedRemoteAddress = normalizeClientIp(remoteAddress);
|
|
||||||
const connectionId = crypto.randomUUID();
|
const connectionId = crypto.randomUUID();
|
||||||
const connection = {
|
const connection = {
|
||||||
id: connectionId,
|
id: connectionId,
|
||||||
@@ -433,9 +508,7 @@ function createPlayerRuntime(options) {
|
|||||||
paused: false,
|
paused: false,
|
||||||
blackout: false,
|
blackout: false,
|
||||||
playerPublicBaseUrl: null,
|
playerPublicBaseUrl: null,
|
||||||
clientIp: forwardedFor || normalizedRemoteAddress,
|
label: 'connected client',
|
||||||
remoteAddress: normalizedRemoteAddress,
|
|
||||||
label: forwardedFor || normalizedRemoteAddress || 'connected client',
|
|
||||||
connectedAt: new Date(),
|
connectedAt: new Date(),
|
||||||
lastSeenAt: new Date()
|
lastSeenAt: new Date()
|
||||||
};
|
};
|
||||||
@@ -476,7 +549,6 @@ function createPlayerRuntime(options) {
|
|||||||
}
|
}
|
||||||
connection.paused = Boolean(payload.paused);
|
connection.paused = Boolean(payload.paused);
|
||||||
connection.blackout = Boolean(payload.blackout);
|
connection.blackout = Boolean(payload.blackout);
|
||||||
connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp;
|
|
||||||
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
|
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
|
||||||
id: payload.currentSlide.id || null,
|
id: payload.currentSlide.id || null,
|
||||||
title: payload.currentSlide.title || '',
|
title: payload.currentSlide.title || '',
|
||||||
@@ -508,6 +580,7 @@ function createPlayerRuntime(options) {
|
|||||||
broadcastAnnouncementRefresh: broadcastAnnouncementRefresh,
|
broadcastAnnouncementRefresh: broadcastAnnouncementRefresh,
|
||||||
snapshotConnections: snapshotConnections,
|
snapshotConnections: snapshotConnections,
|
||||||
snapshotAllConnections: snapshotAllConnections,
|
snapshotAllConnections: snapshotAllConnections,
|
||||||
|
snapshotSlugs: snapshotSlugs,
|
||||||
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
|
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
|
||||||
sendCommandToConnection: sendCommandToConnection,
|
sendCommandToConnection: sendCommandToConnection,
|
||||||
broadcastCommand: broadcastCommand
|
broadcastCommand: broadcastCommand
|
||||||
|
|||||||
+7
-4
@@ -194,18 +194,21 @@ const PERMISSION_SECTIONS = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) {
|
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section, sectionIndex) {
|
||||||
return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource) {
|
return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource, resourceIndex) {
|
||||||
return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, index) {
|
return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, actionIndex) {
|
||||||
return {
|
return {
|
||||||
key: `${resource.key}.${action.key}`,
|
key: `${resource.key}.${action.key}`,
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
sectionOrder: section.order,
|
sectionOrder: section.order,
|
||||||
|
sectionIndex: sectionIndex,
|
||||||
actionName: action.name,
|
actionName: action.name,
|
||||||
permissionOrder: index + 1,
|
permissionOrder: actionIndex + 1,
|
||||||
|
permissionIndex: actionIndex,
|
||||||
sectionName: section.sectionName,
|
sectionName: section.sectionName,
|
||||||
resourceKey: resource.key,
|
resourceKey: resource.key,
|
||||||
resourceOrder: resource.order,
|
resourceOrder: resource.order,
|
||||||
|
resourceIndex: resourceIndex,
|
||||||
resourceName: resource.name,
|
resourceName: resource.name,
|
||||||
actionKey: action.key,
|
actionKey: action.key,
|
||||||
description: action.description
|
description: action.description
|
||||||
|
|||||||
+5
-4
@@ -62,7 +62,8 @@ async function start() {
|
|||||||
const playerActionService = createPlayerActionService({
|
const playerActionService = createPlayerActionService({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
playerInternalBaseUrl: webConfig.thinClientBaseUrl
|
playerInternalBaseUrl: webConfig.playerInternalUrl,
|
||||||
|
bridgeInternalBaseUrl: webConfig.bridgeInternalUrl
|
||||||
});
|
});
|
||||||
const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
|
const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
|
||||||
|
|
||||||
@@ -70,8 +71,8 @@ async function start() {
|
|||||||
const webBootstrap = createWebBootstrap({
|
const webBootstrap = createWebBootstrap({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
playerInternalBaseUrl: webConfig.playerInternalBaseUrl,
|
playerInternalBaseUrl: webConfig.playerInternalUrl,
|
||||||
thinClientBaseUrl: webConfig.thinClientBaseUrl,
|
bridgeInternalBaseUrl: webConfig.bridgeInternalUrl,
|
||||||
uploadDir: webConfig.uploadsDir,
|
uploadDir: webConfig.uploadsDir,
|
||||||
formatDashboardDate: formatDashboardDate,
|
formatDashboardDate: formatDashboardDate,
|
||||||
notifyPlayerScreens: notifyPlayerScreens,
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
@@ -210,7 +211,7 @@ async function start() {
|
|||||||
initializeBackgroundTasks: initializeBackgroundTasks,
|
initializeBackgroundTasks: initializeBackgroundTasks,
|
||||||
captureSlideThumbnail: captureSlideThumbnail,
|
captureSlideThumbnail: captureSlideThumbnail,
|
||||||
server: server,
|
server: server,
|
||||||
webBaseUrl: webConfig.webBaseUrl,
|
webBaseUrl: webConfig.webInternalUrl,
|
||||||
dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs
|
dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Vendored
+6
-6
@@ -8,8 +8,8 @@ const { createRequestAuthHeaders } = require('#src/request-auth');
|
|||||||
function createWebBootstrap(options) {
|
function createWebBootstrap(options) {
|
||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
const configuredPlayerInternalUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||||
const configuredThinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
|
const configuredBridgeInternalUrl = String(options && options.bridgeInternalBaseUrl || process.env.BRIDGE_INTERNAL_URL || '').trim().replace(/\/$/, '');
|
||||||
const uploadDir = String(options && options.uploadDir || '').trim();
|
const uploadDir = String(options && options.uploadDir || '').trim();
|
||||||
const dashboardRefreshIntervalMs = 5000;
|
const dashboardRefreshIntervalMs = 5000;
|
||||||
const formatDashboardDate = options && options.formatDashboardDate;
|
const formatDashboardDate = options && options.formatDashboardDate;
|
||||||
@@ -27,7 +27,7 @@ function createWebBootstrap(options) {
|
|||||||
let dashboardRefreshInFlight = null;
|
let dashboardRefreshInFlight = null;
|
||||||
let broadcastDashboardState = null;
|
let broadcastDashboardState = null;
|
||||||
function getPlayerSnapshotSocketUrl(slug) {
|
function getPlayerSnapshotSocketUrl(slug) {
|
||||||
const resolvedPlayerInternalBaseUrl = configuredThinClientBaseUrl || configuredPlayerInternalBaseUrl;
|
const resolvedPlayerInternalBaseUrl = configuredBridgeInternalUrl || configuredPlayerInternalUrl;
|
||||||
if (!resolvedPlayerInternalBaseUrl) {
|
if (!resolvedPlayerInternalBaseUrl) {
|
||||||
throw new Error('Unable to resolve the player internal base URL.');
|
throw new Error('Unable to resolve the player internal base URL.');
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ function createWebBootstrap(options) {
|
|||||||
const dashboardStateService = createDashboardStateService({
|
const dashboardStateService = createDashboardStateService({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
thinClientBaseUrl: configuredThinClientBaseUrl,
|
thinClientBaseUrl: configuredBridgeInternalUrl,
|
||||||
playerSnapshotCache: playerSnapshotCache,
|
playerSnapshotCache: playerSnapshotCache,
|
||||||
playerSnapshotSockets: playerSnapshotSockets,
|
playerSnapshotSockets: playerSnapshotSockets,
|
||||||
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
||||||
@@ -120,7 +120,7 @@ function createWebBootstrap(options) {
|
|||||||
const uploadSyncService = createUploadSyncService({
|
const uploadSyncService = createUploadSyncService({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
playerInternalBaseUrl: configuredPlayerInternalBaseUrl,
|
playerInternalBaseUrl: configuredPlayerInternalUrl,
|
||||||
playerSnapshotCache: playerSnapshotCache,
|
playerSnapshotCache: playerSnapshotCache,
|
||||||
notifyPlayerScreens: notifyPlayerScreens,
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
backgroundTaskQueue: backgroundTaskQueue
|
backgroundTaskQueue: backgroundTaskQueue
|
||||||
@@ -253,7 +253,7 @@ function createWebBootstrap(options) {
|
|||||||
return {
|
return {
|
||||||
upload: upload,
|
upload: upload,
|
||||||
uploadSyncService: uploadSyncService,
|
uploadSyncService: uploadSyncService,
|
||||||
playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null,
|
playerInternalBaseUrl: configuredPlayerInternalUrl || null,
|
||||||
buildDashboardState: buildDashboardState,
|
buildDashboardState: buildDashboardState,
|
||||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||||
|
|||||||
@@ -52,7 +52,13 @@ function registerStartupTasks(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
await loadTaskModules(backgroundTaskDirectory, options);
|
const startupTaskFile = path.join(backgroundTaskDirectory, 'data-source-refresh.js');
|
||||||
|
const taskModule = require(startupTaskFile);
|
||||||
|
const exported = getTaskExport(taskModule);
|
||||||
|
|
||||||
|
if (typeof exported === 'function') {
|
||||||
|
await exported(options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ function registerFontSyncTask(options) {
|
|||||||
backgroundTaskQueue.setTaskHandler('font-sync', async function (task) {
|
backgroundTaskQueue.setTaskHandler('font-sync', async function (task) {
|
||||||
const payload = task && task.payload ? task.payload : task || {};
|
const payload = task && task.payload ? task.payload : task || {};
|
||||||
const uploadDir = String(payload.uploadDir || '').trim();
|
const uploadDir = String(payload.uploadDir || '').trim();
|
||||||
|
const playerIdentifier = String(payload.playerIdentifier || payload.deviceId || '').trim();
|
||||||
const operations = Array.isArray(payload.operations)
|
const operations = Array.isArray(payload.operations)
|
||||||
? payload.operations
|
? payload.operations
|
||||||
: Array.isArray(payload.uploadPaths)
|
: Array.isArray(payload.uploadPaths)
|
||||||
@@ -28,9 +29,9 @@ function registerFontSyncTask(options) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
||||||
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir);
|
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
|
||||||
} else {
|
} else {
|
||||||
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir);
|
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,26 +18,20 @@ function registerFontSweepTask(options) {
|
|||||||
const uploadSyncService = options && options.uploadSyncService;
|
const uploadSyncService = options && options.uploadSyncService;
|
||||||
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
|
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
|
||||||
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
|
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
|
||||||
const getPlayerTaskMetadata = uploadSyncService && uploadSyncService.getPlayerTaskMetadata;
|
|
||||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||||
|
|
||||||
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
|
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
|
||||||
throw new Error('registerFontSweepTask requires the font sweep dependencies.');
|
throw new Error('registerFontSweepTask requires the font sweep dependencies.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadataPromise = typeof getPlayerTaskMetadata === 'function'
|
|
||||||
? Promise.resolve(getPlayerTaskMetadata())
|
|
||||||
: Promise.resolve({});
|
|
||||||
|
|
||||||
return metadataPromise.then(function (metadata) {
|
|
||||||
backgroundTaskQueue.registerRecurringTask({
|
backgroundTaskQueue.registerRecurringTask({
|
||||||
key: TASK.key,
|
key: TASK.key,
|
||||||
title: TASK.title,
|
title: TASK.title,
|
||||||
category: TASK.category,
|
category: TASK.category,
|
||||||
intervalMs: TASK.intervalMs,
|
intervalMs: TASK.intervalMs,
|
||||||
metadata: Object.assign({
|
metadata: {
|
||||||
mediaDir: mediaDir
|
mediaDir: mediaDir
|
||||||
}, metadata || {}),
|
},
|
||||||
run: async function () {
|
run: async function () {
|
||||||
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
|
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
|
||||||
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
|
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
|
||||||
@@ -69,7 +63,6 @@ function registerFontSweepTask(options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { registerFontSweepTask };
|
module.exports = { registerFontSweepTask };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const TASK = {
|
||||||
|
key: 'onboarding-device-prune',
|
||||||
|
title: 'Onboarding device prune',
|
||||||
|
category: 'cleanup',
|
||||||
|
trigger: 'scheduled recurring task, hourly',
|
||||||
|
purpose: 'remove stale onboarding device bindings that have been idle for more than one minute.',
|
||||||
|
taskType: 'recurring-run',
|
||||||
|
intervalMs: 60 * 60 * 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
function registerOnboardingDevicePruneTask(options) {
|
||||||
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||||
|
const pool = options && options.pool;
|
||||||
|
const common = options && options.common;
|
||||||
|
|
||||||
|
if (!backgroundTaskQueue || !pool || !common || typeof common.pruneStaleOnboardingDevices !== 'function') {
|
||||||
|
throw new Error('registerOnboardingDevicePruneTask requires the onboarding prune dependencies.');
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundTaskQueue.registerRecurringTask({
|
||||||
|
key: TASK.key,
|
||||||
|
title: TASK.title,
|
||||||
|
category: TASK.category,
|
||||||
|
intervalMs: TASK.intervalMs,
|
||||||
|
metadata: {},
|
||||||
|
run: async function () {
|
||||||
|
await common.pruneStaleOnboardingDevices(pool);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerOnboardingDevicePruneTask };
|
||||||
@@ -1,16 +1,40 @@
|
|||||||
const { collectFontLibrarySyncOperations } = require('../../media/font-library');
|
const { collectFontLibrarySyncOperations } = require('../../media/font-library');
|
||||||
|
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
||||||
|
|
||||||
const TASK = {
|
const TASK = {
|
||||||
key: 'initial-font-sync',
|
key: 'initial-font-sync',
|
||||||
category: 'fonts'
|
category: 'fonts'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 normalizePlayerMetadata(player) {
|
||||||
|
const playerIdentifier = String(player && player.identifier || '').trim();
|
||||||
|
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim();
|
||||||
|
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerIdentifier: playerIdentifier || null,
|
||||||
|
playerPublicBaseUrl: playerPublicBaseUrl || null,
|
||||||
|
playerInternalBaseUrl: playerInternalBaseUrl || null,
|
||||||
|
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null,
|
||||||
|
playerActive: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function registerInitialFontSyncTask(options) {
|
function registerInitialFontSyncTask(options) {
|
||||||
|
const pool = options && options.pool;
|
||||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||||
const uploadSyncService = options && options.uploadSyncService;
|
const uploadSyncService = options && options.uploadSyncService;
|
||||||
|
|
||||||
if (!backgroundTaskQueue || !mediaDir) {
|
if (!pool || !backgroundTaskQueue || !mediaDir) {
|
||||||
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
|
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,20 +42,45 @@ function registerInitialFontSyncTask(options) {
|
|||||||
? uploadSyncService.getPlayerTaskMetadata()
|
? uploadSyncService.getPlayerTaskMetadata()
|
||||||
: Promise.resolve({});
|
: Promise.resolve({});
|
||||||
|
|
||||||
return Promise.resolve(metadataPromise).then(function (metadata) {
|
return Promise.resolve(metadataPromise).then(async function () {
|
||||||
|
let players = [];
|
||||||
|
try {
|
||||||
|
players = await fetchPlayerRegistrations(pool);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to fetch player registrations for initial font sync:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const livePlayers = Array.isArray(players)
|
||||||
|
? players.filter(function (player) {
|
||||||
|
return isRecentPlayerRegistration(player, 60);
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!livePlayers.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operations = collectFontLibrarySyncOperations(mediaDir);
|
||||||
|
return Promise.all(livePlayers.map(function (player) {
|
||||||
|
const metadata = normalizePlayerMetadata(player);
|
||||||
return backgroundTaskQueue.enqueueTask({
|
return backgroundTaskQueue.enqueueTask({
|
||||||
key: TASK.key,
|
key: TASK.key,
|
||||||
title: 'Initial font sync',
|
title: 'Initial font sync',
|
||||||
category: TASK.category,
|
category: TASK.category,
|
||||||
taskType: 'font-sync',
|
taskType: 'font-sync',
|
||||||
metadata: Object.assign({}, metadata || {}),
|
metadata: metadata,
|
||||||
payload: {
|
payload: {
|
||||||
mode: 'initial',
|
mode: 'initial',
|
||||||
uploadDir: mediaDir,
|
uploadDir: mediaDir,
|
||||||
operations: collectFontLibrarySyncOperations(mediaDir)
|
operations: operations,
|
||||||
|
playerIdentifier: metadata.playerIdentifier,
|
||||||
|
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
|
||||||
|
playerInternalBaseUrl: metadata.playerInternalBaseUrl
|
||||||
},
|
},
|
||||||
persist: true
|
persist: true
|
||||||
});
|
});
|
||||||
|
}));
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
console.warn('Unable to queue initial font sync:', error);
|
console.warn('Unable to queue initial font sync:', error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,37 @@ const TASK = {
|
|||||||
category: 'media-sync',
|
category: 'media-sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
||||||
|
|
||||||
|
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 normalizePlayerMetadata(player) {
|
||||||
|
const playerIdentifier = String(player && player.identifier || '').trim();
|
||||||
|
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim();
|
||||||
|
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerIdentifier: playerIdentifier || null,
|
||||||
|
playerPublicBaseUrl: playerPublicBaseUrl || null,
|
||||||
|
playerInternalBaseUrl: playerInternalBaseUrl || null,
|
||||||
|
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null,
|
||||||
|
playerActive: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function registerInitialMediaSyncTask(options) {
|
function registerInitialMediaSyncTask(options) {
|
||||||
|
const pool = options && options.pool;
|
||||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||||
const uploadSyncService = options && options.uploadSyncService;
|
const uploadSyncService = options && options.uploadSyncService;
|
||||||
|
|
||||||
if (!backgroundTaskQueue || !mediaDir) {
|
if (!pool || !backgroundTaskQueue || !mediaDir) {
|
||||||
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
|
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,19 +41,43 @@ function registerInitialMediaSyncTask(options) {
|
|||||||
? uploadSyncService.getPlayerTaskMetadata()
|
? uploadSyncService.getPlayerTaskMetadata()
|
||||||
: Promise.resolve({});
|
: Promise.resolve({});
|
||||||
|
|
||||||
return Promise.resolve(metadataPromise).then(function (metadata) {
|
return Promise.resolve(metadataPromise).then(async function () {
|
||||||
|
let players = [];
|
||||||
|
try {
|
||||||
|
players = await fetchPlayerRegistrations(pool);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to fetch player registrations for initial media sync:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const livePlayers = Array.isArray(players)
|
||||||
|
? players.filter(function (player) {
|
||||||
|
return isRecentPlayerRegistration(player, 60);
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!livePlayers.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(livePlayers.map(function (player) {
|
||||||
|
const metadata = normalizePlayerMetadata(player);
|
||||||
return backgroundTaskQueue.enqueueTask({
|
return backgroundTaskQueue.enqueueTask({
|
||||||
key: TASK.key,
|
key: TASK.key,
|
||||||
title: 'Initial media sync',
|
title: 'Initial media sync',
|
||||||
category: TASK.category,
|
category: TASK.category,
|
||||||
taskType: 'media-sync',
|
taskType: 'media-sync',
|
||||||
metadata: Object.assign({}, metadata || {}),
|
metadata: metadata,
|
||||||
payload: {
|
payload: {
|
||||||
mode: 'initial',
|
mode: 'initial',
|
||||||
uploadDir: mediaDir
|
uploadDir: mediaDir,
|
||||||
|
playerIdentifier: metadata.playerIdentifier,
|
||||||
|
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
|
||||||
|
playerInternalBaseUrl: metadata.playerInternalBaseUrl
|
||||||
},
|
},
|
||||||
persist: true
|
persist: true
|
||||||
});
|
});
|
||||||
|
}));
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
console.warn('Unable to queue initial media sync:', error);
|
console.warn('Unable to queue initial media sync:', error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ function createWebConfig() {
|
|||||||
const uploadsDir = path.join(mediaDir, 'uploads');
|
const uploadsDir = path.join(mediaDir, 'uploads');
|
||||||
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
||||||
const assetDir = path.join(__dirname, '..', 'public');
|
const assetDir = path.join(__dirname, '..', 'public');
|
||||||
const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
|
const playerInternalUrl = (process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
|
||||||
const thinClientBaseUrl = (process.env.THIN_CLIENT_BASE_URL || 'http://player-bridge:8090').replace(/\/$/, '');
|
const bridgeInternalUrl = (process.env.BRIDGE_INTERNAL_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 webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, '');
|
||||||
const sessionCookieName = 'digital_signage_session';
|
const sessionCookieName = 'digital_signage_session';
|
||||||
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
||||||
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
|
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
|
||||||
@@ -20,9 +20,9 @@ function createWebConfig() {
|
|||||||
uploadsDir: uploadsDir,
|
uploadsDir: uploadsDir,
|
||||||
thumbnailsDir: thumbnailsDir,
|
thumbnailsDir: thumbnailsDir,
|
||||||
assetDir: assetDir,
|
assetDir: assetDir,
|
||||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
playerInternalUrl: playerInternalUrl,
|
||||||
thinClientBaseUrl: thinClientBaseUrl,
|
bridgeInternalUrl: bridgeInternalUrl,
|
||||||
webBaseUrl: webBaseUrl,
|
webInternalUrl: webInternalUrl,
|
||||||
sessionCookieName: sessionCookieName,
|
sessionCookieName: sessionCookieName,
|
||||||
sessionMaxAgeMs: sessionMaxAgeMs,
|
sessionMaxAgeMs: sessionMaxAgeMs,
|
||||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ function normalizeBaseUrl(value) {
|
|||||||
return String(value || '').trim().replace(/\/$/, '');
|
return String(value || '').trim().replace(/\/$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendPlayerDeviceIdToUrl(baseUrl, playerIdentifier) {
|
||||||
|
const targetBaseUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
const deviceId = String(playerIdentifier || '').trim();
|
||||||
|
if (!targetBaseUrl || !deviceId) {
|
||||||
|
return targetBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(targetBaseUrl);
|
||||||
|
url.searchParams.set('deviceId', deviceId);
|
||||||
|
return url.toString().replace(/\/$/, '');
|
||||||
|
} catch (_error) {
|
||||||
|
return targetBaseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePlayerRowBaseUrl(player) {
|
function normalizePlayerRowBaseUrl(player) {
|
||||||
return normalizeBaseUrl(player && player.internal_base_url);
|
return normalizeBaseUrl(player && player.internal_base_url);
|
||||||
}
|
}
|
||||||
@@ -74,8 +90,8 @@ function createUploadSyncService(options) {
|
|||||||
throw new Error('createUploadSyncService requires the upload dependencies.');
|
throw new Error('createUploadSyncService requires the upload dependencies.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPlayerInternalBaseUrl() {
|
async function getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
|
||||||
const metadata = await getPlayerTaskMetadata();
|
const metadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl);
|
||||||
if (!metadata || metadata.playerActive === false) {
|
if (!metadata || metadata.playerActive === false) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -83,10 +99,43 @@ function createUploadSyncService(options) {
|
|||||||
return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
|
return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPlayerTaskMetadata() {
|
async function getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
|
||||||
if (playerTaskMetadata) {
|
const normalizedPreferredPlayerIdentifier = String(preferredPlayerIdentifier || '').trim();
|
||||||
|
const normalizedPreferredPlayerInternalBaseUrl = String(preferredPlayerInternalBaseUrl || '').trim().replace(/\/$/, '');
|
||||||
|
|
||||||
|
if (normalizedPreferredPlayerIdentifier && pool && typeof fetchPlayerRegistrations === 'function') {
|
||||||
|
try {
|
||||||
|
const players = await fetchPlayerRegistrations(pool);
|
||||||
|
const registeredPlayers = Array.isArray(players) ? players : [];
|
||||||
|
const exactPlayer = registeredPlayers.find(function (player) {
|
||||||
|
return String(player && player.identifier || '').trim() === normalizedPreferredPlayerIdentifier;
|
||||||
|
}) || null;
|
||||||
|
if (exactPlayer) {
|
||||||
|
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(exactPlayer) || normalizedPreferredPlayerInternalBaseUrl || null;
|
||||||
|
const resolvedPublicBaseUrl = normalizeBaseUrl(exactPlayer && exactPlayer.public_base_url);
|
||||||
|
const resolvedIdentifier = String(exactPlayer && exactPlayer.identifier || '').trim();
|
||||||
|
playerInternalBaseUrl = resolvedInternalBaseUrl || null;
|
||||||
|
playerTaskMetadata = {
|
||||||
|
playerIdentifier: resolvedIdentifier || normalizedPreferredPlayerIdentifier || null,
|
||||||
|
playerPublicBaseUrl: resolvedPublicBaseUrl || null,
|
||||||
|
playerInternalBaseUrl: resolvedInternalBaseUrl || null,
|
||||||
|
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null,
|
||||||
|
playerActive: true
|
||||||
|
};
|
||||||
return playerTaskMetadata;
|
return playerTaskMetadata;
|
||||||
}
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerTaskMetadata && playerTaskMetadata.playerActive !== false) {
|
||||||
|
return playerTaskMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerTaskMetadata && playerTaskMetadata.playerActive === false) {
|
||||||
|
playerTaskMetadata = null;
|
||||||
|
playerInternalBaseUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (playerTaskMetadataPromise) {
|
if (playerTaskMetadataPromise) {
|
||||||
return playerTaskMetadataPromise;
|
return playerTaskMetadataPromise;
|
||||||
@@ -404,6 +453,23 @@ function createUploadSyncService(options) {
|
|||||||
return Boolean(localUploadDir);
|
return Boolean(localUploadDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchLivePlayerRegistrations() {
|
||||||
|
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 isPlayerUnavailableError(error) {
|
function isPlayerUnavailableError(error) {
|
||||||
const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase();
|
const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase();
|
||||||
return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT';
|
return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT';
|
||||||
@@ -413,15 +479,33 @@ function createUploadSyncService(options) {
|
|||||||
return Boolean(response) && Number(response.status) === 503;
|
return Boolean(response) && Number(response.status) === 503;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPendingPlayerUploadSyncKey(operation) {
|
||||||
|
const uploadPath = normalizeUploadReference(operation && operation.uploadPath);
|
||||||
|
const metadata = operation && operation.metadata && typeof operation.metadata === 'object'
|
||||||
|
? operation.metadata
|
||||||
|
: null;
|
||||||
|
const playerIdentifier = String((operation && operation.playerIdentifier) || (metadata && metadata.playerIdentifier) || '').trim();
|
||||||
|
const playerInternalBaseUrl = normalizeBaseUrl((operation && operation.playerInternalBaseUrl) || (metadata && metadata.playerInternalBaseUrl) || '');
|
||||||
|
|
||||||
|
return [uploadPath, playerIdentifier, playerInternalBaseUrl].filter(Boolean).join('|');
|
||||||
|
}
|
||||||
|
|
||||||
function queuePlayerUploadSync(operation) {
|
function queuePlayerUploadSync(operation) {
|
||||||
if (!operation || !operation.uploadPath) {
|
if (!operation || !operation.uploadPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), {
|
const metadata = operation.metadata && typeof operation.metadata === 'object' ? operation.metadata : null;
|
||||||
|
const playerIdentifier = String((operation && operation.playerIdentifier) || (metadata && metadata.playerIdentifier) || '').trim();
|
||||||
|
const playerInternalBaseUrl = normalizeBaseUrl((operation && operation.playerInternalBaseUrl) || (metadata && metadata.playerInternalBaseUrl) || '');
|
||||||
|
|
||||||
|
pendingPlayerUploadSyncs.set(buildPendingPlayerUploadSyncKey(operation), {
|
||||||
type: operation.type === 'delete' ? 'delete' : 'put',
|
type: operation.type === 'delete' ? 'delete' : 'put',
|
||||||
uploadPath: normalizeUploadReference(operation.uploadPath),
|
uploadPath: normalizeUploadReference(operation.uploadPath),
|
||||||
uploadDir: operation.uploadDir || null
|
uploadDir: operation.uploadDir || null,
|
||||||
|
metadata: metadata,
|
||||||
|
playerIdentifier: playerIdentifier || null,
|
||||||
|
playerInternalBaseUrl: playerInternalBaseUrl || null
|
||||||
});
|
});
|
||||||
|
|
||||||
schedulePendingPlayerUploadSyncFlush();
|
schedulePendingPlayerUploadSyncFlush();
|
||||||
@@ -438,9 +522,12 @@ function createUploadSyncService(options) {
|
|||||||
console.warn('Unable to flush pending upload syncs:', error);
|
console.warn('Unable to flush pending upload syncs:', error);
|
||||||
});
|
});
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
if (pendingPlayerUploadSyncFlushTimer && typeof pendingPlayerUploadSyncFlushTimer.unref === 'function') {
|
||||||
|
pendingPlayerUploadSyncFlushTimer.unref();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
|
async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
|
||||||
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -450,6 +537,7 @@ function createUploadSyncService(options) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
|
||||||
const relativePath = getUploadRelativePath(uploadPath);
|
const relativePath = getUploadRelativePath(uploadPath);
|
||||||
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
|
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
|
||||||
if (!relativePath || !sourcePath) {
|
if (!relativePath || !sourcePath) {
|
||||||
@@ -471,7 +559,8 @@ function createUploadSyncService(options) {
|
|||||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||||
body: fileBuffer
|
body: fileBuffer
|
||||||
});
|
});
|
||||||
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
const mediaUploadUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
|
||||||
|
const response = await fetch(mediaUploadUrl, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
@@ -494,7 +583,7 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
|
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
|
||||||
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -504,6 +593,7 @@ function createUploadSyncService(options) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
|
||||||
const relativePath = getUploadRelativePath(uploadPath);
|
const relativePath = getUploadRelativePath(uploadPath);
|
||||||
if (!relativePath) {
|
if (!relativePath) {
|
||||||
return false;
|
return false;
|
||||||
@@ -513,7 +603,8 @@ function createUploadSyncService(options) {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||||
});
|
});
|
||||||
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
const mediaDeleteUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
|
||||||
|
const response = await fetch(mediaDeleteUrl, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
@@ -535,20 +626,26 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) {
|
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir, preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
|
||||||
if (!shouldMirrorUploads(localUploadDir)) {
|
if (!shouldMirrorUploads(localUploadDir)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl);
|
||||||
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
|
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
|
||||||
for (let i = 0; i < uniqueRefs.length; i += 1) {
|
for (let i = 0; i < uniqueRefs.length; i += 1) {
|
||||||
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl);
|
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
queuePlayerUploadSync({
|
queuePlayerUploadSync({
|
||||||
type: 'put',
|
type: 'put',
|
||||||
uploadPath: uniqueRefs[i],
|
uploadPath: uniqueRefs[i],
|
||||||
uploadDir: localUploadDir
|
uploadDir: localUploadDir,
|
||||||
|
playerIdentifier: preferredPlayerIdentifier,
|
||||||
|
playerInternalBaseUrl: preferredPlayerInternalBaseUrl,
|
||||||
|
metadata: preferredPlayerIdentifier || preferredPlayerInternalBaseUrl ? {
|
||||||
|
playerIdentifier: preferredPlayerIdentifier || null,
|
||||||
|
playerInternalBaseUrl: preferredPlayerInternalBaseUrl || null
|
||||||
|
} : null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -666,17 +763,39 @@ function createUploadSyncService(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const players = await fetchLivePlayerRegistrations();
|
||||||
|
if (!players.length) {
|
||||||
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
|
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
|
||||||
mode: 'playlist',
|
mode: 'playlist',
|
||||||
operation: operation
|
operation: operation
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Promise.all(players.map(function (player) {
|
||||||
|
const playerIdentifier = String(player && player.identifier || '').trim();
|
||||||
|
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, '');
|
||||||
|
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, '');
|
||||||
|
|
||||||
|
return queueMediaSyncTask('media-sync:' + operation.key + (playerIdentifier ? ':' + playerIdentifier : ''), 'Media sync', {
|
||||||
|
mode: 'playlist',
|
||||||
|
operation: operation,
|
||||||
|
playerIdentifier: playerIdentifier || null,
|
||||||
|
playerInternalBaseUrl: playerInternalBaseUrl || null,
|
||||||
|
playerPublicBaseUrl: playerPublicBaseUrl || null
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function flushPendingPlaylistUploadSyncs() {
|
async function flushPendingPlaylistUploadSyncs() {
|
||||||
if (pendingPlaylistUploadSyncFlushInFlight) {
|
if (pendingPlaylistUploadSyncFlushInFlight) {
|
||||||
return pendingPlaylistUploadSyncFlushInFlight;
|
return pendingPlaylistUploadSyncFlushInFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingPlaylistUploadSyncFlushTimer) {
|
||||||
|
clearTimeout(pendingPlaylistUploadSyncFlushTimer);
|
||||||
|
pendingPlaylistUploadSyncFlushTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!pendingPlaylistUploadSyncs.size) {
|
if (!pendingPlaylistUploadSyncs.size) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -715,49 +834,59 @@ function createUploadSyncService(options) {
|
|||||||
return pendingPlayerUploadSyncFlushInFlight;
|
return pendingPlayerUploadSyncFlushInFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingPlayerUploadSyncFlushTimer) {
|
||||||
|
clearTimeout(pendingPlayerUploadSyncFlushTimer);
|
||||||
|
pendingPlayerUploadSyncFlushTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!pendingPlayerUploadSyncs.size) {
|
if (!pendingPlayerUploadSyncs.size) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingPlayerUploadSyncFlushInFlight = (async function () {
|
pendingPlayerUploadSyncFlushInFlight = (async function () {
|
||||||
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values());
|
const pendingEntries = Array.from(pendingPlayerUploadSyncs.entries());
|
||||||
const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata
|
const firstOperation = pendingEntries.length && pendingEntries[0] ? pendingEntries[0][1] : null;
|
||||||
? pendingEntries[0].metadata
|
const summaryPlayerMetadata = firstOperation && firstOperation.metadata
|
||||||
: await getPlayerTaskMetadata();
|
? firstOperation.metadata
|
||||||
if (playerMetadata && playerMetadata.playerActive === false) {
|
: await getPlayerTaskMetadata(firstOperation && firstOperation.playerIdentifier, firstOperation && firstOperation.playerInternalBaseUrl);
|
||||||
pendingPlayerUploadSyncs.clear();
|
|
||||||
pendingPlayerUploadSyncRetryLogAt = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
|
|
||||||
? playerMetadata.playerInternalBaseUrl
|
|
||||||
: await getPlayerInternalBaseUrl();
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failureCount = 0;
|
let failureCount = 0;
|
||||||
for (let i = 0; i < pendingEntries.length; i += 1) {
|
for (let i = 0; i < pendingEntries.length; i += 1) {
|
||||||
const operation = pendingEntries[i];
|
const entry = pendingEntries[i];
|
||||||
|
const pendingKey = entry[0];
|
||||||
|
const operation = entry[1];
|
||||||
|
const playerMetadata = operation && operation.metadata
|
||||||
|
? operation.metadata
|
||||||
|
: await getPlayerTaskMetadata(operation && operation.playerIdentifier, operation && operation.playerInternalBaseUrl);
|
||||||
|
if (playerMetadata && playerMetadata.playerActive === false) {
|
||||||
|
pendingPlayerUploadSyncs.delete(pendingKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
|
||||||
|
? playerMetadata.playerInternalBaseUrl
|
||||||
|
: await getPlayerInternalBaseUrl(playerMetadata && playerMetadata.playerIdentifier, playerMetadata && playerMetadata.playerInternalBaseUrl);
|
||||||
let success = false;
|
let success = false;
|
||||||
if (operation.type === 'delete') {
|
if (operation.type === 'delete') {
|
||||||
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
|
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier);
|
||||||
} else {
|
} else {
|
||||||
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
|
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier);
|
||||||
}
|
}
|
||||||
if (success) {
|
if (success) {
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
pendingPlayerUploadSyncs.delete(operation.uploadPath);
|
pendingPlayerUploadSyncs.delete(pendingKey);
|
||||||
} else {
|
} else {
|
||||||
failureCount += 1;
|
failureCount += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (successCount) {
|
if (successCount) {
|
||||||
logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, playerMetadata);
|
logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, summaryPlayerMetadata);
|
||||||
}
|
}
|
||||||
if (failureCount) {
|
if (failureCount) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) {
|
if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) {
|
||||||
pendingPlayerUploadSyncRetryLogAt = now;
|
pendingPlayerUploadSyncRetryLogAt = now;
|
||||||
logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, playerMetadata);
|
logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, summaryPlayerMetadata);
|
||||||
}
|
}
|
||||||
} else if (!pendingPlayerUploadSyncs.size) {
|
} else if (!pendingPlayerUploadSyncs.size) {
|
||||||
pendingPlayerUploadSyncRetryLogAt = 0;
|
pendingPlayerUploadSyncRetryLogAt = 0;
|
||||||
@@ -782,6 +911,8 @@ function createUploadSyncService(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playerMetadata = await getPlayerTaskMetadata(taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
|
||||||
|
|
||||||
const data = await common.fetchAdminData(pool);
|
const data = await common.fetchAdminData(pool);
|
||||||
const uploadRefs = new Set();
|
const uploadRefs = new Set();
|
||||||
(data.slides || []).forEach(function (slide) {
|
(data.slides || []).forEach(function (slide) {
|
||||||
@@ -799,7 +930,8 @@ function createUploadSyncService(options) {
|
|||||||
queuePlayerUploadSync({
|
queuePlayerUploadSync({
|
||||||
type: 'put',
|
type: 'put',
|
||||||
uploadPath: uploadPath,
|
uploadPath: uploadPath,
|
||||||
uploadDir: uploadDir
|
uploadDir: uploadDir,
|
||||||
|
metadata: playerMetadata
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
fontLibraryOperations.forEach(function (operation) {
|
fontLibraryOperations.forEach(function (operation) {
|
||||||
@@ -810,7 +942,8 @@ function createUploadSyncService(options) {
|
|||||||
queuePlayerUploadSync({
|
queuePlayerUploadSync({
|
||||||
type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put',
|
type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put',
|
||||||
uploadPath: operation.uploadPath,
|
uploadPath: operation.uploadPath,
|
||||||
uploadDir: uploadDir
|
uploadDir: uploadDir,
|
||||||
|
metadata: playerMetadata
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await flushPendingPlayerUploadSyncs();
|
await flushPendingPlayerUploadSyncs();
|
||||||
@@ -821,14 +954,34 @@ function createUploadSyncService(options) {
|
|||||||
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
||||||
|
|
||||||
if (operation.nextUploadRefs.length) {
|
if (operation.nextUploadRefs.length) {
|
||||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation.previousUploadRefs.length) {
|
if (operation.previousUploadRefs.length) {
|
||||||
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||||
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
const removedUploadRefs = operation.previousUploadRefs.filter(function (reference) {
|
||||||
return !nextUploadRefSet.has(reference);
|
return !nextUploadRefSet.has(reference);
|
||||||
}));
|
});
|
||||||
|
for (let i = 0; i < removedUploadRefs.length; i += 1) {
|
||||||
|
const removedUploadRef = removedUploadRefs[i];
|
||||||
|
const referenceCount = await countUploadReferences(pool, removedUploadRef);
|
||||||
|
if (referenceCount > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await removeUploadFileFromPlayer(removedUploadRef, operation.localUploadDir, taskPayload.playerInternalBaseUrl, taskPayload.playerIdentifier);
|
||||||
|
if (!deleted) {
|
||||||
|
queuePlayerUploadSync({
|
||||||
|
type: 'delete',
|
||||||
|
uploadPath: removedUploadRef,
|
||||||
|
uploadDir: operation.localUploadDir,
|
||||||
|
playerIdentifier: taskPayload.playerIdentifier,
|
||||||
|
playerInternalBaseUrl: taskPayload.playerInternalBaseUrl,
|
||||||
|
metadata: playerMetadata
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await removeUnusedUploadFiles(pool, operation.localUploadDir, removedUploadRefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation.refreshScreenSlugs.length) {
|
if (operation.refreshScreenSlugs.length) {
|
||||||
@@ -857,7 +1010,7 @@ function createUploadSyncService(options) {
|
|||||||
safePayload.operation = Object.assign({}, safePayload.operation);
|
safePayload.operation = Object.assign({}, safePayload.operation);
|
||||||
delete safePayload.operation.pool;
|
delete safePayload.operation.pool;
|
||||||
}
|
}
|
||||||
const playerMetadata = await getPlayerTaskMetadata();
|
const playerMetadata = await getPlayerTaskMetadata(safePayload.playerIdentifier, safePayload.playerInternalBaseUrl);
|
||||||
|
|
||||||
const definition = {
|
const definition = {
|
||||||
key: taskKey,
|
key: taskKey,
|
||||||
|
|||||||
+117
-64
@@ -1,5 +1,5 @@
|
|||||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||||
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
|
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
|
||||||
|
|
||||||
function isLocalLikeBaseUrl(value) {
|
function isLocalLikeBaseUrl(value) {
|
||||||
let host = '';
|
let host = '';
|
||||||
@@ -33,24 +33,10 @@ function isRecentPlayerRegistration(player, staleSeconds) {
|
|||||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
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) {
|
function createPlayerActionService(options) {
|
||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||||
|
const configuredBridgeInternalBaseUrl = String(options && options.bridgeInternalBaseUrl || '').trim().replace(/\/$/, '');
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
|
|
||||||
if (!common) {
|
if (!common) {
|
||||||
@@ -151,6 +137,79 @@ function createPlayerActionService(options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchScreenConnectionsFromBaseUrl(baseUrl, slug) {
|
||||||
|
const targetBaseUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
if (!targetBaseUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders = createRequestAuthHeaders({
|
||||||
|
method: 'GET',
|
||||||
|
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...authHeaders
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(function () { return ''; });
|
||||||
|
const error = new Error(errorText || `Unable to fetch connections for player ${slug}.`);
|
||||||
|
error.statusCode = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forwardPlayerCommandToDevice(deviceId, commandOrPayload) {
|
||||||
|
const targetDeviceId = String(deviceId || '').trim();
|
||||||
|
const targetBridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl);
|
||||||
|
if (!targetDeviceId) {
|
||||||
|
throw new Error('Device ID is required.');
|
||||||
|
}
|
||||||
|
if (!targetBridgeBaseUrl) {
|
||||||
|
throw new Error('Unable to resolve the player bridge base URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||||
|
? Object.assign({}, commandOrPayload)
|
||||||
|
: { command: commandOrPayload };
|
||||||
|
|
||||||
|
const authHeaders = createRequestAuthHeaders({
|
||||||
|
method: 'POST',
|
||||||
|
pathname: `/api/players/${encodeURIComponent(targetDeviceId)}/commands`,
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${targetBridgeBaseUrl}/api/players/${encodeURIComponent(targetDeviceId)}/commands`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
...authHeaders
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(function () { return ''; });
|
||||||
|
const error = new Error(errorText || `Unable to send command to player ${targetDeviceId}.`);
|
||||||
|
error.statusCode = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||||
return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId);
|
return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId);
|
||||||
@@ -190,69 +249,62 @@ function createPlayerActionService(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getScreenConnections(slug) {
|
async function getScreenConnections(slug) {
|
||||||
const authHeaders = createRequestAuthHeaders({
|
const bridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl);
|
||||||
method: 'GET',
|
const playerBaseUrl = await getPlayerInternalBaseUrl();
|
||||||
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
|
const targetBaseUrls = Array.from(new Set([bridgeBaseUrl, playerBaseUrl].map(normalizeBaseUrl).filter(Boolean)));
|
||||||
});
|
|
||||||
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) {
|
if (!targetBaseUrls.length) {
|
||||||
throw new Error('Unable to resolve the player internal base URL.');
|
throw new Error('Unable to resolve the player internal base URL.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(targetBaseUrls.map(async function (baseUrl) {
|
const results = await Promise.allSettled(targetBaseUrls.map(function (baseUrl) {
|
||||||
const response = await fetch(`${baseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
return fetchScreenConnectionsFromBaseUrl(baseUrl, slug);
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...authHeaders
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json().catch(function () {
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const successfulResults = results.filter(function (result) {
|
||||||
|
return result.status === 'fulfilled' && result.value;
|
||||||
|
}).map(function (result) {
|
||||||
|
return result.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!successfulResults.length) {
|
||||||
|
const rejection = results.find(function (result) {
|
||||||
|
return result.status === 'rejected';
|
||||||
|
});
|
||||||
|
throw rejection ? rejection.reason : new Error(`Unable to fetch connections for player ${slug}.`);
|
||||||
|
}
|
||||||
|
|
||||||
const mergedConnections = [];
|
const mergedConnections = [];
|
||||||
let screen = null;
|
const seenKeys = new Set();
|
||||||
let degraded = false;
|
|
||||||
results.forEach(function (result) {
|
successfulResults.forEach(function (result) {
|
||||||
if (!result) {
|
const connections = Array.isArray(result && result.connections) ? result.connections : [];
|
||||||
degraded = true;
|
connections.forEach(function (connection) {
|
||||||
|
const key = [
|
||||||
|
String(connection && connection.id || '').trim(),
|
||||||
|
String(connection && connection.clientId || '').trim(),
|
||||||
|
String(connection && connection.deviceId || '').trim(),
|
||||||
|
String(connection && connection.playerPublicBaseUrl || '').trim()
|
||||||
|
].join('|');
|
||||||
|
if (!key || seenKeys.has(key)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!screen && result.screen) {
|
seenKeys.add(key);
|
||||||
screen = result.screen;
|
mergedConnections.push(connection);
|
||||||
}
|
});
|
||||||
if (Array.isArray(result.connections)) {
|
|
||||||
mergedConnections.push.apply(mergedConnections, result.connections);
|
|
||||||
}
|
|
||||||
if (result.degraded) {
|
|
||||||
degraded = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
screen: screen,
|
screen: successfulResults.find(function (result) {
|
||||||
|
return Boolean(result && result.screen);
|
||||||
|
}) ? successfulResults.find(function (result) {
|
||||||
|
return Boolean(result && result.screen);
|
||||||
|
}).screen : null,
|
||||||
screenSlug: slug,
|
screenSlug: slug,
|
||||||
count: mergedConnections.length,
|
count: mergedConnections.length,
|
||||||
connections: mergedConnections,
|
connections: mergedConnections,
|
||||||
degraded: degraded
|
degraded: results.some(function (result) {
|
||||||
|
return result.status === 'fulfilled' && Boolean(result.value && result.value.degraded);
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +354,7 @@ function createPlayerActionService(options) {
|
|||||||
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
|
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
|
||||||
getScreenConnections: getScreenConnections,
|
getScreenConnections: getScreenConnections,
|
||||||
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
|
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
|
||||||
|
forwardPlayerCommandToDevice: forwardPlayerCommandToDevice,
|
||||||
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
||||||
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
||||||
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
|
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ module.exports = function registerMiddleware(app, deps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use(function (req, res, next) {
|
app.use(function (req, res, next) {
|
||||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media') {
|
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font') {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||||
var LIST_PAGE_SIZE = 25;
|
var LIST_PAGE_SIZE = 25;
|
||||||
var latestDashboardState = null;
|
var latestDashboardState = null;
|
||||||
var ALL_SCREENS_SLUG = '__all__';
|
|
||||||
var ALL_SCREENS_LABEL = 'All screens';
|
|
||||||
|
|
||||||
function getClientSearchInput() {
|
function getClientSearchInput() {
|
||||||
var table = document.getElementById('dashboard-clients-table');
|
var table = document.getElementById('dashboard-clients-table');
|
||||||
@@ -20,6 +18,152 @@
|
|||||||
return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]');
|
return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getScreenCommandSelect() {
|
||||||
|
return document.querySelector('[data-screen-command-select]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScreenCommandForms() {
|
||||||
|
if (!document.querySelectorAll) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedScreenClients(state) {
|
||||||
|
var select = getScreenCommandSelect();
|
||||||
|
var selectedSlug = select ? String(select.value || '').trim() : '';
|
||||||
|
var clients = Array.isArray(state && state.clients) ? state.clients : [];
|
||||||
|
|
||||||
|
if (!selectedSlug || selectedSlug === '__all__') {
|
||||||
|
return clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clients.filter(function (client) {
|
||||||
|
return String(client && client.screen_slug || '').trim() === selectedSlug;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedScreenLabel() {
|
||||||
|
var select = getScreenCommandSelect();
|
||||||
|
if (!select) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
|
||||||
|
return selectedOption ? String(selectedOption.getAttribute('data-screen-name') || selectedOption.textContent || '').trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateToggleButton(button, form, state) {
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var action = String(form && form.getAttribute('data-screen-command-action') || '').trim();
|
||||||
|
var selectedLabel = getSelectedScreenLabel() || 'selected screen group';
|
||||||
|
var isAllScreens = String(getScreenCommandSelect() && getScreenCommandSelect().value || '').trim() === '__all__';
|
||||||
|
var clients = getSelectedScreenClients(state);
|
||||||
|
var hasClients = clients.length > 0;
|
||||||
|
var allPaused = hasClients && clients.every(function (client) {
|
||||||
|
return Boolean(client && client.paused);
|
||||||
|
});
|
||||||
|
var allBlackout = hasClients && clients.every(function (client) {
|
||||||
|
return Boolean(client && client.blackout);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (action === 'pause') {
|
||||||
|
var pauseLabel = allPaused ? 'Resume ' + (isAllScreens ? 'all clients' : 'screen') : 'Pause ' + (isAllScreens ? 'all clients' : 'screen');
|
||||||
|
var pauseConfirm = allPaused ? 'Resume ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?' : 'Pause ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?';
|
||||||
|
var pauseIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill';
|
||||||
|
button.innerHTML = '<i class="bi ' + pauseIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(pauseLabel);
|
||||||
|
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
|
||||||
|
button.setAttribute('aria-label', pauseLabel);
|
||||||
|
button.setAttribute('title', pauseLabel);
|
||||||
|
if (form) {
|
||||||
|
var pauseInput = form.querySelector('input[name="paused"]');
|
||||||
|
if (pauseInput) {
|
||||||
|
pauseInput.value = allPaused ? 'false' : 'true';
|
||||||
|
}
|
||||||
|
form.setAttribute('data-confirm-message', pauseConfirm);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'blackout') {
|
||||||
|
var blackoutLabel = allBlackout ? 'Restore ' + (isAllScreens ? 'all clients' : 'screen') : 'Blackout ' + (isAllScreens ? 'all clients' : 'screen');
|
||||||
|
var blackoutConfirm = allBlackout ? 'Restore ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?' : 'Blackout ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?';
|
||||||
|
var blackoutIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash';
|
||||||
|
button.innerHTML = '<i class="bi ' + blackoutIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(blackoutLabel);
|
||||||
|
setButtonVariant(button, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
|
||||||
|
button.setAttribute('aria-label', blackoutLabel);
|
||||||
|
button.setAttribute('title', blackoutLabel);
|
||||||
|
if (form) {
|
||||||
|
var blackoutInput = form.querySelector('input[name="blackout"]');
|
||||||
|
if (blackoutInput) {
|
||||||
|
blackoutInput.value = allBlackout ? 'false' : 'true';
|
||||||
|
}
|
||||||
|
form.setAttribute('data-confirm-message', blackoutConfirm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScreenCommandControls() {
|
||||||
|
var select = getScreenCommandSelect();
|
||||||
|
if (!select) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedSlug = String(select.value || '').trim();
|
||||||
|
var hasSelectedGroup = Boolean(selectedSlug);
|
||||||
|
var actionTarget = '/clients/' + encodeURIComponent(selectedSlug || '__all__') + '/commands';
|
||||||
|
var selectedName = getSelectedScreenLabel();
|
||||||
|
var selectedClients = getSelectedScreenClients(latestDashboardState);
|
||||||
|
var allPaused = selectedClients.length > 0 && selectedClients.every(function (client) {
|
||||||
|
return Boolean(client && client.paused);
|
||||||
|
});
|
||||||
|
var allBlackout = selectedClients.length > 0 && selectedClients.every(function (client) {
|
||||||
|
return Boolean(client && client.blackout);
|
||||||
|
});
|
||||||
|
|
||||||
|
getScreenCommandForms().forEach(function (form) {
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setAttribute('action', actionTarget);
|
||||||
|
|
||||||
|
var action = String(form.getAttribute('data-screen-command-action') || '').trim();
|
||||||
|
var button = form.querySelector('button[type="submit"]');
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
button.disabled = !hasSelectedGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'pause' || action === 'blackout') {
|
||||||
|
updateToggleButton(button, form, latestDashboardState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
button.setAttribute('aria-label', selectedName ? selectedName : 'Selected screen group');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initScreenCommandControls() {
|
||||||
|
var select = getScreenCommandSelect();
|
||||||
|
if (!select || (select.dataset && select.dataset.bound === 'true')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (select.dataset) {
|
||||||
|
select.dataset.bound = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
select.addEventListener('change', updateScreenCommandControls);
|
||||||
|
updateScreenCommandControls();
|
||||||
|
}
|
||||||
|
|
||||||
function getClientListQueryState() {
|
function getClientListQueryState() {
|
||||||
var searchParams = new URLSearchParams(String(window.location && window.location.search || ''));
|
var searchParams = new URLSearchParams(String(window.location && window.location.search || ''));
|
||||||
var searchInput = getClientSearchInput();
|
var searchInput = getClientSearchInput();
|
||||||
@@ -101,8 +245,6 @@
|
|||||||
client && client.slug,
|
client && client.slug,
|
||||||
client && client.screen_slug,
|
client && client.screen_slug,
|
||||||
client && client.screen_name,
|
client && client.screen_name,
|
||||||
client && client.ipAddress,
|
|
||||||
client && client.clientIp,
|
|
||||||
client && client.status,
|
client && client.status,
|
||||||
client && client.currentSlideTitle
|
client && client.currentSlideTitle
|
||||||
];
|
];
|
||||||
@@ -120,7 +262,6 @@
|
|||||||
client: function (client) { return String(client && (getClientDisplayName(client) || client.client_name || client.name || client.clientId) || '').trim(); },
|
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(); },
|
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
|
||||||
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
|
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
|
||||||
ip: function (client) { return String(client && client.clientIp || '').trim(); },
|
|
||||||
viewport: function (client) {
|
viewport: function (client) {
|
||||||
var viewport = client && client.viewport;
|
var viewport = client && client.viewport;
|
||||||
if (!viewport || !viewport.width || !viewport.height) {
|
if (!viewport || !viewport.width || !viewport.height) {
|
||||||
@@ -139,12 +280,6 @@
|
|||||||
? [normalizedSortKey]
|
? [normalizedSortKey]
|
||||||
: ['client'];
|
: ['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) {
|
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
|
||||||
for (var index = 0; index < sortKeys.length; index += 1) {
|
for (var index = 0; index < sortKeys.length; index += 1) {
|
||||||
var sortKeyName = sortKeys[index];
|
var sortKeyName = sortKeys[index];
|
||||||
@@ -170,77 +305,6 @@
|
|||||||
return clients.slice((query.page - 1) * LIST_PAGE_SIZE, ((query.page - 1) * LIST_PAGE_SIZE) + LIST_PAGE_SIZE);
|
return clients.slice((query.page - 1) * LIST_PAGE_SIZE, ((query.page - 1) * LIST_PAGE_SIZE) + LIST_PAGE_SIZE);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClientMoveModalElements() {
|
|
||||||
return {
|
|
||||||
modal: document.getElementById('client-move-screen-modal'),
|
|
||||||
form: document.getElementById('client-move-screen-form'),
|
|
||||||
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]'),
|
|
||||||
playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClientMoveScreens() {
|
|
||||||
if (latestDashboardState && Array.isArray(latestDashboardState.screens) && latestDashboardState.screens.length) {
|
|
||||||
return latestDashboardState.screens.slice().sort(compareScreensByConnectedClients);
|
|
||||||
}
|
|
||||||
|
|
||||||
var select = document.getElementById('client-move-screen-target');
|
|
||||||
if (!select || !select.options) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.prototype.slice.call(select.options).map(function (option) {
|
|
||||||
return {
|
|
||||||
slug: String(option.value || '').trim(),
|
|
||||||
name: String(option.textContent || option.value || '').trim()
|
|
||||||
};
|
|
||||||
}).filter(function (screen) {
|
|
||||||
return Boolean(screen && screen.slug);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateClientMoveModalFromRow(row) {
|
|
||||||
var elements = getClientMoveModalElements();
|
|
||||||
if (!elements.form || !elements.targetSelect || !row) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 || []);
|
|
||||||
|
|
||||||
options.forEach(function (option) {
|
|
||||||
option.disabled = false;
|
|
||||||
if (String(option.value || '').trim() === currentScreenSlug) {
|
|
||||||
option.disabled = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#';
|
|
||||||
if (elements.connectionInput) {
|
|
||||||
elements.connectionInput.value = connectionId;
|
|
||||||
}
|
|
||||||
if (elements.deviceInput) {
|
|
||||||
elements.deviceInput.value = deviceId;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function renderClientActionCell(client) {
|
function renderClientActionCell(client) {
|
||||||
var paused = Boolean(client.paused);
|
var paused = Boolean(client.paused);
|
||||||
var pauseButtonClass = 'btn btn-sm btn-info';
|
var pauseButtonClass = 'btn btn-sm btn-info';
|
||||||
@@ -257,7 +321,7 @@
|
|||||||
return [
|
return [
|
||||||
'<div class="actions justify-content-end">',
|
'<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) + '" /><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>',
|
'<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>',
|
'<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" 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) + '" /><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="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="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="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>',
|
||||||
@@ -266,6 +330,60 @@
|
|||||||
].join('');
|
].join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getClientMoveModalElements() {
|
||||||
|
return {
|
||||||
|
modal: document.getElementById('client-move-screen-modal'),
|
||||||
|
form: document.getElementById('client-move-screen-form'),
|
||||||
|
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]'),
|
||||||
|
playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClientMoveModalFromRow(row) {
|
||||||
|
var elements = getClientMoveModalElements();
|
||||||
|
if (!elements.form || !elements.targetSelect || !row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
|
||||||
|
var connectionId = String(row.getAttribute('data-client-id') || '').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 || []);
|
||||||
|
|
||||||
|
options.forEach(function (option) {
|
||||||
|
if (!option) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
option.disabled = String(option.value || '').trim() === currentScreenSlug;
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#';
|
||||||
|
if (elements.connectionInput) {
|
||||||
|
elements.connectionInput.value = connectionId;
|
||||||
|
}
|
||||||
|
if (elements.deviceInput) {
|
||||||
|
elements.deviceInput.value = deviceId;
|
||||||
|
}
|
||||||
|
if (elements.clientNameInput) {
|
||||||
|
elements.clientNameInput.value = clientName;
|
||||||
|
}
|
||||||
|
if (elements.playerBaseUrlInput) {
|
||||||
|
elements.playerBaseUrlInput.value = playerBaseUrl;
|
||||||
|
}
|
||||||
|
if (elements.targetSelect) {
|
||||||
|
elements.targetSelect.value = '';
|
||||||
|
}
|
||||||
|
if (elements.form.querySelector('button[type="submit"]')) {
|
||||||
|
elements.form.querySelector('button[type="submit"]').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateClientActionCell(cell, client) {
|
function updateClientActionCell(cell, client) {
|
||||||
if (!cell) {
|
if (!cell) {
|
||||||
return;
|
return;
|
||||||
@@ -412,13 +530,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasActionsColumn) {
|
if (!hasActionsColumn) {
|
||||||
if (row.cells.length > 6) {
|
if (row.cells.length > 5) {
|
||||||
row.deleteCell(row.cells.length - 1);
|
row.deleteCell(row.cells.length - 1);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var actionCell = row.cells.length > 6 ? row.cells[6] : null;
|
var actionCell = row.cells.length > 5 ? row.cells[5] : null;
|
||||||
if (!actionCell) {
|
if (!actionCell) {
|
||||||
actionCell = row.insertCell(-1);
|
actionCell = row.insertCell(-1);
|
||||||
actionCell.setAttribute('data-label', 'Actions');
|
actionCell.setAttribute('data-label', 'Actions');
|
||||||
@@ -430,8 +548,6 @@
|
|||||||
|
|
||||||
function renderClientRow(client, hasActionsColumn) {
|
function renderClientRow(client, hasActionsColumn) {
|
||||||
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 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 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 clientNameValue = getClientDisplayName(client);
|
||||||
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
|
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
|
||||||
@@ -440,11 +556,10 @@
|
|||||||
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
|
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
|
||||||
|
|
||||||
return [
|
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 || '') + '" data-client-player-base-url="' + escapeHtml(client.player_url || '') + '">',
|
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.id || '') + '" data-client-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="Client"><div>' + clientName + '</div></td>',
|
||||||
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
|
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
|
||||||
'<td data-label="Current Slide">' + currentSlide + '</td>',
|
'<td data-label="Current Slide">' + currentSlide + '</td>',
|
||||||
'<td data-label="IP">' + clientIp + '</td>',
|
|
||||||
'<td data-label="Viewport">' + viewport + '</td>',
|
'<td data-label="Viewport">' + viewport + '</td>',
|
||||||
'<td data-label="Connected/Updated">' + connectedAt + '</td>',
|
'<td data-label="Connected/Updated">' + connectedAt + '</td>',
|
||||||
actionCell,
|
actionCell,
|
||||||
@@ -453,13 +568,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateClientRowCells(row, client, hasActionsColumn) {
|
function updateClientRowCells(row, client, hasActionsColumn) {
|
||||||
if (!row || !row.cells || row.cells.length < 6) {
|
if (!row || !row.cells || row.cells.length < 5) {
|
||||||
return;
|
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 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 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 clientNameValue = getClientDisplayName(client);
|
||||||
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
|
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
|
||||||
@@ -467,7 +580,8 @@
|
|||||||
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</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-key', escapeHtml(getClientRowKey(client)));
|
||||||
row.setAttribute('data-client-id', escapeHtml(client.clientId || ''));
|
row.setAttribute('data-client-id', escapeHtml(client.id || ''));
|
||||||
|
row.setAttribute('data-client-client-id', escapeHtml(client.clientId || ''));
|
||||||
row.setAttribute('data-client-device-id', escapeHtml(client.deviceId || ''));
|
row.setAttribute('data-client-device-id', escapeHtml(client.deviceId || ''));
|
||||||
row.setAttribute('data-client-screen-slug', escapeHtml(client.screen_slug || ''));
|
row.setAttribute('data-client-screen-slug', escapeHtml(client.screen_slug || ''));
|
||||||
row.setAttribute('data-client-player-base-url', escapeHtml(client.player_url || ''));
|
row.setAttribute('data-client-player-base-url', escapeHtml(client.player_url || ''));
|
||||||
@@ -475,9 +589,8 @@
|
|||||||
setCellHtml(row.cells[0], '<div>' + clientName + '</div>');
|
setCellHtml(row.cells[0], '<div>' + clientName + '</div>');
|
||||||
setCellHtml(row.cells[1], '<div>' + screenName + '</div>');
|
setCellHtml(row.cells[1], '<div>' + screenName + '</div>');
|
||||||
setCellHtml(row.cells[2], currentSlide);
|
setCellHtml(row.cells[2], currentSlide);
|
||||||
setCellHtml(row.cells[3], clientIp);
|
setCellHtml(row.cells[3], viewport);
|
||||||
setCellHtml(row.cells[4], viewport);
|
setCellHtml(row.cells[4], connectedAt);
|
||||||
setCellHtml(row.cells[5], connectedAt);
|
|
||||||
|
|
||||||
syncClientActionCell(row, client, hasActionsColumn);
|
syncClientActionCell(row, client, hasActionsColumn);
|
||||||
}
|
}
|
||||||
@@ -627,6 +740,112 @@
|
|||||||
updateClientTable(latestDashboardState, true);
|
updateClientTable(latestDashboardState, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initClientMoveHandler() {
|
||||||
|
var elements = getClientMoveModalElements();
|
||||||
|
if (!elements.modal || !elements.form || !elements.targetSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof elements.modal.addEventListener === 'function') {
|
||||||
|
elements.modal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
var trigger = event && event.relatedTarget ? event.relatedTarget : null;
|
||||||
|
var row = trigger && trigger.closest ? trigger.closest('tr[data-client-key]') : null;
|
||||||
|
if (row) {
|
||||||
|
updateClientMoveModalFromRow(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
|
||||||
|
if (!moveButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event && typeof event.preventDefault === 'function') {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateClientMoveModalFromRow(row);
|
||||||
|
|
||||||
|
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
|
||||||
|
window.pulseModal.show(elements.modal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.bootstrap && window.bootstrap.Modal) {
|
||||||
|
window.bootstrap.Modal.getOrCreateInstance(elements.modal).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.form.addEventListener('submit', function (event) {
|
||||||
|
if (elements.form.dataset && elements.form.dataset.busy === 'true') {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetScreenSlug = String(elements.targetSelect.value || '').trim();
|
||||||
|
if (!targetScreenSlug) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.alert('Choose a target screen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
elements.form.dataset.busy = 'true';
|
||||||
|
|
||||||
|
var formData = new FormData(elements.form);
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
formData.forEach(function (value, key) {
|
||||||
|
body.append(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch(elements.form.action, {
|
||||||
|
method: (elements.form.method || 'POST').toUpperCase(),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': 'application/json, text/plain, */*'
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.text().then(function (text) {
|
||||||
|
var error = new Error(text || 'Unable to move client.');
|
||||||
|
try {
|
||||||
|
var payload = JSON.parse(text);
|
||||||
|
if (payload && (payload.error || payload.message)) {
|
||||||
|
error = new Error(String(payload.error || payload.message));
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// fall back to the raw text body
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
|
||||||
|
window.pulseModal.hide(elements.modal);
|
||||||
|
} else if (window.bootstrap && window.bootstrap.Modal) {
|
||||||
|
window.bootstrap.Modal.getOrCreateInstance(elements.modal).hide();
|
||||||
|
}
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}).catch(function (error) {
|
||||||
|
window.alert(error && error.message ? error.message : 'Unable to move client.');
|
||||||
|
}).finally(function () {
|
||||||
|
delete elements.form.dataset.busy;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateKioskLauncherModal(state) {
|
function updateKioskLauncherModal(state) {
|
||||||
var modal = document.getElementById('dashboard-kiosk-launcher-modal');
|
var modal = document.getElementById('dashboard-kiosk-launcher-modal');
|
||||||
if (!modal) {
|
if (!modal) {
|
||||||
@@ -696,163 +915,6 @@
|
|||||||
}
|
}
|
||||||
grid.innerHTML = screens.map(renderScreenTile).join('');
|
grid.innerHTML = screens.map(renderScreenTile).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScreenCommandControls(state) {
|
|
||||||
var select = document.getElementById('screen-command-select');
|
|
||||||
if (!select) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var forms = Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]'));
|
|
||||||
var pill = document.querySelector('[data-screen-command-pill]');
|
|
||||||
var nameNode = document.querySelector('[data-screen-command-name]');
|
|
||||||
var metaNode = document.querySelector('[data-screen-command-meta]');
|
|
||||||
var screens = Array.isArray(state && state.screens) ? state.screens : [];
|
|
||||||
var screenBySlug = {};
|
|
||||||
|
|
||||||
screens.forEach(function (screen) {
|
|
||||||
if (screen && screen.slug) {
|
|
||||||
screenBySlug[String(screen.slug)] = screen;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!screens.length) {
|
|
||||||
select.value = '';
|
|
||||||
select.disabled = true;
|
|
||||||
forms.forEach(function (form) {
|
|
||||||
form.querySelectorAll('button, input').forEach(function (control) {
|
|
||||||
control.disabled = true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (pill) {
|
|
||||||
pill.classList.remove('is-live');
|
|
||||||
pill.classList.add('is-idle');
|
|
||||||
pill.textContent = 'No screens';
|
|
||||||
}
|
|
||||||
if (nameNode) {
|
|
||||||
nameNode.textContent = 'No target available';
|
|
||||||
}
|
|
||||||
if (metaNode) {
|
|
||||||
metaNode.textContent = 'Create a screen before using screen-level commands.';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
select.disabled = false;
|
|
||||||
|
|
||||||
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;
|
|
||||||
var hasClients = connectionCount > 0;
|
|
||||||
var allPaused = hasClients && selectedClients.every(function (client) {
|
|
||||||
return Boolean(client && client.paused);
|
|
||||||
});
|
|
||||||
var allBlackout = hasClients && selectedClients.every(function (client) {
|
|
||||||
return Boolean(client && client.blackout);
|
|
||||||
});
|
|
||||||
var connectionLabel = hasClients ? connectionCount + ' connected client' + (connectionCount === 1 ? '' : 's') : 'No clients connected';
|
|
||||||
|
|
||||||
if (pill) {
|
|
||||||
pill.classList.toggle('is-live', hasClients);
|
|
||||||
pill.classList.toggle('is-idle', !hasClients);
|
|
||||||
pill.textContent = connectionLabel;
|
|
||||||
}
|
|
||||||
if (nameNode) {
|
|
||||||
nameNode.textContent = selectedScreen
|
|
||||||
? String(selectedScreen.name || 'Selected screen')
|
|
||||||
: 'Select a target screen group';
|
|
||||||
}
|
|
||||||
if (metaNode) {
|
|
||||||
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"]');
|
|
||||||
var button = form.querySelector('button[type="submit"]');
|
|
||||||
if (commandInput) {
|
|
||||||
if (command === 'pause') {
|
|
||||||
commandInput.value = allPaused ? 'pause' : 'pause';
|
|
||||||
var pauseStateInput = form.querySelector('input[name="paused"]');
|
|
||||||
if (pauseStateInput) {
|
|
||||||
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 ? (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
|
|
||||||
? (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"]');
|
|
||||||
if (blackoutStateInput) {
|
|
||||||
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 ? (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
|
|
||||||
? (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 = commandTargetSlug ? '/clients/' + encodeURIComponent(commandTargetSlug) + '/commands' : '#';
|
|
||||||
if (command === 'reload') {
|
|
||||||
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>' + (isAllSelected ? 'Reload all screens' : 'Reload screen');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) {
|
|
||||||
control.disabled = !commandTargetSlug;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function readScreenCommandStateFromDom() {
|
|
||||||
var select = document.getElementById('screen-command-select');
|
|
||||||
if (!select) {
|
|
||||||
return { screens: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
screens: Array.prototype.slice.call(select.options || []).map(function (option) {
|
|
||||||
return {
|
|
||||||
slug: String(option.value || '').trim(),
|
|
||||||
name: String(option.getAttribute('data-screen-name') || option.textContent || option.value || '').trim(),
|
|
||||||
player_connection_count: Number(option.getAttribute('data-player-connection-count') || 0),
|
|
||||||
playlist_name: String(option.getAttribute('data-playlist-name') || '').trim()
|
|
||||||
};
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDashboardQuickActions(state) {
|
function updateDashboardQuickActions(state) {
|
||||||
var pauseButton = document.getElementById('dashboard-pause-all-button');
|
var pauseButton = document.getElementById('dashboard-pause-all-button');
|
||||||
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
|
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
|
||||||
@@ -889,7 +951,7 @@
|
|||||||
var blackoutButtonIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash';
|
var blackoutButtonIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash';
|
||||||
|
|
||||||
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(label);
|
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(label);
|
||||||
setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], 'btn-secondary');
|
setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
|
||||||
if (blackoutInput) {
|
if (blackoutInput) {
|
||||||
blackoutInput.value = allBlackout ? 'false' : 'true';
|
blackoutInput.value = allBlackout ? 'false' : 'true';
|
||||||
}
|
}
|
||||||
@@ -907,10 +969,10 @@
|
|||||||
window.webLatestDashboardState = latestDashboardState;
|
window.webLatestDashboardState = latestDashboardState;
|
||||||
updateStats(state);
|
updateStats(state);
|
||||||
updateScreenGrid(state);
|
updateScreenGrid(state);
|
||||||
updateScreenCommandControls(state);
|
|
||||||
updateClientTable(state);
|
updateClientTable(state);
|
||||||
updateKioskLauncherModal(state);
|
updateKioskLauncherModal(state);
|
||||||
updateDashboardQuickActions(state);
|
updateDashboardQuickActions(state);
|
||||||
|
updateScreenCommandControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendClientRename(screenSlug, connectionId, clientId, deviceId, clientName) {
|
function sendClientRename(screenSlug, connectionId, clientId, deviceId, clientName) {
|
||||||
@@ -967,8 +1029,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
|
var connectionId = String(row.getAttribute('data-client-id') || '').trim();
|
||||||
var clientId = String(row.getAttribute('data-client-id') || '').trim();
|
var clientId = String(row.getAttribute('data-client-client-id') || '').trim();
|
||||||
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
|
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
|
||||||
var screenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
|
var screenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
|
||||||
var currentName = String(cell.textContent || '').trim();
|
var currentName = String(cell.textContent || '').trim();
|
||||||
@@ -1003,18 +1065,41 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof elements.modal.addEventListener === 'function') {
|
||||||
|
elements.modal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
var trigger = event && event.relatedTarget ? event.relatedTarget : null;
|
||||||
|
var row = trigger && trigger.closest ? trigger.closest('tr[data-client-key]') : null;
|
||||||
|
if (row) {
|
||||||
|
updateClientMoveModalFromRow(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('click', function (event) {
|
document.addEventListener('click', function (event) {
|
||||||
var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
|
var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
|
||||||
if (!moveButton) {
|
if (!moveButton) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event && typeof event.preventDefault === 'function') {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
|
var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateClientMoveModalFromRow(row);
|
updateClientMoveModalFromRow(row);
|
||||||
|
|
||||||
|
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
|
||||||
|
window.pulseModal.show(elements.modal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.bootstrap && window.bootstrap.Modal) {
|
||||||
|
window.bootstrap.Modal.getOrCreateInstance(elements.modal).show();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.form.addEventListener('submit', function (event) {
|
elements.form.addEventListener('submit', function (event) {
|
||||||
@@ -1131,11 +1216,5 @@
|
|||||||
initClientRenameHandler();
|
initClientRenameHandler();
|
||||||
initClientMoveHandler();
|
initClientMoveHandler();
|
||||||
initKioskLauncherModal();
|
initKioskLauncherModal();
|
||||||
var screenCommandSelect = document.getElementById('screen-command-select');
|
initScreenCommandControls();
|
||||||
if (screenCommandSelect) {
|
|
||||||
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
|
|
||||||
screenCommandSelect.addEventListener('change', function () {
|
|
||||||
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -37,7 +37,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getClientRowKey(client) {
|
function getClientRowKey(client) {
|
||||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
var clientName = String(client && client.client_name ? client.client_name : '').trim();
|
||||||
|
if (clientName) {
|
||||||
|
return clientName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(client && client.screen_slug ? client.screen_slug : client && client.deviceId ? client.deviceId : client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClientDisplayName(client) {
|
function getClientDisplayName(client) {
|
||||||
|
|||||||
@@ -12,40 +12,122 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
const withClientNameReservation = deps.withClientNameReservation;
|
const withClientNameReservation = deps.withClientNameReservation;
|
||||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
const ALL_SCREENS_SLUG = '__all__';
|
|
||||||
|
|
||||||
async function resolveScreenPlayerBaseUrls(screenSlug, connectionId) {
|
function normalizeExplicitPlayerBaseUrl(value) {
|
||||||
if (typeof getScreenConnections !== 'function' || !screenSlug) {
|
return String(value || '').trim().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConnectionBaseUrl(connection) {
|
||||||
|
return normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value) {
|
||||||
|
return String(value || '').trim().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlayerRegistrations() {
|
||||||
|
if (typeof common.fetchPlayerRegistrations !== 'function') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getScreenConnections(screenSlug);
|
const registrations = await common.fetchPlayerRegistrations(pool);
|
||||||
const liveConnections = Array.isArray(response && response.connections) ? response.connections : [];
|
return Array.isArray(registrations) ? registrations : [];
|
||||||
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) {
|
} catch (_error) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeExplicitPlayerBaseUrl(value) {
|
async function fetchScreenConnections(screenSlug) {
|
||||||
return String(value || '').trim().replace(/\/$/, '');
|
if (typeof getScreenConnections !== 'function') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getScreenConnections(screenSlug);
|
||||||
|
return Array.isArray(response && response.connections) ? response.connections : [];
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllScreenTargets() {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT slug
|
||||||
|
FROM d_screens
|
||||||
|
WHERE slug IS NOT NULL
|
||||||
|
ORDER BY slug ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
return (rows || [])
|
||||||
|
.map(function (row) {
|
||||||
|
return {
|
||||||
|
slug: String(row && row.slug ? row.slug : '').trim()
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(function (row) {
|
||||||
|
return Boolean(row.slug);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePlayerBaseUrlsForConnections(connections) {
|
||||||
|
const connectionList = Array.isArray(connections) ? connections : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const registrations = await fetchPlayerRegistrations();
|
||||||
|
|
||||||
|
return connectionList.map(function (connection) {
|
||||||
|
const selectedPublicBaseUrl = normalizeConnectionBaseUrl(connection);
|
||||||
|
if (!selectedPublicBaseUrl || seen.has(selectedPublicBaseUrl)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
seen.add(selectedPublicBaseUrl);
|
||||||
|
const matchedPlayer = registrations.find(function (player) {
|
||||||
|
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === selectedPublicBaseUrl;
|
||||||
|
}) || null;
|
||||||
|
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || selectedPublicBaseUrl;
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePlayerBaseUrlForExplicitPublicBaseUrl(playerBaseUrl) {
|
||||||
|
const normalizedPublicBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
|
||||||
|
if (!normalizedPublicBaseUrl) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrations = await fetchPlayerRegistrations();
|
||||||
|
const matchedPlayer = registrations.find(function (player) {
|
||||||
|
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === normalizedPublicBaseUrl;
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || normalizedPublicBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forwardPlayerCommandForConnections(screenSlug, connections, commandPayload, connectionId, deviceId) {
|
||||||
|
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections);
|
||||||
|
if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
||||||
|
const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) {
|
||||||
|
return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
sent: results.filter(function (result) {
|
||||||
|
return result.status === 'fulfilled';
|
||||||
|
}).length,
|
||||||
|
results: results
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forwardPlayerCommandForPlayerBaseUrl(screenSlug, playerBaseUrl, commandPayload, connectionId, deviceId) {
|
||||||
|
const targetBaseUrl = await resolvePlayerBaseUrlForExplicitPublicBaseUrl(playerBaseUrl);
|
||||||
|
if (targetBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
||||||
|
return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecentPlayerRegistration(player, staleSeconds) {
|
function isRecentPlayerRegistration(player, staleSeconds) {
|
||||||
@@ -56,32 +138,13 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
|
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) {
|
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const slug = String(req.params.slug || '').trim();
|
const slug = String(req.params.slug || '').trim();
|
||||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
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 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 playerBaseUrl = String((req.body && req.body.playerBaseUrl) || req.query.playerBaseUrl || '').trim();
|
||||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||||
? req.body.blackout
|
? req.body.blackout
|
||||||
: req.query.blackout;
|
: req.query.blackout;
|
||||||
@@ -93,14 +156,25 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
return res.status(400).json({ error: 'Command is required' });
|
return res.status(400).json({ error: 'Command is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slug === ALL_SCREENS_SLUG) {
|
if (slug === '__all__') {
|
||||||
if (command === 'setclientname' || command === 'moveclient') {
|
if (command !== 'reload' && command !== 'pause' && command !== 'blackout') {
|
||||||
return res.status(400).json({ error: 'This command requires a specific screen.' });
|
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');
|
const targets = await fetchAllScreenTargets();
|
||||||
if (!screenRows.length) {
|
if (!targets.length) {
|
||||||
return res.status(404).json({ error: 'No screens found' });
|
if (typeof broadcastDashboardState === 'function') {
|
||||||
|
await broadcastDashboardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
allScreens: true,
|
||||||
|
command: command,
|
||||||
|
targetScreenCount: 0,
|
||||||
|
targetPlayerCount: 0,
|
||||||
|
sent: 0
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||||
@@ -109,36 +183,41 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
||||||
commandPayload.blackout = blackoutValue;
|
commandPayload.blackout = blackoutValue;
|
||||||
}
|
}
|
||||||
|
if (command === 'pause' && req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused') && commandPayload && typeof commandPayload === 'object') {
|
||||||
await Promise.all(screenRows.map(function (screenRow) {
|
commandPayload.paused = req.body.paused;
|
||||||
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);
|
const results = await Promise.allSettled(targets.map(async function (target) {
|
||||||
});
|
const liveConnections = await fetchScreenConnections(target.slug);
|
||||||
|
if (liveConnections.length) {
|
||||||
|
return forwardPlayerCommandForConnections(target.slug, liveConnections, commandPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return forwardPlayerCommand(target.slug, commandPayload);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const sentCount = results.reduce(function (total, result) {
|
||||||
|
if (result.status !== 'fulfilled') {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentValue = Number(result.value && typeof result.value.sent === 'number' ? result.value.sent : 1);
|
||||||
|
return total + (Number.isFinite(sentValue) && sentValue > 0 ? sentValue : 1);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
if (typeof broadcastDashboardState === 'function') {
|
if (typeof broadcastDashboardState === 'function') {
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
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,
|
ok: true,
|
||||||
allScreens: true
|
allScreens: true,
|
||||||
|
command: command,
|
||||||
|
targetScreenCount: targets.length,
|
||||||
|
targetPlayerCount: sentCount,
|
||||||
|
sent: sentCount,
|
||||||
|
blackout: command === 'blackout' ? Boolean(commandPayload.blackout) : undefined,
|
||||||
|
paused: command === 'pause' ? Boolean(commandPayload.paused) : undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,28 +226,6 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
return res.status(404).json({ error: 'Screen not found' });
|
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') {
|
if (command === 'setclientname') {
|
||||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
|
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();
|
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||||
@@ -231,12 +288,21 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!onboardingRow) {
|
if (!onboardingRow) {
|
||||||
await forwardPlayerCommand(slug, {
|
if (playerBaseUrl) {
|
||||||
|
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
|
||||||
command: command,
|
command: command,
|
||||||
clientName: clientName,
|
clientName: clientName,
|
||||||
clientId: connectionId || deviceId || null,
|
clientId: connectionId || deviceId || null,
|
||||||
deviceId: deviceId || null
|
deviceId: deviceId || null
|
||||||
}, connectionId || deviceId || undefined);
|
}, connectionId || deviceId || undefined, deviceId || null);
|
||||||
|
} else {
|
||||||
|
await forwardPlayerCommandForConnections(slug, liveConnections, {
|
||||||
|
command: command,
|
||||||
|
clientName: clientName,
|
||||||
|
clientId: connectionId || deviceId || null,
|
||||||
|
deviceId: deviceId || null
|
||||||
|
}, connectionId || deviceId || undefined, deviceId || null);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof broadcastDashboardState === 'function') {
|
if (typeof broadcastDashboardState === 'function') {
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
@@ -323,52 +389,35 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
return res.status(500).json({ error: 'Client binding is unavailable.' });
|
return res.status(500).json({ error: 'Client binding is unavailable.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let liveConnections = [];
|
const liveConnections = await fetchScreenConnections(slug);
|
||||||
try {
|
|
||||||
const [screenSlugs] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
|
|
||||||
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
|
|
||||||
const screenSlug = String(row && row.slug ? row.slug : '').trim();
|
|
||||||
if (!screenSlug || typeof getScreenConnections !== 'function') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const liveResponse = await getScreenConnections(screenSlug);
|
|
||||||
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
|
|
||||||
} catch (_error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
liveConnections = liveResults.flat();
|
|
||||||
} catch (_error) {
|
|
||||||
liveConnections = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
|
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
|
||||||
|
let targetPlayerUrl = '';
|
||||||
const liveConnection = Array.isArray(liveConnections)
|
const liveConnection = Array.isArray(liveConnections)
|
||||||
? liveConnections.find(function (connection) {
|
? liveConnections.find(function (connection) {
|
||||||
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
|
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
|
||||||
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
|
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
|
||||||
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
|
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
|
||||||
})
|
}) || liveConnections[0] || null
|
||||||
: null;
|
: null;
|
||||||
const sourcePlayerBaseUrl = String(
|
const sourcePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || '');
|
||||||
explicitPlayerBaseUrl ||
|
if (sourcePlayerBaseUrl) {
|
||||||
(liveConnection && liveConnection.playerPublicBaseUrl) ||
|
targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`;
|
||||||
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
|
}
|
||||||
''
|
if (!targetPlayerUrl) {
|
||||||
).trim().replace(/\/$/, '');
|
targetPlayerUrl = `/screen/${encodeURIComponent(targetScreenSlug)}`;
|
||||||
const targetPlayerUrl = sourcePlayerBaseUrl ? `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
|
}
|
||||||
|
|
||||||
if (sourcePlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
if (playerBaseUrl) {
|
||||||
await forwardPlayerCommandToBaseUrl(sourcePlayerBaseUrl, slug, {
|
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
|
||||||
command: 'redirect',
|
command: 'redirect',
|
||||||
url: targetPlayerUrl
|
url: targetPlayerUrl
|
||||||
}, connectionId || deviceId || undefined);
|
}, connectionId || deviceId || undefined, deviceId || null);
|
||||||
} else {
|
} else {
|
||||||
await forwardPlayerCommand(slug, {
|
await forwardPlayerCommandForConnections(slug, liveConnections, {
|
||||||
command: 'redirect',
|
command: 'redirect',
|
||||||
url: targetPlayerUrl
|
url: targetPlayerUrl
|
||||||
}, connectionId || deviceId || undefined);
|
}, connectionId || deviceId || undefined, deviceId || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof broadcastDashboardState === 'function') {
|
if (typeof broadcastDashboardState === 'function') {
|
||||||
@@ -395,16 +444,10 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
commandPayload.blackout = blackoutValue;
|
commandPayload.blackout = blackoutValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerBaseUrls = await resolveScreenPlayerBaseUrls(slug, connectionId);
|
const liveConnections = await fetchScreenConnections(slug);
|
||||||
const result = playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function'
|
const result = playerBaseUrl
|
||||||
? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) {
|
? await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, commandPayload, connectionId, null)
|
||||||
return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandPayload, connectionId);
|
: await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null);
|
||||||
})).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') {
|
if (typeof broadcastDashboardState === 'function') {
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
|
|||||||
+135
-11
@@ -11,6 +11,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
|
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
|
||||||
const getScreenConnections = deps.getScreenConnections;
|
const getScreenConnections = deps.getScreenConnections;
|
||||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||||
|
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
const SCREEN_NAME_MAX_LENGTH = 255;
|
const SCREEN_NAME_MAX_LENGTH = 255;
|
||||||
@@ -25,13 +26,106 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
return text.slice(0, limit);
|
return text.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAllScreenSlugs() {
|
function normalizeBaseUrl(value) {
|
||||||
const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
|
return String(value || '').trim().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExplicitPlayerBaseUrl(value) {
|
||||||
|
return String(value || '').trim().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlayerRegistrations() {
|
||||||
|
if (typeof common.fetchPlayerRegistrations !== 'function') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registrations = await common.fetchPlayerRegistrations(pool);
|
||||||
|
return Array.isArray(registrations) ? registrations : [];
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fetchLiveConnectionsForScreen(slug) {
|
||||||
|
if (typeof getScreenConnections !== 'function') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getScreenConnections(slug);
|
||||||
|
return Array.isArray(response && response.connections) ? response.connections : [];
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePlayerBaseUrlsForConnections(connections) {
|
||||||
|
const connectionList = Array.isArray(connections) ? connections : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const registrations = await fetchPlayerRegistrations();
|
||||||
|
|
||||||
|
return connectionList.map(function (connection) {
|
||||||
|
const selectedPublicBaseUrl = normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl);
|
||||||
|
if (!selectedPublicBaseUrl || seen.has(selectedPublicBaseUrl)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(selectedPublicBaseUrl);
|
||||||
|
const matchedPlayer = registrations.find(function (player) {
|
||||||
|
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === selectedPublicBaseUrl;
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || selectedPublicBaseUrl;
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forwardPlayerCommandForConnections(slug, connections, commandPayload) {
|
||||||
|
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections);
|
||||||
|
if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
||||||
|
const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) {
|
||||||
|
return forwardPlayerCommandToBaseUrl(targetBaseUrl, slug, commandPayload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
sent: results.filter(function (result) {
|
||||||
|
return result.status === 'fulfilled';
|
||||||
|
}).length,
|
||||||
|
results: results
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return forwardPlayerCommand(slug, commandPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllScreenTargets() {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT s.slug
|
||||||
|
FROM d_screens s
|
||||||
|
WHERE s.slug IS NOT NULL
|
||||||
|
ORDER BY s.slug ASC`
|
||||||
|
);
|
||||||
return (rows || [])
|
return (rows || [])
|
||||||
.map(function (row) {
|
.map(function (row) {
|
||||||
return String(row && row.slug ? row.slug : '').trim();
|
return {
|
||||||
|
slug: String(row && row.slug ? row.slug : '').trim(),
|
||||||
|
playerId: '',
|
||||||
|
publicBaseUrl: '',
|
||||||
|
internalBaseUrl: ''
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(function (row) {
|
||||||
|
return Boolean(row.slug);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
|
app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
|
||||||
@@ -52,8 +146,8 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
return res.status(400).json({ error: 'Unsupported command' });
|
return res.status(400).json({ error: 'Unsupported command' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const slugs = await fetchAllScreenSlugs();
|
const targets = await fetchAllScreenTargets();
|
||||||
if (!slugs.length) {
|
if (!targets.length) {
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
return res.json({ ok: true, command: command, sent: 0 });
|
return res.json({ ok: true, command: command, sent: 0 });
|
||||||
}
|
}
|
||||||
@@ -70,7 +164,22 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
: 'reload';
|
: 'reload';
|
||||||
|
|
||||||
const sentCount = await notifyPlayerScreens(slugs, payload);
|
const results = await Promise.allSettled(targets.map(async function (target) {
|
||||||
|
const liveConnections = await fetchLiveConnectionsForScreen(target.slug);
|
||||||
|
if (liveConnections.length) {
|
||||||
|
return forwardPlayerCommandForConnections(target.slug, liveConnections, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return forwardPlayerCommand(target.slug, payload);
|
||||||
|
}));
|
||||||
|
const sentCount = results.reduce(function (total, result) {
|
||||||
|
if (result.status !== 'fulfilled') {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentValue = Number(result.value && typeof result.value.sent === 'number' ? result.value.sent : 1);
|
||||||
|
return total + (Number.isFinite(sentValue) && sentValue > 0 ? sentValue : 1);
|
||||||
|
}, 0);
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -149,14 +258,29 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||||
}
|
}
|
||||||
if (previousSlug && previousSlug !== slug) {
|
if (previousSlug && previousSlug !== slug) {
|
||||||
const playerBaseUrl = typeof common.fetchPlayerPublicBaseUrl === 'function'
|
const previousScreenTargets = await pool.query(
|
||||||
? await common.fetchPlayerPublicBaseUrl(pool)
|
`SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url
|
||||||
: '';
|
FROM d_screens s
|
||||||
|
LEFT JOIN d_players p ON p.device_id = s.player_id
|
||||||
|
WHERE s.slug = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[previousSlug]
|
||||||
|
);
|
||||||
|
const previousTargetRow = previousScreenTargets[0] && previousScreenTargets[0][0] || null;
|
||||||
|
const previousInternalBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.internal_base_url) || '';
|
||||||
|
const previousPublicBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.public_base_url) || '';
|
||||||
|
if (previousInternalBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
||||||
|
await forwardPlayerCommandToBaseUrl(previousInternalBaseUrl, previousSlug, {
|
||||||
|
command: 'redirect',
|
||||||
|
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
await forwardPlayerCommand(previousSlug, {
|
await forwardPlayerCommand(previousSlug, {
|
||||||
command: 'redirect',
|
command: 'redirect',
|
||||||
url: playerBaseUrl ? `${playerBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||||
closeUrl: '/screens',
|
closeUrl: '/screens',
|
||||||
newUrl: '/screens/new',
|
newUrl: '/screens/new',
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
|
|
||||||
const LIST_PAGE_SIZE = 25;
|
const LIST_PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
function toSortIndex(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : 999;
|
||||||
|
}
|
||||||
|
|
||||||
function slugifyRoleKey(name) {
|
function slugifyRoleKey(name) {
|
||||||
const value = String(name || '').trim().toLowerCase();
|
const value = String(name || '').trim().toLowerCase();
|
||||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ROLE_KEY_MAX_LENGTH);
|
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ROLE_KEY_MAX_LENGTH);
|
||||||
@@ -73,8 +78,11 @@
|
|||||||
resourceKey: definition ? definition.resourceKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
resourceKey: definition ? definition.resourceKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||||
resourceName: definition ? definition.resourceName : permission.section_name,
|
resourceName: definition ? definition.resourceName : permission.section_name,
|
||||||
categoryName: definition ? definition.sectionName : permission.section_name,
|
categoryName: definition ? definition.sectionName : permission.section_name,
|
||||||
|
sectionIndex: definition ? toSortIndex(definition.sectionIndex) : 999,
|
||||||
sectionOrder: definition ? definition.sectionOrder : 999,
|
sectionOrder: definition ? definition.sectionOrder : 999,
|
||||||
|
resourceIndex: definition ? toSortIndex(definition.resourceIndex) : 999,
|
||||||
resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999,
|
resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999,
|
||||||
|
permissionIndex: definition ? toSortIndex(definition.permissionIndex) : 999,
|
||||||
permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999,
|
permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999,
|
||||||
actionKey: definition ? definition.actionKey : 'read',
|
actionKey: definition ? definition.actionKey : 'read',
|
||||||
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
|
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
|
||||||
@@ -103,7 +111,9 @@
|
|||||||
id: sectionKey || 'permissions',
|
id: sectionKey || 'permissions',
|
||||||
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
|
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
|
||||||
categoryName: String(permission.categoryName || '').trim(),
|
categoryName: String(permission.categoryName || '').trim(),
|
||||||
|
sectionIndex: toSortIndex(permission.sectionIndex),
|
||||||
sectionOrder: Number(permission.sectionOrder) || 999,
|
sectionOrder: Number(permission.sectionOrder) || 999,
|
||||||
|
resourceIndex: toSortIndex(permission.resourceIndex),
|
||||||
permissions: []
|
permissions: []
|
||||||
};
|
};
|
||||||
groupIndex.set(sectionKey, group);
|
groupIndex.set(sectionKey, group);
|
||||||
@@ -114,8 +124,13 @@
|
|||||||
|
|
||||||
groups.forEach(function (group) {
|
groups.forEach(function (group) {
|
||||||
group.permissions.sort(function (left, right) {
|
group.permissions.sort(function (left, right) {
|
||||||
const leftOrder = Number(left.permissionOrder) || 999;
|
const leftIndex = toSortIndex(left.permissionIndex);
|
||||||
const rightOrder = Number(right.permissionOrder) || 999;
|
const rightIndex = toSortIndex(right.permissionIndex);
|
||||||
|
if (leftIndex !== rightIndex) {
|
||||||
|
return leftIndex - rightIndex;
|
||||||
|
}
|
||||||
|
const leftOrder = toSortIndex(left.permissionOrder);
|
||||||
|
const rightOrder = toSortIndex(right.permissionOrder);
|
||||||
if (leftOrder !== rightOrder) {
|
if (leftOrder !== rightOrder) {
|
||||||
return leftOrder - rightOrder;
|
return leftOrder - rightOrder;
|
||||||
}
|
}
|
||||||
@@ -124,8 +139,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
groups.sort(function (left, right) {
|
groups.sort(function (left, right) {
|
||||||
const leftOrder = Number(left.sectionOrder) || 999;
|
const leftIndex = toSortIndex(left.sectionIndex);
|
||||||
const rightOrder = Number(right.sectionOrder) || 999;
|
const rightIndex = toSortIndex(right.sectionIndex);
|
||||||
|
if (leftIndex !== rightIndex) {
|
||||||
|
return leftIndex - rightIndex;
|
||||||
|
}
|
||||||
|
const leftOrder = toSortIndex(left.sectionOrder);
|
||||||
|
const rightOrder = toSortIndex(right.sectionOrder);
|
||||||
if (leftOrder !== rightOrder) {
|
if (leftOrder !== rightOrder) {
|
||||||
return leftOrder - rightOrder;
|
return leftOrder - rightOrder;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Internal sync trigger routes for player-driven queue flushes.
|
// Internal sync trigger routes for player-driven queue flushes.
|
||||||
|
|
||||||
const { verifyRequestAuth } = require('#src/request-auth');
|
const { verifyRequestAuth } = require('#src/request-auth');
|
||||||
|
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
|
||||||
|
|
||||||
function requireRequestAuth(req, res, next) {
|
function requireRequestAuth(req, res, next) {
|
||||||
if (!verifyRequestAuth(req)) {
|
if (!verifyRequestAuth(req)) {
|
||||||
@@ -11,21 +12,77 @@ function requireRequestAuth(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function registerInternalSyncRoutes(app, deps) {
|
module.exports = function registerInternalSyncRoutes(app, deps) {
|
||||||
|
const backgroundTaskQueue = deps && deps.backgroundTaskQueue;
|
||||||
const uploadSyncService = deps && deps.uploadSyncService;
|
const uploadSyncService = deps && deps.uploadSyncService;
|
||||||
const mediaDir = String(deps && deps.mediaDir || '').trim();
|
const mediaDir = String(deps && deps.mediaDir || '').trim();
|
||||||
|
|
||||||
if (!uploadSyncService || typeof uploadSyncService.flushPendingPlayerUploadSyncs !== 'function' || typeof uploadSyncService.runMediaSyncTask !== 'function' || !mediaDir) {
|
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function' || !uploadSyncService || !mediaDir) {
|
||||||
throw new Error('registerInternalSyncRoutes requires the sync dependencies.');
|
throw new Error('registerInternalSyncRoutes requires the sync dependencies.');
|
||||||
}
|
}
|
||||||
|
|
||||||
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
|
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
|
||||||
try {
|
try {
|
||||||
await uploadSyncService.runMediaSyncTask({
|
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
|
||||||
|
? _req.body
|
||||||
|
: {};
|
||||||
|
const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim();
|
||||||
|
const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim();
|
||||||
|
const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim();
|
||||||
|
const task = await backgroundTaskQueue.enqueueTask({
|
||||||
|
key: 'player-media-sync',
|
||||||
|
title: 'Player media sync',
|
||||||
|
category: 'media-sync',
|
||||||
|
taskType: 'media-sync',
|
||||||
|
metadata: {
|
||||||
|
playerIdentifier: playerIdentifier || null,
|
||||||
|
playerPublicBaseUrl: playerPublicBaseUrl || null,
|
||||||
|
playerInternalBaseUrl: playerInternalBaseUrl || null,
|
||||||
|
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
mode: 'initial',
|
mode: 'initial',
|
||||||
uploadDir: mediaDir
|
uploadDir: mediaDir,
|
||||||
|
playerIdentifier: playerIdentifier,
|
||||||
|
playerPublicBaseUrl: playerPublicBaseUrl,
|
||||||
|
playerInternalBaseUrl: playerInternalBaseUrl
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await uploadSyncService.flushPendingPlayerUploadSyncs();
|
res.status(202).json({ ok: true, queued: true, task: task });
|
||||||
res.json({ ok: true });
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/internal/sync/player-font', requireRequestAuth, async function (_req, res, next) {
|
||||||
|
try {
|
||||||
|
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
|
||||||
|
? _req.body
|
||||||
|
: {};
|
||||||
|
const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim();
|
||||||
|
const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim();
|
||||||
|
const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim();
|
||||||
|
const operations = collectFontLibrarySyncOperations(mediaDir);
|
||||||
|
const task = await backgroundTaskQueue.enqueueTask({
|
||||||
|
key: 'player-font-sync',
|
||||||
|
title: 'Player font sync',
|
||||||
|
category: 'fonts',
|
||||||
|
taskType: 'font-sync',
|
||||||
|
metadata: {
|
||||||
|
playerIdentifier: playerIdentifier || null,
|
||||||
|
playerPublicBaseUrl: playerPublicBaseUrl || null,
|
||||||
|
playerInternalBaseUrl: playerInternalBaseUrl || null,
|
||||||
|
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
mode: 'initial',
|
||||||
|
uploadDir: mediaDir,
|
||||||
|
operations: operations,
|
||||||
|
playerIdentifier: playerIdentifier,
|
||||||
|
playerPublicBaseUrl: playerPublicBaseUrl,
|
||||||
|
playerInternalBaseUrl: playerInternalBaseUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.status(202).json({ ok: true, queued: true, task: task });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function registerRoutes(app, deps) {
|
|||||||
registerSignageRoutes(app, deps);
|
registerSignageRoutes(app, deps);
|
||||||
registerSettingsAndContentRoutes(app, deps);
|
registerSettingsAndContentRoutes(app, deps);
|
||||||
registerInternalSyncRoutes(app, {
|
registerInternalSyncRoutes(app, {
|
||||||
|
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||||
uploadSyncService: deps.uploadSyncService,
|
uploadSyncService: deps.uploadSyncService,
|
||||||
mediaDir: deps.mediaDir
|
mediaDir: deps.mediaDir
|
||||||
});
|
});
|
||||||
@@ -125,6 +126,8 @@ function registerSignageRoutes(app, deps) {
|
|||||||
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
||||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||||
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
||||||
|
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
|
||||||
|
forwardPlayerCommandToDevice: deps.playerActionService.forwardPlayerCommandToDevice,
|
||||||
requirePermission: deps.requirePermission
|
requirePermission: deps.requirePermission
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,31 @@ function buildTaskPlayerLabel(metadata) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTaskDescription(task, metadata) {
|
||||||
|
const playerLabel = buildTaskPlayerLabel(metadata);
|
||||||
|
const taskKey = String(task && task.key || '').trim();
|
||||||
|
let displayKey = taskKey;
|
||||||
|
|
||||||
|
if (playerLabel && displayKey) {
|
||||||
|
const playerPrefix = `${playerLabel}:`;
|
||||||
|
const playerSuffix = `:${playerLabel}`;
|
||||||
|
|
||||||
|
if (displayKey.startsWith(playerPrefix)) {
|
||||||
|
displayKey = displayKey.slice(playerPrefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayKey.endsWith(playerSuffix)) {
|
||||||
|
displayKey = displayKey.slice(0, -playerSuffix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayKey === playerLabel) {
|
||||||
|
displayKey = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [playerLabel, displayKey].filter(Boolean).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
function buildQueuePageViewModel(data, message, currentUser) {
|
function buildQueuePageViewModel(data, message, currentUser) {
|
||||||
const tasks = (data && data.tasks) || [];
|
const tasks = (data && data.tasks) || [];
|
||||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
||||||
@@ -262,7 +287,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
|||||||
|
|
||||||
const visibleTasksWithSources = visibleTasks.map(function (task) {
|
const visibleTasksWithSources = visibleTasks.map(function (task) {
|
||||||
const playerLabel = buildTaskPlayerLabel(task.metadata);
|
const playerLabel = buildTaskPlayerLabel(task.metadata);
|
||||||
const taskDescription = [playerLabel, String(task && task.key || '').trim()].filter(Boolean).join(':');
|
const taskDescription = buildTaskDescription(task, task.metadata);
|
||||||
|
|
||||||
return Object.assign({}, task, {
|
return Object.assign({}, task, {
|
||||||
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
|
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ function normalizeSectionId(value) {
|
|||||||
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toSortIndex(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : 999;
|
||||||
|
}
|
||||||
|
|
||||||
function buildPermissionSections(permissionGroups) {
|
function buildPermissionSections(permissionGroups) {
|
||||||
const sections = [];
|
const sections = [];
|
||||||
const sectionIndex = new Map();
|
const sectionIndex = new Map();
|
||||||
@@ -23,7 +28,8 @@ function buildPermissionSections(permissionGroups) {
|
|||||||
sectionIndex.set(sectionKey, {
|
sectionIndex.set(sectionKey, {
|
||||||
id: sectionKey,
|
id: sectionKey,
|
||||||
title: sectionTitle,
|
title: sectionTitle,
|
||||||
sectionOrder: Number(group && group.sectionOrder) || 999,
|
sourceIndex: toSortIndex(group && group.sectionIndex),
|
||||||
|
sectionOrder: toSortIndex(group && group.sectionOrder),
|
||||||
groups: []
|
groups: []
|
||||||
});
|
});
|
||||||
sections.push(sectionIndex.get(sectionKey));
|
sections.push(sectionIndex.get(sectionKey));
|
||||||
@@ -47,8 +53,14 @@ function buildPermissionSections(permissionGroups) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
section.groups.sort(function (left, right) {
|
section.groups.sort(function (left, right) {
|
||||||
const leftOrder = Number(left.resourceOrder) || 999;
|
const leftIndex = toSortIndex(left.resourceIndex);
|
||||||
const rightOrder = Number(right.resourceOrder) || 999;
|
const rightIndex = toSortIndex(right.resourceIndex);
|
||||||
|
if (leftIndex !== rightIndex) {
|
||||||
|
return leftIndex - rightIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftOrder = toSortIndex(left.resourceOrder);
|
||||||
|
const rightOrder = toSortIndex(right.resourceOrder);
|
||||||
if (leftOrder !== rightOrder) {
|
if (leftOrder !== rightOrder) {
|
||||||
return leftOrder - rightOrder;
|
return leftOrder - rightOrder;
|
||||||
}
|
}
|
||||||
@@ -65,8 +77,14 @@ function buildPermissionSections(permissionGroups) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
sections.sort(function (left, right) {
|
sections.sort(function (left, right) {
|
||||||
const leftOrder = Number(left.sectionOrder) || 999;
|
const leftIndex = toSortIndex(left.sourceIndex);
|
||||||
const rightOrder = Number(right.sectionOrder) || 999;
|
const rightIndex = toSortIndex(right.sourceIndex);
|
||||||
|
if (leftIndex !== rightIndex) {
|
||||||
|
return leftIndex - rightIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftOrder = toSortIndex(left.sectionOrder);
|
||||||
|
const rightOrder = toSortIndex(right.sectionOrder);
|
||||||
if (leftOrder !== rightOrder) {
|
if (leftOrder !== rightOrder) {
|
||||||
return leftOrder - rightOrder;
|
return leftOrder - rightOrder;
|
||||||
}
|
}
|
||||||
@@ -134,5 +152,6 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildRbacAddViewModel: buildRbacAddViewModel,
|
buildRbacAddViewModel: buildRbacAddViewModel,
|
||||||
buildRbacEditViewModel: buildRbacEditViewModel
|
buildRbacEditViewModel: buildRbacEditViewModel,
|
||||||
|
buildPermissionSections: buildPermissionSections
|
||||||
};
|
};
|
||||||
@@ -18,7 +18,6 @@ module.exports = function registerClientsRoutes(app, deps) {
|
|||||||
client: function (client) { return String(client && (client.client_name || client.name || client.clientId) || '').trim(); },
|
client: function (client) { return String(client && (client.client_name || client.name || client.clientId) || '').trim(); },
|
||||||
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
|
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
|
||||||
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
|
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
|
||||||
ip: function (client) { return String(client && client.clientIp || '').trim(); },
|
|
||||||
viewport: function (client) {
|
viewport: function (client) {
|
||||||
const viewport = client && client.viewport;
|
const viewport = client && client.viewport;
|
||||||
if (!viewport || !viewport.width || !viewport.height) {
|
if (!viewport || !viewport.width || !viewport.height) {
|
||||||
@@ -37,12 +36,6 @@ module.exports = function registerClientsRoutes(app, deps) {
|
|||||||
? [normalizedSortKey]
|
? [normalizedSortKey]
|
||||||
: ['client'];
|
: ['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) {
|
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
|
||||||
for (let index = 0; index < sortKeys.length; index += 1) {
|
for (let index = 0; index < sortKeys.length; index += 1) {
|
||||||
const sortKeyName = sortKeys[index];
|
const sortKeyName = sortKeys[index];
|
||||||
@@ -71,7 +64,6 @@ module.exports = function registerClientsRoutes(app, deps) {
|
|||||||
'deviceId',
|
'deviceId',
|
||||||
'slug',
|
'slug',
|
||||||
'screen_slug',
|
'screen_slug',
|
||||||
'ipAddress',
|
|
||||||
'status'
|
'status'
|
||||||
]);
|
]);
|
||||||
const filteredClients = (data.clients || []).filter(matchesSearch);
|
const filteredClients = (data.clients || []).filter(matchesSearch);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
<form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||||
|
<input type="hidden" name="permissions_present" value="1" />
|
||||||
<input type="hidden" name="users_present" value="1" />
|
<input type="hidden" name="users_present" value="1" />
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
<span class="fw-semibold">{{title}}</span>
|
<span class="fw-semibold">{{title}}</span>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="permission-section-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="permission-section-heading-{{id}}" data-bs-parent="#role-permissions-accordion">
|
<div id="permission-section-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="permission-section-heading-{{id}}">
|
||||||
<div class="accordion-body p-0">
|
<div class="accordion-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped align-middle mb-0 rbac-permissions-table" style="table-layout: fixed; width: 100%;">
|
<table class="table table-striped align-middle mb-0 rbac-permissions-table" style="table-layout: fixed; width: 100%;">
|
||||||
|
|||||||
@@ -40,19 +40,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="screen-command-actions">
|
<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">
|
<form method="post" action="#" class="inline-form" data-confirm-message="Reload selected screen?" data-screen-command-form data-screen-command-action="reload" data-async-command>
|
||||||
<input type="hidden" name="command" value="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>
|
<button type="submit" class="btn btn-sm btn-danger" disabled><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen</button>
|
||||||
</form>
|
</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">
|
<form method="post" action="#" class="inline-form" data-confirm-message="Pause selected screen?" data-screen-command-form data-screen-command-action="pause" data-async-command>
|
||||||
<input type="hidden" name="command" value="pause" />
|
<input type="hidden" name="command" value="pause" />
|
||||||
<input type="hidden" name="paused" value="true" />
|
<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>
|
<button type="submit" class="btn btn-sm btn-info" disabled><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause screen</button>
|
||||||
</form>
|
</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">
|
<form method="post" action="#" class="inline-form" data-confirm-message="Blackout selected screen?" data-screen-command-form data-screen-command-action="blackout" data-async-command>
|
||||||
<input type="hidden" name="command" value="blackout" />
|
<input type="hidden" name="command" value="blackout" />
|
||||||
<input type="hidden" name="blackout" value="true" />
|
<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>
|
<button type="submit" class="btn btn-sm btn-secondary" disabled><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>Blackout screen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +88,6 @@
|
|||||||
<th data-table-sort-key="client">Client</th>
|
<th data-table-sort-key="client">Client</th>
|
||||||
<th data-table-sort-key="screen">Current Screen Group</th>
|
<th data-table-sort-key="screen">Current Screen Group</th>
|
||||||
<th data-table-sort-key="slide">Current Slide</th>
|
<th data-table-sort-key="slide">Current Slide</th>
|
||||||
<th data-table-sort-key="ip">IP</th>
|
|
||||||
<th data-table-sort-key="viewport">Viewport</th>
|
<th data-table-sort-key="viewport">Viewport</th>
|
||||||
<th data-table-sort-key="connected">Connected/Updated</th>
|
<th data-table-sort-key="connected">Connected/Updated</th>
|
||||||
{{#if (hasPermission currentUser 'clients.allow')}}<th>Actions</th>{{/if}}
|
{{#if (hasPermission currentUser 'clients.allow')}}<th>Actions</th>{{/if}}
|
||||||
@@ -111,7 +110,6 @@
|
|||||||
<span class="empty">No slide currently showing</span>
|
<span class="empty">No slide currently showing</span>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="IP">{{#if clientIp}}{{clientIp}}{{else}}<span class="empty">Unknown</span>{{/if}}</td>
|
|
||||||
<td data-label="Viewport">
|
<td data-label="Viewport">
|
||||||
{{#if viewport}}
|
{{#if viewport}}
|
||||||
{{viewport.width}}x{{viewport.height}}
|
{{viewport.width}}x{{viewport.height}}
|
||||||
@@ -138,7 +136,7 @@
|
|||||||
<input type="hidden" name="playerBaseUrl" value="{{player_url}}" />
|
<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>
|
<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>
|
</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>
|
<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" 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>
|
<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="command" value="previous" />
|
||||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||||
@@ -169,7 +167,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr data-table-search-empty-default><td colspan="{{#if (hasPermission currentUser 'clients.allow')}}7{{else}}6{{/if}}" class="empty">No connected clients yet.</td></tr>
|
<tr data-table-search-empty-default><td colspan="{{#if (hasPermission currentUser 'clients.allow')}}6{{else}}5{{/if}}" class="empty">No connected clients yet.</td></tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -43,9 +43,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{#if (hasPermission currentUser "dashboard.allow")}}
|
{{#if (hasPermission currentUser "dashboard.allow")}}
|
||||||
<div class="row pb-4">
|
<div class="row">
|
||||||
<div class="col-12 col-xl-8">
|
<div class="col-12 col-xl-8">
|
||||||
<div class="card card-outline card-primary dashboard-actions-card">
|
<div class="card card-outline card-primary dashboard-actions-card pb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="dashboard-card-heading">
|
<div class="dashboard-card-heading">
|
||||||
<h3 class="card-title">Quick actions</h3>
|
<h3 class="card-title">Quick actions</h3>
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-xl-4">
|
<div class="col-12 col-xl-4">
|
||||||
<div class="card card-outline card-secondary dashboard-launcher-card h-100">
|
<div class="card card-outline card-secondary dashboard-launcher-card h-100 pb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="dashboard-card-heading">
|
<div class="dashboard-card-heading">
|
||||||
<h3 class="card-title">Kiosk launchers</h3>
|
<h3 class="card-title">Kiosk launchers</h3>
|
||||||
@@ -144,7 +144,8 @@
|
|||||||
<li>The script looks for a supported browser on the device and launches the first one it finds.</li>
|
<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>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>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>
|
<li>If a browser is already open or open in the background, the launcher will bring it to the front, but not enter kiosk mode.</li>
|
||||||
|
<li>Exit with Alt+F4 on Windows or Linux.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="mt-3 pt-3 border-top">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -44,7 +44,18 @@ test('move client rebinding redirects the live player to the target screen', asy
|
|||||||
return [[]];
|
return [[]];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
common: {},
|
common: {
|
||||||
|
fetchPlayerRegistrations: async () => ([
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-a.example',
|
||||||
|
internal_base_url: 'http://player-a.internal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-b.example',
|
||||||
|
internal_base_url: 'http://player-b.internal'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
},
|
||||||
forwardPlayerCommand(slug, payload, connectionId) {
|
forwardPlayerCommand(slug, payload, connectionId) {
|
||||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
|
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -67,6 +78,19 @@ test('move client rebinding redirects the live player to the target screen', asy
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (slug === 'target-screen') {
|
||||||
|
return {
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'target-conn-1',
|
||||||
|
clientId: 'target-conn-1',
|
||||||
|
deviceId: 'target-device-1',
|
||||||
|
playerPublicBaseUrl: 'https://remote-target.example'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { connections: [] };
|
return { connections: [] };
|
||||||
},
|
},
|
||||||
isClientNameAvailable: async () => true,
|
isClientNameAvailable: async () => true,
|
||||||
@@ -115,7 +139,8 @@ test('move client rebinding redirects the live player to the target screen', asy
|
|||||||
assert.equal(response.body.targetScreenSlug, 'target-screen');
|
assert.equal(response.body.targetScreenSlug, 'target-screen');
|
||||||
assert.equal(response.body.playerUrl, 'http://remote-player.example/screen/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 === 'broadcastDashboardState'), true);
|
||||||
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example' && entry.payload && entry.payload.command === 'redirect'), true);
|
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen' && entry.payload && entry.payload.command === 'redirect'), false);
|
||||||
|
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('screen control commands can target all screens', async () => {
|
test('screen control commands can target all screens', async () => {
|
||||||
@@ -127,22 +152,65 @@ test('screen control commands can target all screens', async () => {
|
|||||||
async query(sql) {
|
async query(sql) {
|
||||||
calls.push({ kind: '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')) {
|
if (String(sql || '').includes('FROM d_screens') && String(sql || '').includes('ORDER BY slug ASC')) {
|
||||||
return [[
|
return [[
|
||||||
{ id: 1, name: 'Alpha', slug: 'alpha' },
|
{ slug: 'alpha' },
|
||||||
{ id: 2, name: 'Beta', slug: 'beta' }
|
{ slug: 'beta' }
|
||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [[]];
|
return [[]];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
common: {},
|
common: {
|
||||||
|
fetchPlayerRegistrations: async () => ([
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-a.example',
|
||||||
|
internal_base_url: 'http://player-a.internal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-b.example',
|
||||||
|
internal_base_url: 'http://player-b.internal'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
},
|
||||||
forwardPlayerCommand(slug, payload) {
|
forwardPlayerCommand(slug, payload) {
|
||||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
getScreenConnections: async () => ({ connections: [] }),
|
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
|
||||||
|
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
|
||||||
|
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,
|
isClientNameAvailable: async () => true,
|
||||||
withClientNameReservation: async (_pool, _name, callback) => callback(),
|
withClientNameReservation: async (_pool, _name, callback) => callback(),
|
||||||
broadcastDashboardState: async () => {
|
broadcastDashboardState: async () => {
|
||||||
@@ -184,11 +252,13 @@ test('screen control commands can target all screens', async () => {
|
|||||||
assert.equal(response.body.ok, true);
|
assert.equal(response.body.ok, true);
|
||||||
assert.equal(response.body.allScreens, true);
|
assert.equal(response.body.allScreens, true);
|
||||||
assert.equal(response.body.targetScreenCount, 2);
|
assert.equal(response.body.targetScreenCount, 2);
|
||||||
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommand').map((entry) => entry.slug), ['alpha', 'beta']);
|
assert.equal(response.body.targetPlayerCount, 2);
|
||||||
|
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl), ['http://player-a.internal', 'http://player-b.internal']);
|
||||||
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
||||||
|
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('screen control commands use the live connection player url when available', async () => {
|
test('screen control commands use the bridge for live connections', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const { app, handlers } = createHandlers();
|
const { app, handlers } = createHandlers();
|
||||||
|
|
||||||
@@ -197,6 +267,17 @@ test('screen control commands use the live connection player url when available'
|
|||||||
async query(sql, params) {
|
async query(sql, params) {
|
||||||
calls.push({ kind: 'query', sql, params });
|
calls.push({ kind: 'query', sql, params });
|
||||||
|
|
||||||
|
if (String(sql || '').includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) {
|
||||||
|
return [[
|
||||||
|
{
|
||||||
|
slug: 'source-screen',
|
||||||
|
player_id: 'player-a',
|
||||||
|
public_base_url: 'http://player-a.example',
|
||||||
|
internal_base_url: 'http://player-a.internal'
|
||||||
|
}
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-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' }]];
|
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
|
||||||
}
|
}
|
||||||
@@ -204,15 +285,22 @@ test('screen control commands use the live connection player url when available'
|
|||||||
return [[]];
|
return [[]];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
common: {},
|
common: {
|
||||||
|
fetchPlayerRegistrations: async () => ([
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-a.example',
|
||||||
|
internal_base_url: 'http://player-a.internal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-b.example',
|
||||||
|
internal_base_url: 'http://player-b.internal'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
},
|
||||||
forwardPlayerCommand(slug, payload, connectionId) {
|
forwardPlayerCommand(slug, payload, connectionId) {
|
||||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
|
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
|
|
||||||
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
|
|
||||||
return { ok: true };
|
|
||||||
},
|
|
||||||
getScreenConnections: async (slug) => {
|
getScreenConnections: async (slug) => {
|
||||||
if (slug === 'source-screen') {
|
if (slug === 'source-screen') {
|
||||||
return {
|
return {
|
||||||
@@ -268,11 +356,11 @@ test('screen control commands use the live connection player url when available'
|
|||||||
|
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.equal(response.body.ok, true);
|
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' && entry.slug === 'source-screen'), true);
|
||||||
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
|
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://local-player.example'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('screen control commands fan out to every live player for the selected screen', async () => {
|
test('screen control commands fan out through the bridge for the selected screen', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const { app, handlers } = createHandlers();
|
const { app, handlers } = createHandlers();
|
||||||
|
|
||||||
@@ -281,6 +369,15 @@ test('screen control commands fan out to every live player for the selected scre
|
|||||||
async query(sql, params) {
|
async query(sql, params) {
|
||||||
calls.push({ kind: 'query', sql, params });
|
calls.push({ kind: 'query', sql, params });
|
||||||
|
|
||||||
|
if (String(sql || '').includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) {
|
||||||
|
return [[{
|
||||||
|
slug: 'source-screen',
|
||||||
|
player_id: 'player-a',
|
||||||
|
public_base_url: 'http://player-a.example',
|
||||||
|
internal_base_url: 'http://player-a.internal'
|
||||||
|
}]];
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-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' }]];
|
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
|
||||||
}
|
}
|
||||||
@@ -288,7 +385,18 @@ test('screen control commands fan out to every live player for the selected scre
|
|||||||
return [[]];
|
return [[]];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
common: {},
|
common: {
|
||||||
|
fetchPlayerRegistrations: async () => ([
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-a.example',
|
||||||
|
internal_base_url: 'http://player-a.internal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-b.example',
|
||||||
|
internal_base_url: 'http://player-b.internal'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
},
|
||||||
forwardPlayerCommand(slug, payload, connectionId) {
|
forwardPlayerCommand(slug, payload, connectionId) {
|
||||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
|
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -305,7 +413,7 @@ test('screen control commands fan out to every live player for the selected scre
|
|||||||
id: 'conn-1',
|
id: 'conn-1',
|
||||||
clientId: 'conn-1',
|
clientId: 'conn-1',
|
||||||
deviceId: 'device-123',
|
deviceId: 'device-123',
|
||||||
playerPublicBaseUrl: 'http://local-player-a.example'
|
playerPublicBaseUrl: 'http://local-player.example'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'conn-2',
|
id: 'conn-2',
|
||||||
@@ -357,14 +465,14 @@ test('screen control commands fan out to every live player for the selected scre
|
|||||||
|
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.equal(response.body.ok, true);
|
assert.equal(response.body.ok, true);
|
||||||
|
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen'), false);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
|
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
|
||||||
['http://local-player-a.example', 'http://local-player-b.example']
|
['http://local-player.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 () => {
|
test('all screens commands fan out through the bridge for each screen', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const { app, handlers } = createHandlers();
|
const { app, handlers } = createHandlers();
|
||||||
|
|
||||||
@@ -373,23 +481,34 @@ test('all screens commands fan out across the live player for each screen', asyn
|
|||||||
async query(sql) {
|
async query(sql) {
|
||||||
calls.push({ kind: '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')) {
|
if (String(sql || '').includes('FROM d_screens') && String(sql || '').includes('ORDER BY slug ASC')) {
|
||||||
return [[
|
return [[
|
||||||
{ id: 1, name: 'Alpha', slug: 'alpha' },
|
{ slug: 'alpha' },
|
||||||
{ id: 2, name: 'Beta', slug: 'beta' }
|
{ slug: 'beta' }
|
||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [[]];
|
return [[]];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
common: {},
|
common: {
|
||||||
|
fetchPlayerRegistrations: async () => ([
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-a.example',
|
||||||
|
internal_base_url: 'http://player-a.internal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-b.example',
|
||||||
|
internal_base_url: 'http://player-b.internal'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
},
|
||||||
forwardPlayerCommand(slug, payload) {
|
forwardPlayerCommand(slug, payload) {
|
||||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload) {
|
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
|
||||||
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
|
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
getScreenConnections: async (slug) => {
|
getScreenConnections: async (slug) => {
|
||||||
@@ -461,10 +580,11 @@ test('all screens commands fan out across the live player for each screen', asyn
|
|||||||
assert.equal(response.body.ok, true);
|
assert.equal(response.body.ok, true);
|
||||||
assert.equal(response.body.allScreens, true);
|
assert.equal(response.body.allScreens, true);
|
||||||
assert.equal(response.body.targetScreenCount, 2);
|
assert.equal(response.body.targetScreenCount, 2);
|
||||||
|
assert.equal(response.body.targetPlayerCount, 2);
|
||||||
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
|
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
|
||||||
['http://player-a.example', 'http://player-b.example']
|
['http://player-a.internal', 'http://player-b.internal']
|
||||||
);
|
);
|
||||||
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
|
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { registerFontSweepTask } = require('../src/web/lib/background-tasks/tasks-scheduled/font-sweep');
|
||||||
|
|
||||||
|
test('font sweep registers a generic cleanup recurring task without player metadata', async () => {
|
||||||
|
let registeredTask = null;
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
registerFontSweepTask({
|
||||||
|
backgroundTaskQueue: {
|
||||||
|
registerRecurringTask(task) {
|
||||||
|
registeredTask = task;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mediaDir: '/tmp/media',
|
||||||
|
uploadSyncService: {
|
||||||
|
async pushUploadFileToPlayer(uploadPath, mediaDir) {
|
||||||
|
calls.push(['put', uploadPath, mediaDir]);
|
||||||
|
},
|
||||||
|
async removeUploadFileFromPlayer(uploadPath, mediaDir) {
|
||||||
|
calls.push(['delete', uploadPath, mediaDir]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(registeredTask);
|
||||||
|
assert.equal(registeredTask.key, 'font-sweep');
|
||||||
|
assert.equal(registeredTask.title, 'Font sweep');
|
||||||
|
assert.equal(registeredTask.category, 'cleanup');
|
||||||
|
assert.equal(registeredTask.intervalMs, 24 * 60 * 60 * 1000);
|
||||||
|
assert.deepEqual(registeredTask.metadata, { mediaDir: '/tmp/media' });
|
||||||
|
|
||||||
|
await registeredTask.run();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['put', '/media/fonts/fonts.json', '/tmp/media'],
|
||||||
|
['put', '/media/fonts/fonts.css', '/tmp/media']
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { registerOnboardingDevicePruneTask } = require('../src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune');
|
||||||
|
|
||||||
|
test('onboarding device prune registers an hourly recurring cleanup job', async () => {
|
||||||
|
let registeredTask = null;
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
registerOnboardingDevicePruneTask({
|
||||||
|
backgroundTaskQueue: {
|
||||||
|
registerRecurringTask(task) {
|
||||||
|
registeredTask = task;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pool: {},
|
||||||
|
common: {
|
||||||
|
async pruneStaleOnboardingDevices(pool) {
|
||||||
|
calls.push(pool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(registeredTask);
|
||||||
|
assert.equal(registeredTask.key, 'onboarding-device-prune');
|
||||||
|
assert.equal(registeredTask.title, 'Onboarding device prune');
|
||||||
|
assert.equal(registeredTask.category, 'cleanup');
|
||||||
|
assert.equal(registeredTask.intervalMs, 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await registeredTask.run();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [{}]);
|
||||||
|
});
|
||||||
@@ -23,6 +23,18 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
|
|||||||
playerLabel: 'Player Alpha'
|
playerLabel: 'Player Alpha'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Media sync task',
|
||||||
|
key: 'media-sync:slide:update:2:lzstealthcom',
|
||||||
|
category: 'Refresh',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: '2026-08-04T11:15:00.000Z',
|
||||||
|
startedAt: '2026-08-04T11:16:00.000Z',
|
||||||
|
finishedAt: '2026-08-04T11:17:00.000Z',
|
||||||
|
metadata: {
|
||||||
|
playerLabel: 'lzstealthcom'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Older task',
|
title: 'Older task',
|
||||||
key: 'older-key',
|
key: 'older-key',
|
||||||
@@ -73,6 +85,8 @@ 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-confirm-message="Clear finished background tasks\?"/);
|
||||||
assert.match(queueHtml, /data-local-datetime-format="24h"/);
|
assert.match(queueHtml, /data-local-datetime-format="24h"/);
|
||||||
assert.match(queueHtml, /Player Alpha:secret-key/);
|
assert.match(queueHtml, /Player Alpha:secret-key/);
|
||||||
|
assert.match(queueHtml, /lzstealthcom:media-sync:slide:update:2/);
|
||||||
|
assert.doesNotMatch(queueHtml, /lzstealthcom:media-sync:slide:update:2:lzstealthcom/);
|
||||||
assert.doesNotMatch(queueKeySearchHtml, /Example task/);
|
assert.doesNotMatch(queueKeySearchHtml, /Example task/);
|
||||||
assert.match(queueDateSearchHtml, /Example task/);
|
assert.match(queueDateSearchHtml, /Example task/);
|
||||||
assert.match(scheduledHtml, /data-local-datetime-format="24h"/);
|
assert.match(scheduledHtml, /data-local-datetime-format="24h"/);
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
|
const registerScreenCommandRoutes = require('../src/web/routes/admin/client-commands');
|
||||||
|
|
||||||
|
function createAppHarness() {
|
||||||
|
const handlers = {};
|
||||||
|
const app = {
|
||||||
|
post(path, ...routeHandlers) {
|
||||||
|
handlers[path] = routeHandlers;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { app, handlers };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('client command route forwards all screens commands', async () => {
|
||||||
|
const { app, handlers } = createAppHarness();
|
||||||
|
const calls = [];
|
||||||
|
registerScreenCommandRoutes(app, {
|
||||||
|
pool: {
|
||||||
|
async query(sql) {
|
||||||
|
calls.push({ kind: 'query', sql });
|
||||||
|
|
||||||
|
if (String(sql).includes('FROM d_screens') && String(sql).includes('ORDER BY slug ASC')) {
|
||||||
|
return [[{ slug: 'alpha' }, { slug: 'beta' }]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [[[]]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
async fetchPlayerRegistrations() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-a.example',
|
||||||
|
internal_base_url: 'http://player-a.internal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-b.example',
|
||||||
|
internal_base_url: 'http://player-b.internal'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
forwardPlayerCommand(screenSlug, commandPayload) {
|
||||||
|
calls.push({ kind: 'forwardPlayerCommand', screenSlug, commandPayload });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
forwardPlayerCommandToBaseUrl(baseUrl, screenSlug, commandPayload) {
|
||||||
|
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, screenSlug, commandPayload });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
getScreenConnections: async (slug) => {
|
||||||
|
if (slug === 'alpha') {
|
||||||
|
return { connections: [{ playerPublicBaseUrl: 'http://player-a.example' }] };
|
||||||
|
}
|
||||||
|
if (slug === 'beta') {
|
||||||
|
return { connections: [{ playerPublicBaseUrl: 'http://player-b.example' }] };
|
||||||
|
}
|
||||||
|
return { connections: [] };
|
||||||
|
},
|
||||||
|
isClientNameAvailable() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
withClientNameReservation() {
|
||||||
|
throw new Error('withClientNameReservation should not be called for __all__');
|
||||||
|
},
|
||||||
|
broadcastDashboardState() {
|
||||||
|
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(payload) {
|
||||||
|
this.body = payload;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await routeHandlers[1]({
|
||||||
|
params: { slug: '__all__' },
|
||||||
|
body: { command: 'reload' },
|
||||||
|
query: {},
|
||||||
|
currentUser: { id: 1 }
|
||||||
|
}, 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(response.body.sent, 2);
|
||||||
|
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl), ['http://player-a.internal', 'http://player-b.internal']);
|
||||||
|
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client command route forwards a single screen command', async () => {
|
||||||
|
const { app, handlers } = createAppHarness();
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
registerScreenCommandRoutes(app, {
|
||||||
|
pool: {
|
||||||
|
async query(sql) {
|
||||||
|
if (String(sql).includes('SELECT id, name, slug FROM d_screens WHERE slug = ?')) {
|
||||||
|
return [[{ id: 7, name: 'Demo Lobby', slug: 'demo-lobby' }]];
|
||||||
|
}
|
||||||
|
if (String(sql).includes('SELECT client_name')) {
|
||||||
|
return [[null]];
|
||||||
|
}
|
||||||
|
if (String(sql).includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
|
||||||
|
return [[[]]];
|
||||||
|
}
|
||||||
|
if (String(sql).includes('UPDATE d_onboarding_devices')) {
|
||||||
|
return [{ affectedRows: 0 }];
|
||||||
|
}
|
||||||
|
if (String(sql).includes('FROM d_onboarding_devices d')) {
|
||||||
|
return [[[]]];
|
||||||
|
}
|
||||||
|
return [[[]]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
async fetchPlayerRegistrations() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
forwardPlayerCommand(screenSlug, commandPayload, connectionId) {
|
||||||
|
calls.push({ kind: 'forward', screenSlug, commandPayload, connectionId });
|
||||||
|
return Promise.resolve({ ok: true, sent: 1 });
|
||||||
|
},
|
||||||
|
forwardPlayerCommandToBaseUrl() {
|
||||||
|
throw new Error('forwardPlayerCommandToBaseUrl should not be called for this path');
|
||||||
|
},
|
||||||
|
getScreenConnections() {
|
||||||
|
return Promise.resolve({ connections: [] });
|
||||||
|
},
|
||||||
|
isClientNameAvailable() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
withClientNameReservation() {
|
||||||
|
throw new Error('withClientNameReservation should not be called for reload');
|
||||||
|
},
|
||||||
|
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(payload) {
|
||||||
|
this.body = payload;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await routeHandlers[1]({
|
||||||
|
params: { slug: 'demo-lobby' },
|
||||||
|
body: { command: 'reload' },
|
||||||
|
query: {},
|
||||||
|
currentUser: { id: 1 }
|
||||||
|
}, response, () => {});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].kind, 'forward');
|
||||||
|
assert.equal(calls[0].screenSlug, 'demo-lobby');
|
||||||
|
assert.deepEqual(calls[0].commandPayload, { command: 'reload' });
|
||||||
|
assert.equal(calls[0].connectionId, undefined);
|
||||||
|
assert.equal(response.body.ok, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
|
const { registerInitialFontSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/font-sync');
|
||||||
|
|
||||||
|
test('initial font sync queues a separate task for each live player', async () => {
|
||||||
|
const queuedTasks = [];
|
||||||
|
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
|
||||||
|
const staleLastSeenAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
await registerInitialFontSyncTask({
|
||||||
|
pool: {
|
||||||
|
async query() {
|
||||||
|
return [[
|
||||||
|
{
|
||||||
|
identifier: 'player-one',
|
||||||
|
public_base_url: 'https://player-one.example',
|
||||||
|
internal_base_url: 'http://player-one:8081',
|
||||||
|
last_seen_at: liveLastSeenAt
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: 'player-two',
|
||||||
|
public_base_url: 'https://player-two.example',
|
||||||
|
internal_base_url: 'http://player-two:8081',
|
||||||
|
last_seen_at: liveLastSeenAt
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: 'player-stale',
|
||||||
|
public_base_url: 'https://player-stale.example',
|
||||||
|
internal_base_url: 'http://player-stale:8081',
|
||||||
|
last_seen_at: staleLastSeenAt
|
||||||
|
}
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backgroundTaskQueue: {
|
||||||
|
async enqueueTask(definition) {
|
||||||
|
queuedTasks.push(definition);
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mediaDir: 'e:/Projects Git/pulse-signage/media',
|
||||||
|
uploadSyncService: {
|
||||||
|
async getPlayerTaskMetadata() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(queuedTasks.length, 2);
|
||||||
|
assert.deepEqual(queuedTasks.map(function (task) {
|
||||||
|
return task.metadata.playerIdentifier;
|
||||||
|
}).sort(), ['player-one', 'player-two']);
|
||||||
|
assert.deepEqual(queuedTasks.map(function (task) {
|
||||||
|
return task.payload.playerIdentifier;
|
||||||
|
}).sort(), ['player-one', 'player-two']);
|
||||||
|
assert.ok(queuedTasks.every(function (task) {
|
||||||
|
return task.key === 'initial-font-sync' && task.title === 'Initial font sync' && task.taskType === 'font-sync';
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
|
const { registerInitialMediaSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/media-sync');
|
||||||
|
|
||||||
|
test('initial media sync queues a separate task for each live player', async () => {
|
||||||
|
const queuedTasks = [];
|
||||||
|
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
|
||||||
|
const staleLastSeenAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
await registerInitialMediaSyncTask({
|
||||||
|
pool: {
|
||||||
|
async query() {
|
||||||
|
return [[
|
||||||
|
{
|
||||||
|
identifier: 'player-one',
|
||||||
|
public_base_url: 'https://player-one.example',
|
||||||
|
internal_base_url: 'http://player-one:8081',
|
||||||
|
last_seen_at: liveLastSeenAt
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: 'player-two',
|
||||||
|
public_base_url: 'https://player-two.example',
|
||||||
|
internal_base_url: 'http://player-two:8081',
|
||||||
|
last_seen_at: liveLastSeenAt
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: 'player-stale',
|
||||||
|
public_base_url: 'https://player-stale.example',
|
||||||
|
internal_base_url: 'http://player-stale:8081',
|
||||||
|
last_seen_at: staleLastSeenAt
|
||||||
|
}
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backgroundTaskQueue: {
|
||||||
|
async enqueueTask(definition) {
|
||||||
|
queuedTasks.push(definition);
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mediaDir: 'e:/Projects Git/pulse-signage/media',
|
||||||
|
uploadSyncService: {
|
||||||
|
async getPlayerTaskMetadata() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(queuedTasks.length, 2);
|
||||||
|
assert.deepEqual(queuedTasks.map(function (task) {
|
||||||
|
return task.metadata.playerIdentifier;
|
||||||
|
}).sort(), ['player-one', 'player-two']);
|
||||||
|
assert.deepEqual(queuedTasks.map(function (task) {
|
||||||
|
return task.payload.playerIdentifier;
|
||||||
|
}).sort(), ['player-one', 'player-two']);
|
||||||
|
assert.ok(queuedTasks.every(function (task) {
|
||||||
|
return task.key === 'initial-media-sync' && task.title === 'Initial media sync' && task.taskType === 'media-sync';
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
|
const { createRequestAuthHeaders } = require('../src/request-auth');
|
||||||
|
const registerInternalSyncRoutes = require('../src/web/routes/internal/sync');
|
||||||
|
|
||||||
|
test('player media sync requests are queued as background tasks', async () => {
|
||||||
|
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
|
||||||
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'test-shared-secret';
|
||||||
|
|
||||||
|
const queuedTasks = [];
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
registerInternalSyncRoutes(app, {
|
||||||
|
backgroundTaskQueue: {
|
||||||
|
async enqueueTask(definition) {
|
||||||
|
queuedTasks.push(definition);
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
key: definition.key,
|
||||||
|
taskType: definition.taskType,
|
||||||
|
title: definition.title,
|
||||||
|
category: definition.category,
|
||||||
|
status: 'queued',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
startedAt: '',
|
||||||
|
finishedAt: '',
|
||||||
|
errorMessage: '',
|
||||||
|
attempts: 0,
|
||||||
|
metadata: definition.metadata,
|
||||||
|
payload: definition.payload
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
uploadSyncService: {},
|
||||||
|
mediaDir: 'e:/Projects Git/pulse-signage/media'
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(function (error, _req, res, _next) {
|
||||||
|
res.status(error.statusCode || 500).json({ error: String(error && error.message ? error.message : error) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = app.listen(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const body = {
|
||||||
|
deviceId: 'player-remote',
|
||||||
|
playerPublicBaseUrl: 'https://player.example.test',
|
||||||
|
playerInternalBaseUrl: 'http://player-bridge:8090'
|
||||||
|
};
|
||||||
|
const headers = Object.assign({
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}, createRequestAuthHeaders({
|
||||||
|
method: 'POST',
|
||||||
|
pathname: '/api/internal/sync/player-media',
|
||||||
|
body: body
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${address.port}/api/internal/sync/player-media`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 202);
|
||||||
|
assert.equal(queuedTasks.length, 1);
|
||||||
|
assert.equal(queuedTasks[0].key, 'player-media-sync');
|
||||||
|
assert.equal(queuedTasks[0].taskType, 'media-sync');
|
||||||
|
assert.equal(queuedTasks[0].payload.mode, 'initial');
|
||||||
|
assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote');
|
||||||
|
assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote');
|
||||||
|
} finally {
|
||||||
|
await new Promise(function (resolve) {
|
||||||
|
server.close(resolve);
|
||||||
|
});
|
||||||
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('player font sync requests are queued as background tasks', async () => {
|
||||||
|
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
|
||||||
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'test-shared-secret';
|
||||||
|
|
||||||
|
const queuedTasks = [];
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
registerInternalSyncRoutes(app, {
|
||||||
|
backgroundTaskQueue: {
|
||||||
|
async enqueueTask(definition) {
|
||||||
|
queuedTasks.push(definition);
|
||||||
|
return {
|
||||||
|
id: 43,
|
||||||
|
key: definition.key,
|
||||||
|
taskType: definition.taskType,
|
||||||
|
title: definition.title,
|
||||||
|
category: definition.category,
|
||||||
|
status: 'queued',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
startedAt: '',
|
||||||
|
finishedAt: '',
|
||||||
|
errorMessage: '',
|
||||||
|
attempts: 0,
|
||||||
|
metadata: definition.metadata,
|
||||||
|
payload: definition.payload
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
uploadSyncService: {},
|
||||||
|
mediaDir: 'e:/Projects Git/pulse-signage/media'
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(function (error, _req, res, _next) {
|
||||||
|
res.status(error.statusCode || 500).json({ error: String(error && error.message ? error.message : error) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = app.listen(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const body = {
|
||||||
|
deviceId: 'player-remote',
|
||||||
|
playerPublicBaseUrl: 'https://player.example.test',
|
||||||
|
playerInternalBaseUrl: 'http://player-bridge:8090'
|
||||||
|
};
|
||||||
|
const headers = Object.assign({
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}, createRequestAuthHeaders({
|
||||||
|
method: 'POST',
|
||||||
|
pathname: '/api/internal/sync/player-font',
|
||||||
|
body: body
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${address.port}/api/internal/sync/player-font`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 202);
|
||||||
|
assert.equal(queuedTasks.length, 1);
|
||||||
|
assert.equal(queuedTasks[0].key, 'player-font-sync');
|
||||||
|
assert.equal(queuedTasks[0].taskType, 'font-sync');
|
||||||
|
assert.equal(queuedTasks[0].payload.mode, 'initial');
|
||||||
|
assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote');
|
||||||
|
assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote');
|
||||||
|
} finally {
|
||||||
|
await new Promise(function (resolve) {
|
||||||
|
server.close(resolve);
|
||||||
|
});
|
||||||
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
|
||||||
|
}
|
||||||
|
});
|
||||||
+107
-51
@@ -5,7 +5,7 @@ require('../src/common');
|
|||||||
|
|
||||||
const { createPlayerActionService } = require('../src/web/lib/player-actions');
|
const { createPlayerActionService } = require('../src/web/lib/player-actions');
|
||||||
|
|
||||||
test('player actions prefer the exact configured player registration over a remote FQDN row', async () => {
|
test('player actions use the configured bridge url for commands', async () => {
|
||||||
const fetchCalls = [];
|
const fetchCalls = [];
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
global.fetch = async function (url, init) {
|
global.fetch = async function (url, init) {
|
||||||
@@ -29,20 +29,7 @@ test('player actions prefer the exact configured player registration over a remo
|
|||||||
|
|
||||||
const playerActionService = createPlayerActionService({
|
const playerActionService = createPlayerActionService({
|
||||||
common: {},
|
common: {},
|
||||||
pool: {
|
playerInternalBaseUrl: 'http://player:8081'
|
||||||
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;
|
const originalIdentifier = process.env.PLAYER_IDENTIFIER;
|
||||||
@@ -60,12 +47,11 @@ test('player actions prefer the exact configured player registration over a remo
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('player actions merge screen connections from every recent player registration', async () => {
|
test('player actions read screen connections from the bridge base url', async () => {
|
||||||
const fetchCalls = [];
|
const fetchCalls = [];
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
global.fetch = async function (url, init) {
|
global.fetch = async function (url, init) {
|
||||||
fetchCalls.push({ url, init });
|
fetchCalls.push({ url, init });
|
||||||
if (String(url).includes('player-a.example')) {
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -74,25 +60,7 @@ test('player actions merge screen connections from every recent player registrat
|
|||||||
return {
|
return {
|
||||||
screenSlug: 'demo',
|
screenSlug: 'demo',
|
||||||
connections: [
|
connections: [
|
||||||
{ id: 'a-1', playerPublicBaseUrl: 'http://player-a.example' }
|
{ id: 'bridge-1', playerPublicBaseUrl: 'http://bridge.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' }
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -106,28 +74,116 @@ test('player actions merge screen connections from every recent player registrat
|
|||||||
common: {},
|
common: {},
|
||||||
pool: {
|
pool: {
|
||||||
async query() {
|
async query() {
|
||||||
return [[
|
return [[[]]];
|
||||||
{
|
}
|
||||||
identifier: 'player-a',
|
|
||||||
public_base_url: 'http://player-a.example',
|
|
||||||
last_seen_at: new Date().toISOString()
|
|
||||||
},
|
},
|
||||||
{
|
playerInternalBaseUrl: 'http://bridge.example'
|
||||||
identifier: 'player-b',
|
|
||||||
public_base_url: 'http://player-b.example',
|
|
||||||
last_seen_at: new Date().toISOString()
|
|
||||||
}
|
|
||||||
]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await playerActionService.getScreenConnections('demo');
|
const response = await playerActionService.getScreenConnections('demo');
|
||||||
|
|
||||||
|
assert.equal(response.count, 1);
|
||||||
|
assert.deepEqual(response.connections.map(function (connection) { return connection.id; }), ['bridge-1']);
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
assert.equal(fetchCalls[0].url, 'http://bridge.example/api/screens/demo/connections');
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('player actions merge bridge and player screen connections', async () => {
|
||||||
|
const fetchCalls = [];
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
global.fetch = async function (url, init) {
|
||||||
|
fetchCalls.push({ url, init });
|
||||||
|
if (String(url).indexOf('player-bridge:8090') !== -1) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: { get() { return null; } },
|
||||||
|
async json() {
|
||||||
|
return {
|
||||||
|
screenSlug: 'demo',
|
||||||
|
connections: [
|
||||||
|
{ id: 'bridge-1', playerPublicBaseUrl: 'http://bridge.example' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async text() {
|
||||||
|
return JSON.stringify({ ok: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: { get() { return null; } },
|
||||||
|
async json() {
|
||||||
|
return {
|
||||||
|
screenSlug: 'demo',
|
||||||
|
connections: [
|
||||||
|
{ id: 'player-1', playerPublicBaseUrl: 'http://player.example' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async text() {
|
||||||
|
return JSON.stringify({ ok: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const playerActionService = createPlayerActionService({
|
||||||
|
common: {},
|
||||||
|
playerInternalBaseUrl: 'http://player:8081',
|
||||||
|
bridgeInternalBaseUrl: 'http://player-bridge:8090'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await playerActionService.getScreenConnections('demo');
|
||||||
|
|
||||||
|
assert.equal(fetchCalls.length, 2);
|
||||||
|
assert.deepEqual(fetchCalls.map(function (call) { return call.url; }).sort(), [
|
||||||
|
'http://player-bridge:8090/api/screens/demo/connections',
|
||||||
|
'http://player:8081/api/screens/demo/connections'
|
||||||
|
]);
|
||||||
assert.equal(response.count, 2);
|
assert.equal(response.count, 2);
|
||||||
assert.deepEqual(response.connections.map(function (connection) { return connection.id; }), ['a-1', 'b-1']);
|
assert.deepEqual(response.connections.map(function (connection) { return connection.id; }).sort(), ['bridge-1', 'player-1']);
|
||||||
assert.equal(fetchCalls.length, 2);
|
} finally {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('player actions send device commands through the bridge base url', 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, sent: true };
|
||||||
|
},
|
||||||
|
async text() {
|
||||||
|
return JSON.stringify({ ok: true, sent: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const playerActionService = createPlayerActionService({
|
||||||
|
common: {},
|
||||||
|
bridgeInternalBaseUrl: 'http://player-bridge:8090'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await playerActionService.forwardPlayerCommandToDevice('device-123', { command: 'pause' });
|
||||||
|
|
||||||
|
assert.deepEqual(response, { ok: true, sent: true });
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
assert.equal(fetchCalls[0].url, 'http://player-bridge:8090/api/players/device-123/commands');
|
||||||
} finally {
|
} finally {
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ const assert = require('node:assert/strict');
|
|||||||
|
|
||||||
require('../src/common');
|
require('../src/common');
|
||||||
|
|
||||||
const originalWebBaseUrl = process.env.WEB_BASE_URL;
|
const originalWebBaseUrl = process.env.WEB_INTERNAL_URL;
|
||||||
const { resolveWebBaseUrl, resolveScreenCommandTargets } = require('../src/player-bridge/index');
|
const { resolveWebBaseUrl, resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl, resolvePlayerSocketForDeviceId } = require('../src/player-bridge/index');
|
||||||
|
|
||||||
test.after(() => {
|
test.after(() => {
|
||||||
if (originalWebBaseUrl === undefined) {
|
if (originalWebBaseUrl === undefined) {
|
||||||
delete process.env.WEB_BASE_URL;
|
delete process.env.WEB_INTERNAL_URL;
|
||||||
} else {
|
} else {
|
||||||
process.env.WEB_BASE_URL = originalWebBaseUrl;
|
process.env.WEB_INTERNAL_URL = originalWebBaseUrl;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveWebBaseUrl prefers WEB_BASE_URL', () => {
|
test('resolveWebBaseUrl prefers WEB_INTERNAL_URL', () => {
|
||||||
process.env.WEB_BASE_URL = 'https://web.example.test/app/';
|
process.env.WEB_INTERNAL_URL = 'https://web.example.test/app/';
|
||||||
|
|
||||||
const resolved = resolveWebBaseUrl({
|
const resolved = resolveWebBaseUrl({
|
||||||
headers: {
|
headers: {
|
||||||
@@ -30,7 +30,7 @@ test('resolveWebBaseUrl prefers WEB_BASE_URL', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('resolveWebBaseUrl keeps external https hosts on the default port', () => {
|
test('resolveWebBaseUrl keeps external https hosts on the default port', () => {
|
||||||
delete process.env.WEB_BASE_URL;
|
delete process.env.WEB_INTERNAL_URL;
|
||||||
|
|
||||||
const resolved = resolveWebBaseUrl({
|
const resolved = resolveWebBaseUrl({
|
||||||
headers: {
|
headers: {
|
||||||
@@ -61,3 +61,42 @@ test('resolveScreenCommandTargets only returns open sockets for the requested sc
|
|||||||
return target.deviceId;
|
return target.deviceId;
|
||||||
}), ['player-a']);
|
}), ['player-a']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveScreenCommandTargets falls back to a single open socket when the screen map is empty', () => {
|
||||||
|
const playerSockets = new Map([
|
||||||
|
['player-a', { readyState: 1, playerDeviceId: 'player-a' }]
|
||||||
|
]);
|
||||||
|
const screenPlayerDeviceIds = new Map();
|
||||||
|
|
||||||
|
const targets = resolveScreenCommandTargets('demo-conference', playerSockets, screenPlayerDeviceIds);
|
||||||
|
|
||||||
|
assert.deepEqual(targets.map(function (target) {
|
||||||
|
return target.deviceId;
|
||||||
|
}), ['player-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSnapshotUpstreamBaseUrl prefers a local internal player url', () => {
|
||||||
|
assert.equal(resolveSnapshotUpstreamBaseUrl({
|
||||||
|
public_base_url: 'https://pulse-dev-player.lzstealth.com',
|
||||||
|
internal_base_url: 'http://player-dev:8081'
|
||||||
|
}), 'http://player-dev:8081');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSnapshotUpstreamBaseUrl falls back to the public player url for remote players', () => {
|
||||||
|
assert.equal(resolveSnapshotUpstreamBaseUrl({
|
||||||
|
public_base_url: 'https://remote-player.example',
|
||||||
|
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
|
||||||
|
}), 'https://remote-player.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolvePlayerSocketForDeviceId only returns the exact connected player socket', () => {
|
||||||
|
const playerSocketA = { readyState: 1 };
|
||||||
|
const playerSocketB = { readyState: 1 };
|
||||||
|
const playerSockets = new Map([
|
||||||
|
['player-a', playerSocketA],
|
||||||
|
['player-b', playerSocketB]
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(resolvePlayerSocketForDeviceId(playerSockets, 'player-b'), playerSocketB);
|
||||||
|
assert.equal(resolvePlayerSocketForDeviceId(playerSockets, 'player-missing'), null);
|
||||||
|
});
|
||||||
@@ -108,7 +108,7 @@ function registerThinClientRoutes(fetchImpl) {
|
|||||||
return 'form';
|
return 'form';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
thinClientBaseUrl: 'http://bridge.test',
|
bridgeBaseUrl: 'http://bridge.test',
|
||||||
playerPublicBaseUrl: 'http://public.test'
|
playerPublicBaseUrl: 'http://public.test'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ function loadScript(scriptPath, sandbox) {
|
|||||||
vm.runInNewContext(source, sandbox, { filename: scriptPath });
|
vm.runInNewContext(source, sandbox, { filename: scriptPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadHtmlScript(scriptPath, sandbox) {
|
||||||
|
const source = fs.readFileSync(scriptPath, 'utf8').match(/<script>([\s\S]*)<\/script>/);
|
||||||
|
if (!source) {
|
||||||
|
throw new Error(`Unable to extract script body from ${scriptPath}`);
|
||||||
|
}
|
||||||
|
const script = source[1].trim();
|
||||||
|
vm.runInNewContext(script, sandbox, { filename: scriptPath });
|
||||||
|
}
|
||||||
|
|
||||||
test('webpage preloading only targets the next slide', () => {
|
test('webpage preloading only targets the next slide', () => {
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
window: null,
|
window: null,
|
||||||
@@ -96,3 +105,194 @@ test('rtmp warmups only target the next slide', () => {
|
|||||||
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 1).map((slide) => slide.id).join(','), '3');
|
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(','), '');
|
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 2).map((slide) => slide.id).join(','), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('command client ids stay scoped to the screen session', () => {
|
||||||
|
const sessionStorage = (() => {
|
||||||
|
const values = new Map();
|
||||||
|
return {
|
||||||
|
getItem(key) {
|
||||||
|
return values.has(key) ? values.get(key) : null;
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
values.set(String(key), String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const localStorage = (() => {
|
||||||
|
const values = new Map();
|
||||||
|
return {
|
||||||
|
getItem(key) {
|
||||||
|
return values.has(key) ? values.get(key) : null;
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
values.set(String(key), String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const sandbox = {
|
||||||
|
window: null,
|
||||||
|
Date,
|
||||||
|
Array,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
Boolean,
|
||||||
|
Object,
|
||||||
|
Math,
|
||||||
|
console,
|
||||||
|
crypto: {
|
||||||
|
randomUUID() {
|
||||||
|
return 'tab-session-client-id';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
localStorage,
|
||||||
|
sessionStorage,
|
||||||
|
commandClientId: null,
|
||||||
|
commandClientStorageKey: 'pulse-command-client-id',
|
||||||
|
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);
|
||||||
|
|
||||||
|
assert.equal(sandbox.getCommandClientId(), 'tab-session-client-id');
|
||||||
|
assert.equal(sessionStorage.getItem('pulse-command-client-id'), 'tab-session-client-id');
|
||||||
|
assert.equal(localStorage.getItem('pulse-command-client-id'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('player client names stay scoped to the tab session', () => {
|
||||||
|
const sessionStorage = (() => {
|
||||||
|
const values = new Map([['pulse-signage-player-client-name', 'tab-only-name']]);
|
||||||
|
return {
|
||||||
|
getItem(key) {
|
||||||
|
return values.has(key) ? values.get(key) : null;
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
values.set(String(key), String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const localStorage = (() => {
|
||||||
|
const values = new Map([['pulse-signage-player-client-name', 'shared-name']]);
|
||||||
|
return {
|
||||||
|
getItem(key) {
|
||||||
|
return values.has(key) ? values.get(key) : null;
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
values.set(String(key), String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const sandbox = {
|
||||||
|
window: null,
|
||||||
|
Date,
|
||||||
|
Array,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
Boolean,
|
||||||
|
Object,
|
||||||
|
Math,
|
||||||
|
console,
|
||||||
|
sessionStorage,
|
||||||
|
localStorage,
|
||||||
|
WebSocket: { OPEN: 1 },
|
||||||
|
sendCommandState() {}
|
||||||
|
};
|
||||||
|
sandbox.window = sandbox;
|
||||||
|
|
||||||
|
loadHtmlScript(path.join(__dirname, '..', 'src', 'player', 'player-client-name.script.html'), sandbox);
|
||||||
|
|
||||||
|
assert.equal(sandbox.getOnboardingClientName(), 'tab-only-name');
|
||||||
|
assert.equal(localStorage.getItem('pulse-signage-player-client-name'), 'shared-name');
|
||||||
|
|
||||||
|
sandbox.applyOnboardingClientName('Renamed Tab Client', null);
|
||||||
|
assert.equal(sessionStorage.getItem('pulse-signage-player-client-name'), 'Renamed Tab Client');
|
||||||
|
assert.equal(localStorage.getItem('pulse-signage-player-client-name'), 'shared-name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onboarding device ids stay scoped to the tab session', () => {
|
||||||
|
const sessionStorage = (() => {
|
||||||
|
const values = new Map();
|
||||||
|
return {
|
||||||
|
getItem(key) {
|
||||||
|
return values.has(key) ? values.get(key) : null;
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
values.set(String(key), String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const localStorage = (() => {
|
||||||
|
const values = new Map([['pulse-signage-player-device-id', 'shared-device-id']]);
|
||||||
|
return {
|
||||||
|
getItem(key) {
|
||||||
|
return values.has(key) ? values.get(key) : null;
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
values.set(String(key), String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const sandbox = {
|
||||||
|
window: null,
|
||||||
|
Date,
|
||||||
|
Array,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
Boolean,
|
||||||
|
Object,
|
||||||
|
Math,
|
||||||
|
console,
|
||||||
|
sessionStorage,
|
||||||
|
localStorage,
|
||||||
|
crypto: {
|
||||||
|
randomUUID() {
|
||||||
|
return 'tab-device-id';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetch() {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json() {
|
||||||
|
return Promise.resolve({ onboarded: false, screens: [] });
|
||||||
|
},
|
||||||
|
text() {
|
||||||
|
return Promise.resolve('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
getElementById() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
createElement() {
|
||||||
|
return { appendChild() {}, removeChild() {} };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
replace() {}
|
||||||
|
},
|
||||||
|
setInterval() {
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
clearInterval() {}
|
||||||
|
};
|
||||||
|
sandbox.window = sandbox;
|
||||||
|
|
||||||
|
loadHtmlScript(path.join(__dirname, '..', 'src', 'player', 'onboarding', 'player-onboarding-landing.script.html'), sandbox);
|
||||||
|
|
||||||
|
assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'tab-device-id');
|
||||||
|
assert.equal(localStorage.getItem('pulse-signage-player-device-id'), 'shared-device-id');
|
||||||
|
});
|
||||||
@@ -97,6 +97,46 @@ function createPlayerRouteOptions(overrides) {
|
|||||||
}, overrides);
|
}, overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('screen route reports the request origin for the player public base url', async () => {
|
||||||
|
const { app, handlers } = createAppAndHandlers();
|
||||||
|
let reportedBaseUrl = null;
|
||||||
|
|
||||||
|
registerPlayerRoutes(app, {
|
||||||
|
app,
|
||||||
|
pool: { async query() { return [[{ id: 1 }]]; } },
|
||||||
|
common: { renderPlayerPage() { return '<html></html>'; } },
|
||||||
|
...createPlayerRouteOptions({
|
||||||
|
playerPlaylistService: {
|
||||||
|
async buildScreenPlaylist() {
|
||||||
|
return {
|
||||||
|
screen: { id: 7, slug: 'test2' },
|
||||||
|
playlist: null,
|
||||||
|
slides: [],
|
||||||
|
rssFeeds: [],
|
||||||
|
apiSources: [],
|
||||||
|
timetableGroups: [],
|
||||||
|
revision: 'abc123'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPlayerPublicBaseUrl(value) {
|
||||||
|
reportedBaseUrl = value;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = handlers['/screen/:slug'];
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
params: { slug: 'test2' },
|
||||||
|
headers: { host: '127.0.0.1:8080' },
|
||||||
|
socket: { encrypted: false }
|
||||||
|
}, res);
|
||||||
|
|
||||||
|
assert.equal(reportedBaseUrl, 'http://127.0.0.1:8080');
|
||||||
|
});
|
||||||
|
|
||||||
test('screen route falls back to offline rendering when playlist build fails', async () => {
|
test('screen route falls back to offline rendering when playlist build fails', async () => {
|
||||||
const { app, handlers } = createAppAndHandlers();
|
const { app, handlers } = createAppAndHandlers();
|
||||||
const renderCalls = [];
|
const renderCalls = [];
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ function waitFor(predicate, timeoutMs = 1000) {
|
|||||||
test('player runtime snapshots websocket state and checks live names', async () => {
|
test('player runtime snapshots websocket state and checks live names', async () => {
|
||||||
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
||||||
|
|
||||||
const runtime = createPlayerRuntime({ pool: null });
|
const snapshotNotifications = [];
|
||||||
|
const runtime = createPlayerRuntime({
|
||||||
|
pool: null,
|
||||||
|
notifySnapshot(snapshot) {
|
||||||
|
snapshotNotifications.push(snapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
const server = http.createServer();
|
const server = http.createServer();
|
||||||
runtime.installWebsocket(server);
|
runtime.installWebsocket(server);
|
||||||
|
|
||||||
@@ -70,6 +76,7 @@ test('player runtime snapshots websocket state and checks live names', async ()
|
|||||||
const snapshot = runtime.snapshotConnections('test2')[0];
|
const snapshot = runtime.snapshotConnections('test2')[0];
|
||||||
return snapshot && snapshot.clientName === 'Lobby Player' ? snapshot : null;
|
return snapshot && snapshot.clientName === 'Lobby Player' ? snapshot : null;
|
||||||
});
|
});
|
||||||
|
await waitFor(() => snapshotNotifications.length > 0);
|
||||||
const snapshot = runtime.snapshotConnections('test2')[0];
|
const snapshot = runtime.snapshotConnections('test2')[0];
|
||||||
|
|
||||||
assert.equal(snapshot.clientName, 'Lobby Player');
|
assert.equal(snapshot.clientName, 'Lobby Player');
|
||||||
@@ -78,6 +85,8 @@ test('player runtime snapshots websocket state and checks live names', async ()
|
|||||||
assert.equal(snapshot.paused, true);
|
assert.equal(snapshot.paused, true);
|
||||||
assert.equal(snapshot.currentSlideId, 9);
|
assert.equal(snapshot.currentSlideId, 9);
|
||||||
assert.equal(snapshot.currentSlideTitle, 'Intro');
|
assert.equal(snapshot.currentSlideTitle, 'Intro');
|
||||||
|
assert.equal(snapshotNotifications[0].slug, 'test2');
|
||||||
|
assert.equal(snapshotNotifications[0].connections.length, 1);
|
||||||
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'other-device'), false);
|
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'other-device'), false);
|
||||||
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'device123'), true);
|
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'device123'), true);
|
||||||
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Other Name', 'device123'), true);
|
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Other Name', 'device123'), true);
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ const fs = require('node:fs');
|
|||||||
|
|
||||||
const rbacPermissionsScript = fs.readFileSync(require.resolve('../src/web/public/js/rbac-permissions.js'), 'utf8');
|
const rbacPermissionsScript = fs.readFileSync(require.resolve('../src/web/public/js/rbac-permissions.js'), 'utf8');
|
||||||
const rbacFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/rbac/form.hbs'), 'utf8');
|
const rbacFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/rbac/form.hbs'), 'utf8');
|
||||||
|
const { buildPermissionSections } = require('../src/web/routes/settings/rbac/form-view-model');
|
||||||
const rbacFormViewModel = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/form-view-model.js'), 'utf8');
|
const rbacFormViewModel = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/form-view-model.js'), 'utf8');
|
||||||
const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8');
|
const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8');
|
||||||
|
|
||||||
test('rbac form exposes bulk permission controls', () => {
|
test('rbac form exposes bulk permission controls', () => {
|
||||||
|
assert.match(rbacFormTemplate, /name="permissions_present" value="1"/);
|
||||||
assert.match(rbacFormTemplate, /accordion accordion-flush/);
|
assert.match(rbacFormTemplate, /accordion accordion-flush/);
|
||||||
assert.match(rbacFormTemplate, /card card-outline card-secondary overflow-hidden/);
|
assert.match(rbacFormTemplate, /card card-outline card-secondary overflow-hidden/);
|
||||||
assert.match(rbacFormTemplate, /accordion-item overflow-hidden" data-permission-section/);
|
assert.match(rbacFormTemplate, /accordion-item overflow-hidden" data-permission-section/);
|
||||||
assert.match(rbacFormTemplate, /accordion-body p-0/);
|
assert.match(rbacFormTemplate, /accordion-body p-0/);
|
||||||
|
assert.ok(!rbacFormTemplate.includes('data-bs-parent="#role-permissions-accordion"'));
|
||||||
assert.match(rbacFormTemplate, /table-layout: fixed; width: 100%;/);
|
assert.match(rbacFormTemplate, /table-layout: fixed; width: 100%;/);
|
||||||
assert.match(rbacFormTemplate, /<col style="width: 5\.5rem;" \/>/);
|
assert.match(rbacFormTemplate, /<col style="width: 5\.5rem;" \/>/);
|
||||||
assert.match(rbacFormTemplate, /class="text-center"/);
|
assert.match(rbacFormTemplate, /class="text-center"/);
|
||||||
@@ -51,6 +54,45 @@ test('rbac permissions script supports bulk permission selection', () => {
|
|||||||
assert.ok(!rbacPermissionsScript.includes('data-permission-section-select-none'));
|
assert.ok(!rbacPermissionsScript.includes('data-permission-section-select-none'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rbac accordion sections follow source order', () => {
|
||||||
|
const sections = buildPermissionSections([
|
||||||
|
{
|
||||||
|
categoryName: 'Settings',
|
||||||
|
sectionIndex: 3,
|
||||||
|
sectionOrder: 40,
|
||||||
|
resourceIndex: 1,
|
||||||
|
resourceOrder: 20,
|
||||||
|
title: 'Later resource',
|
||||||
|
permissions: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryName: 'Main navigation',
|
||||||
|
sectionIndex: 0,
|
||||||
|
sectionOrder: 10,
|
||||||
|
resourceIndex: 1,
|
||||||
|
resourceOrder: 20,
|
||||||
|
title: 'Clients',
|
||||||
|
permissions: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoryName: 'Main navigation',
|
||||||
|
sectionIndex: 0,
|
||||||
|
sectionOrder: 10,
|
||||||
|
resourceIndex: 0,
|
||||||
|
resourceOrder: 10,
|
||||||
|
title: 'Dashboard',
|
||||||
|
permissions: []
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(sections.map(function (section) {
|
||||||
|
return section.title;
|
||||||
|
}), ['Main navigation', 'Settings']);
|
||||||
|
assert.deepEqual(sections[0].groups.map(function (group) {
|
||||||
|
return group.title;
|
||||||
|
}), ['Dashboard', 'Clients']);
|
||||||
|
});
|
||||||
|
|
||||||
test('rbac duplicate route exists', () => {
|
test('rbac duplicate route exists', () => {
|
||||||
const rbacRoutes = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8');
|
const rbacRoutes = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8');
|
||||||
const duplicateHelpers = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/duplicate.js'), 'utf8');
|
const duplicateHelpers = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/duplicate.js'), 'utf8');
|
||||||
|
|||||||
+187
-1
@@ -99,7 +99,7 @@ test('fqdn player registration wins over a local configured player target for me
|
|||||||
|
|
||||||
assert.equal(success, true);
|
assert.equal(success, true);
|
||||||
assert.equal(fetchCalls.length, 1);
|
assert.equal(fetchCalls.length, 1);
|
||||||
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin');
|
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin?deviceId=player-remote');
|
||||||
} finally {
|
} finally {
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||||
@@ -275,3 +275,189 @@ test('stale player registrations stop media sync retries and warnings', async ()
|
|||||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('slide update sync removes uploads that were removed from the slide on the player', async () => {
|
||||||
|
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-slide-remove-'));
|
||||||
|
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(uploadDir, 'uploads', 'keep.bin'), Buffer.from('keep'));
|
||||||
|
fs.writeFileSync(path.join(uploadDir, 'uploads', 'remove.bin'), Buffer.from('remove'));
|
||||||
|
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
|
||||||
|
|
||||||
|
const fetchCalls = [];
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
global.fetch = async function (url, init) {
|
||||||
|
fetchCalls.push({ url, method: init && init.method ? init.method : 'GET' });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: {
|
||||||
|
get() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async text() {
|
||||||
|
return JSON.stringify({ ok: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadSyncService = createUploadSyncService({
|
||||||
|
common: {
|
||||||
|
async fetchAdminData() {
|
||||||
|
return {
|
||||||
|
slides: [
|
||||||
|
{
|
||||||
|
content_json: JSON.stringify({
|
||||||
|
imageRegion: {
|
||||||
|
type: 'image',
|
||||||
|
value: '/media/uploads/keep.bin'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
],
|
||||||
|
templates: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pool: {
|
||||||
|
async query(sql, params) {
|
||||||
|
if (String(sql).includes('COUNT(*) AS ref_count')) {
|
||||||
|
return [[{
|
||||||
|
ref_count: params && params[0] === '/media/uploads/keep.bin' ? 1 : 0
|
||||||
|
}]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [[
|
||||||
|
{
|
||||||
|
identifier: 'player-one',
|
||||||
|
internal_base_url: 'http://player-one:8081',
|
||||||
|
last_seen_at: liveLastSeenAt
|
||||||
|
}
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
playerSnapshotCache: new Map(),
|
||||||
|
notifyPlayerScreens: async () => {},
|
||||||
|
backgroundTaskQueue: {
|
||||||
|
async enqueueTaskAndWait(definition) {
|
||||||
|
await uploadSyncService.runMediaSyncTask(definition.payload);
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadSyncService.syncPlaylistUploadsOnChange({
|
||||||
|
key: 'slide:update:123',
|
||||||
|
pool: {},
|
||||||
|
localUploadDir: uploadDir,
|
||||||
|
previousUploadRefs: ['/media/uploads/keep.bin', '/media/uploads/remove.bin'],
|
||||||
|
nextUploadRefs: ['/media/uploads/keep.bin']
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'keep.bin')), true);
|
||||||
|
assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'remove.bin')), false);
|
||||||
|
assert.equal(fetchCalls.filter(function (call) {
|
||||||
|
return call.method === 'PUT';
|
||||||
|
}).length, 1);
|
||||||
|
assert.equal(fetchCalls.filter(function (call) {
|
||||||
|
return call.method === 'DELETE';
|
||||||
|
}).length, 1);
|
||||||
|
assert.ok(fetchCalls.some(function (call) {
|
||||||
|
return call.method === 'DELETE' && call.url.includes('remove.bin');
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slide update sync queues one media task per live player and targets each player base url', async () => {
|
||||||
|
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-multi-'));
|
||||||
|
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
|
||||||
|
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
|
||||||
|
const staleLastSeenAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const fetchCalls = [];
|
||||||
|
const queuedTasks = [];
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let uploadSyncService;
|
||||||
|
uploadSyncService = createUploadSyncService({
|
||||||
|
common: {},
|
||||||
|
pool: {
|
||||||
|
async query() {
|
||||||
|
return [[
|
||||||
|
{
|
||||||
|
identifier: 'player-one',
|
||||||
|
internal_base_url: 'http://player-one:8081',
|
||||||
|
last_seen_at: liveLastSeenAt
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: 'player-two',
|
||||||
|
internal_base_url: 'http://player-two:8081',
|
||||||
|
last_seen_at: liveLastSeenAt
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: 'player-stale',
|
||||||
|
internal_base_url: 'http://player-stale:8081',
|
||||||
|
last_seen_at: staleLastSeenAt
|
||||||
|
}
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
playerSnapshotCache: new Map(),
|
||||||
|
notifyPlayerScreens: async () => {},
|
||||||
|
backgroundTaskQueue: {
|
||||||
|
async enqueueTaskAndWait(definition) {
|
||||||
|
queuedTasks.push(definition);
|
||||||
|
await uploadSyncService.runMediaSyncTask(definition.payload);
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadSyncService.syncPlaylistUploadsOnChange({
|
||||||
|
key: 'slide:update:123',
|
||||||
|
pool: {},
|
||||||
|
localUploadDir: uploadDir,
|
||||||
|
nextUploadRefs: ['/media/uploads/sample.bin']
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(queuedTasks.length, 2);
|
||||||
|
assert.deepEqual(queuedTasks.map(function (task) {
|
||||||
|
return task.metadata.playerIdentifier;
|
||||||
|
}).sort(), ['player-one', 'player-two']);
|
||||||
|
assert.ok(queuedTasks.every(function (task) {
|
||||||
|
return task.key.startsWith('media-sync:slide:update:123:');
|
||||||
|
}));
|
||||||
|
assert.deepEqual(fetchCalls.map(function (call) {
|
||||||
|
return call.url;
|
||||||
|
}).sort(), [
|
||||||
|
'http://player-one:8081/api/media/uploads%2Fsample.bin?deviceId=player-one',
|
||||||
|
'http://player-two:8081/api/media/uploads%2Fsample.bin?deviceId=player-two'
|
||||||
|
].sort());
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
+269
-498
@@ -89,273 +89,6 @@ test('dashboard kiosk launcher includes connected player choices', () => {
|
|||||||
assert.match(html, /data-kiosk-launcher-download-base="\/downloads\/kiosk\/pulse-signage-kiosk\.bat"/);
|
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', () => {
|
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 script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
|
||||||
const tbody = {
|
const tbody = {
|
||||||
@@ -428,10 +161,7 @@ test('dashboard client refresh respects the active search filter', () => {
|
|||||||
Math: Math,
|
Math: Math,
|
||||||
Set: Set,
|
Set: Set,
|
||||||
URLSearchParams: URLSearchParams,
|
URLSearchParams: URLSearchParams,
|
||||||
FormData: function FormData() {},
|
FormData: function FormData() {}
|
||||||
setTimeout() {},
|
|
||||||
clearTimeout() {},
|
|
||||||
console: console
|
|
||||||
};
|
};
|
||||||
context.window.document = context.document;
|
context.window.document = context.document;
|
||||||
context.window.WebSocket = context.WebSocket;
|
context.window.WebSocket = context.WebSocket;
|
||||||
@@ -449,21 +179,258 @@ test('dashboard client refresh respects the active search filter', () => {
|
|||||||
assert.doesNotMatch(tbody.innerHTML, /Alpha/);
|
assert.doesNotMatch(tbody.innerHTML, /Alpha/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dashboard client refresh respects the live search input before the URL updates', () => {
|
test('dashboard move client button opens the move modal for the selected row', () => {
|
||||||
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
|
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
|
||||||
const searchInput = {
|
const modal = {
|
||||||
value: 'beta'
|
listeners: {},
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
this.listeners[type] = handler;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const tbody = {
|
const form = {
|
||||||
innerHTML: '<tr><td>stale</td></tr>',
|
dataset: {},
|
||||||
|
method: 'post',
|
||||||
|
action: '',
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === 'button[type="submit"]') {
|
||||||
|
return { disabled: true };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
addEventListener() {}
|
addEventListener() {}
|
||||||
};
|
};
|
||||||
const table = {
|
const connectionInput = { value: '' };
|
||||||
|
const deviceInput = { value: '' };
|
||||||
|
const clientNameInput = { value: '' };
|
||||||
|
const playerBaseUrlInput = { value: '' };
|
||||||
|
const targetSelect = { value: 'alpha' };
|
||||||
|
targetSelect.options = [
|
||||||
|
{ value: 'alpha', disabled: false },
|
||||||
|
{ value: 'beta', disabled: false }
|
||||||
|
];
|
||||||
|
const row = {
|
||||||
getAttribute(name) {
|
getAttribute(name) {
|
||||||
if (name === 'data-has-actions-column') {
|
if (name === 'data-client-screen-slug') {
|
||||||
return 'false';
|
return 'alpha';
|
||||||
|
}
|
||||||
|
if (name === 'data-client-id') {
|
||||||
|
return 'conn-123';
|
||||||
|
}
|
||||||
|
if (name === 'data-client-device-id') {
|
||||||
|
return 'device-123';
|
||||||
|
}
|
||||||
|
if (name === 'data-client-player-base-url') {
|
||||||
|
return 'http://player.local';
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
|
},
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === 'td[data-label="Client"] > div') {
|
||||||
|
return { textContent: 'Lobby Client' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const moveButton = {
|
||||||
|
closest(selector) {
|
||||||
|
if (selector === 'button[data-action="move-screen"]') {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
if (selector === 'tr[data-client-key]') {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
document: {
|
||||||
|
getElementById(id) {
|
||||||
|
if (id === 'client-move-screen-modal') {
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
if (id === 'client-move-screen-form') {
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
if (id === 'client-move-screen-target') {
|
||||||
|
return targetSelect;
|
||||||
|
}
|
||||||
|
if (id === 'dashboard-clients-table') {
|
||||||
|
return { getAttribute() { return 'true'; } };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === '[data-client-move-connection-id]') {
|
||||||
|
return connectionInput;
|
||||||
|
}
|
||||||
|
if (selector === '[data-client-move-device-id]') {
|
||||||
|
return deviceInput;
|
||||||
|
}
|
||||||
|
if (selector === '[data-client-move-client-name]') {
|
||||||
|
return clientNameInput;
|
||||||
|
}
|
||||||
|
if (selector === '[data-client-move-player-base-url]') {
|
||||||
|
return playerBaseUrlInput;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
querySelectorAll() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
if (type === 'click') {
|
||||||
|
this.clickHandler = handler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
location: {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
pulseModal: {
|
||||||
|
show(element) {
|
||||||
|
element.__shown = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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() {}
|
||||||
|
};
|
||||||
|
context.window.document = context.document;
|
||||||
|
context.window.WebSocket = context.WebSocket;
|
||||||
|
|
||||||
|
vm.runInNewContext(script, context);
|
||||||
|
|
||||||
|
assert.equal(typeof context.document.clickHandler, 'function');
|
||||||
|
context.document.clickHandler({
|
||||||
|
target: moveButton,
|
||||||
|
preventDefault() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(modal.__shown, true);
|
||||||
|
assert.equal(form.action, '/clients/alpha/commands');
|
||||||
|
assert.equal(targetSelect.options[0].disabled, true);
|
||||||
|
assert.equal(targetSelect.options[1].disabled, false);
|
||||||
|
assert.equal(connectionInput.value, 'conn-123');
|
||||||
|
assert.equal(deviceInput.value, 'device-123');
|
||||||
|
assert.equal(clientNameInput.value, 'Lobby Client');
|
||||||
|
assert.equal(playerBaseUrlInput.value, 'http://player.local');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard screen group controls stay disabled until a group is selected', () => {
|
||||||
|
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
|
||||||
|
const pauseInput = { value: 'true' };
|
||||||
|
const blackoutInput = { value: 'true' };
|
||||||
|
const reloadButton = {
|
||||||
|
disabled: false,
|
||||||
|
innerHTML: '',
|
||||||
|
setAttribute() {},
|
||||||
|
classList: { add() {}, remove() {} }
|
||||||
|
};
|
||||||
|
const pauseButton = {
|
||||||
|
disabled: false,
|
||||||
|
innerHTML: '',
|
||||||
|
setAttribute() {},
|
||||||
|
classList: { add() {}, remove() {} }
|
||||||
|
};
|
||||||
|
const blackoutButton = {
|
||||||
|
disabled: false,
|
||||||
|
innerHTML: '',
|
||||||
|
setAttribute() {},
|
||||||
|
classList: { add() {}, remove() {} }
|
||||||
|
};
|
||||||
|
const forms = [
|
||||||
|
{
|
||||||
|
getAttribute(name) {
|
||||||
|
if (name === 'data-screen-command-action') {
|
||||||
|
return 'reload';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
setAttribute() {},
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === 'button[type="submit"]') {
|
||||||
|
return reloadButton;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getAttribute(name) {
|
||||||
|
if (name === 'data-screen-command-action') {
|
||||||
|
return 'pause';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
setAttribute() {},
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === 'button[type="submit"]') {
|
||||||
|
return pauseButton;
|
||||||
|
}
|
||||||
|
if (selector === 'input[name="paused"]') {
|
||||||
|
return pauseInput;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getAttribute(name) {
|
||||||
|
if (name === 'data-screen-command-action') {
|
||||||
|
return 'blackout';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
setAttribute() {},
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === 'button[type="submit"]') {
|
||||||
|
return blackoutButton;
|
||||||
|
}
|
||||||
|
if (selector === 'input[name="blackout"]') {
|
||||||
|
return blackoutInput;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const select = {
|
||||||
|
value: '',
|
||||||
|
options: [
|
||||||
|
{ textContent: 'Select Screen Group', getAttribute() { return ''; } },
|
||||||
|
{ textContent: 'Lobby', getAttribute(name) { return name === 'data-screen-name' ? 'Lobby' : ''; } },
|
||||||
|
{ textContent: 'All Screens', getAttribute(name) { return name === 'data-screen-name' ? 'All screens' : ''; } }
|
||||||
|
],
|
||||||
|
selectedIndex: 0,
|
||||||
|
dataset: {},
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
if (type === 'change') {
|
||||||
|
this.changeHandler = handler;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const context = {
|
const context = {
|
||||||
@@ -476,23 +443,30 @@ test('dashboard client refresh respects the live search input before the URL upd
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getElementById(id) {
|
getElementById(id) {
|
||||||
|
if (id === 'screen-command-select') {
|
||||||
|
return select;
|
||||||
|
}
|
||||||
if (id === 'dashboard-clients-table') {
|
if (id === 'dashboard-clients-table') {
|
||||||
return table;
|
return { getAttribute() { return 'false'; } };
|
||||||
}
|
}
|
||||||
if (id === 'dashboard-clients-table-body') {
|
if (id === 'dashboard-clients-table-body') {
|
||||||
return tbody;
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
querySelector(selector) {
|
querySelector(selector) {
|
||||||
if (selector === '[data-table-search]') {
|
if (selector === '[data-screen-command-select]') {
|
||||||
return searchInput;
|
return select;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
querySelectorAll() {
|
querySelectorAll(selector) {
|
||||||
return [];
|
if (selector === '[data-screen-command-form]') {
|
||||||
|
return forms;
|
||||||
}
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
addEventListener() {}
|
||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
location: {
|
location: {
|
||||||
@@ -527,225 +501,22 @@ test('dashboard client refresh respects the live search input before the URL upd
|
|||||||
Math: Math,
|
Math: Math,
|
||||||
Set: Set,
|
Set: Set,
|
||||||
URLSearchParams: URLSearchParams,
|
URLSearchParams: URLSearchParams,
|
||||||
FormData: function FormData() {},
|
FormData: function FormData() {}
|
||||||
setTimeout() {},
|
|
||||||
clearTimeout() {},
|
|
||||||
console: console
|
|
||||||
};
|
};
|
||||||
context.window.document = context.document;
|
context.window.document = context.document;
|
||||||
context.window.WebSocket = context.WebSocket;
|
context.window.WebSocket = context.WebSocket;
|
||||||
|
|
||||||
vm.runInNewContext(script, context);
|
vm.runInNewContext(script, context);
|
||||||
|
|
||||||
context.window.webHandleDashboardState({
|
assert.equal(reloadButton.disabled, true);
|
||||||
clients: [
|
assert.equal(pauseButton.disabled, true);
|
||||||
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' },
|
assert.equal(blackoutButton.disabled, true);
|
||||||
{ id: 'beta', client_name: 'Beta', clientId: 'beta', screen_slug: 'beta', clientIp: '10.0.0.2' }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.match(tbody.innerHTML, /Beta/);
|
select.value = 'lobby';
|
||||||
assert.doesNotMatch(tbody.innerHTML, /Alpha/);
|
select.selectedIndex = 1;
|
||||||
});
|
select.changeHandler();
|
||||||
|
|
||||||
test('dashboard client refresh does not rerender while a table search is loading', () => {
|
assert.equal(reloadButton.disabled, false);
|
||||||
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
|
assert.equal(pauseButton.disabled, false);
|
||||||
const tbody = {
|
assert.equal(blackoutButton.disabled, false);
|
||||||
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/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,12 +14,33 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
|||||||
|
|
||||||
const pool = {
|
const pool = {
|
||||||
async query(sql) {
|
async query(sql) {
|
||||||
if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
|
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url') && sql.includes('WHERE s.slug = ?')) {
|
||||||
return [[{ slug: 'alpha' }, { slug: 'beta' }]];
|
return [[{
|
||||||
|
slug: 'alpha',
|
||||||
|
player_id: 'player-a',
|
||||||
|
public_base_url: 'http://player.local',
|
||||||
|
internal_base_url: 'http://player.internal'
|
||||||
|
}]];
|
||||||
}
|
}
|
||||||
if (sql.includes('SELECT id, name, slug, playlist_id')) {
|
if (sql.includes('SELECT id, name, slug, playlist_id')) {
|
||||||
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
|
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
|
||||||
}
|
}
|
||||||
|
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) {
|
||||||
|
return [[
|
||||||
|
{
|
||||||
|
slug: 'alpha',
|
||||||
|
player_id: 'player-a',
|
||||||
|
public_base_url: 'http://player-a.example',
|
||||||
|
internal_base_url: 'http://player-a.internal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'beta',
|
||||||
|
player_id: 'player-b',
|
||||||
|
public_base_url: 'http://player-b.example',
|
||||||
|
internal_base_url: 'http://player-b.internal'
|
||||||
|
}
|
||||||
|
]];
|
||||||
|
}
|
||||||
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?')) {
|
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?')) {
|
||||||
return [{ affectedRows: 1 }];
|
return [{ affectedRows: 1 }];
|
||||||
}
|
}
|
||||||
@@ -37,7 +58,13 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
|||||||
fetchDuplicateName: async () => null,
|
fetchDuplicateName: async () => null,
|
||||||
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
|
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
|
||||||
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' }),
|
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' }),
|
||||||
fetchPlayerPublicBaseUrl: async () => 'http://player.local'
|
fetchPlayerPublicBaseUrl: async () => 'http://player.local',
|
||||||
|
fetchPlayerRegistrations: async () => ([
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player.local',
|
||||||
|
internal_base_url: 'http://player.internal'
|
||||||
|
}
|
||||||
|
])
|
||||||
},
|
},
|
||||||
pages,
|
pages,
|
||||||
getAuditUserId() { return 7; },
|
getAuditUserId() { return 7; },
|
||||||
@@ -53,11 +80,30 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
|||||||
calls.push({ kind: 'broadcastDashboardState' });
|
calls.push({ kind: 'broadcastDashboardState' });
|
||||||
},
|
},
|
||||||
getScreenDeleteBlockMessage: async () => '',
|
getScreenDeleteBlockMessage: async () => '',
|
||||||
getScreenConnections: async () => [],
|
getScreenConnections: async (slug) => {
|
||||||
|
if (slug === 'alpha') {
|
||||||
|
return {
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'alpha-1',
|
||||||
|
clientId: 'alpha-1',
|
||||||
|
deviceId: 'alpha-device',
|
||||||
|
playerPublicBaseUrl: 'http://player.local/'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { connections: [] };
|
||||||
|
},
|
||||||
forwardPlayerCommand: async (slug, payload) => {
|
forwardPlayerCommand: async (slug, payload) => {
|
||||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
forwardPlayerCommandToBaseUrl: async (baseUrl, slug, payload) => {
|
||||||
|
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
playerPublicBaseUrl: 'http://player.example',
|
playerPublicBaseUrl: 'http://player.example',
|
||||||
requirePermission() {
|
requirePermission() {
|
||||||
return function (_req, _res, next) {
|
return function (_req, _res, next) {
|
||||||
@@ -91,7 +137,233 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
|||||||
|
|
||||||
assert.equal(res.redirectedTo, '/screens?edit=42');
|
assert.equal(res.redirectedTo, '/screens?edit=42');
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
{ kind: 'forwardPlayerCommand', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
|
{ kind: 'forwardPlayerCommandToBaseUrl', baseUrl: 'http://player.internal', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
|
||||||
{ kind: 'redirectAfterSave', url: '/screens?edit=42' }
|
{ kind: 'redirectAfterSave', url: '/screens?edit=42' }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dashboard commands fan out to each connected bridge player', async () => {
|
||||||
|
const handlers = {};
|
||||||
|
const app = {
|
||||||
|
post(path, ...routeHandlers) {
|
||||||
|
handlers[path] = routeHandlers;
|
||||||
|
},
|
||||||
|
get() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
registerManageRoutes(app, {
|
||||||
|
pool: {
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.includes('SELECT s.slug') && sql.includes('FROM d_screens s') && sql.includes('ORDER BY s.slug ASC')) {
|
||||||
|
return [[
|
||||||
|
{
|
||||||
|
slug: 'alpha',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'beta'
|
||||||
|
}
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
fetchPlayerRegistrations: async () => ([
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-a.example',
|
||||||
|
internal_base_url: 'http://player-a.internal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-b.example',
|
||||||
|
internal_base_url: 'http://player-b.internal'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
},
|
||||||
|
pages: { renderScreenFormPage() {}, renderScreenEditPage() {} },
|
||||||
|
getAuditUserId() { return 7; },
|
||||||
|
redirectAfterSave() {},
|
||||||
|
notifyPlayerScreens: async () => 0,
|
||||||
|
broadcastDashboardState: async () => {
|
||||||
|
calls.push({ kind: 'broadcastDashboardState' });
|
||||||
|
},
|
||||||
|
getScreenDeleteBlockMessage: async () => '',
|
||||||
|
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: [] };
|
||||||
|
},
|
||||||
|
forwardPlayerCommand: async (slug, payload) => {
|
||||||
|
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
forwardPlayerCommandToBaseUrl: async (baseUrl, slug, payload) => {
|
||||||
|
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
requirePermission() {
|
||||||
|
return function (_req, _res, next) {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeHandlers = handlers['/commands'];
|
||||||
|
assert.equal(Array.isArray(routeHandlers), true);
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
body: { command: 'pause' },
|
||||||
|
query: {}
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(value) {
|
||||||
|
this.body = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await routeHandlers[1](req, res, () => {});
|
||||||
|
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.equal(res.body.ok, true);
|
||||||
|
assert.equal(res.body.sent, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
|
||||||
|
['http://player-a.internal', 'http://player-b.internal']
|
||||||
|
);
|
||||||
|
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
|
||||||
|
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard commands use live bridge connections when available', async () => {
|
||||||
|
const handlers = {};
|
||||||
|
const app = {
|
||||||
|
post(path, ...routeHandlers) {
|
||||||
|
handlers[path] = routeHandlers;
|
||||||
|
},
|
||||||
|
get() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
registerManageRoutes(app, {
|
||||||
|
pool: {
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.includes('SELECT s.slug') && sql.includes('FROM d_screens s') && sql.includes('ORDER BY s.slug ASC')) {
|
||||||
|
return [[
|
||||||
|
{
|
||||||
|
slug: 'alpha',
|
||||||
|
}
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
fetchPlayerRegistrations: async () => ([
|
||||||
|
{
|
||||||
|
public_base_url: 'http://player-a.example',
|
||||||
|
internal_base_url: 'http://player-a.internal'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
},
|
||||||
|
pages: { renderScreenFormPage() {}, renderScreenEditPage() {} },
|
||||||
|
getAuditUserId() { return 7; },
|
||||||
|
redirectAfterSave() {},
|
||||||
|
notifyPlayerScreens: async () => 0,
|
||||||
|
broadcastDashboardState: async () => {
|
||||||
|
calls.push({ kind: 'broadcastDashboardState' });
|
||||||
|
},
|
||||||
|
getScreenDeleteBlockMessage: async () => '',
|
||||||
|
getScreenConnections: async (slug) => {
|
||||||
|
if (slug !== 'alpha') {
|
||||||
|
return { connections: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: 'alpha-1',
|
||||||
|
clientId: 'alpha-1',
|
||||||
|
deviceId: 'alpha-device',
|
||||||
|
playerPublicBaseUrl: 'http://player-a.example/'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
forwardPlayerCommand: async (slug, payload) => {
|
||||||
|
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
forwardPlayerCommandToBaseUrl: async (baseUrl, slug, payload) => {
|
||||||
|
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
requirePermission() {
|
||||||
|
return function (_req, _res, next) {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeHandlers = handlers['/commands'];
|
||||||
|
assert.equal(Array.isArray(routeHandlers), true);
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
body: { command: 'reload' },
|
||||||
|
query: {}
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(value) {
|
||||||
|
this.body = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await routeHandlers[1](req, res, () => {});
|
||||||
|
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.equal(res.body.ok, true);
|
||||||
|
assert.equal(res.body.sent, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
|
||||||
|
['http://player-a.internal']
|
||||||
|
);
|
||||||
|
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
|
||||||
|
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const registerMiddleware = require('../src/web/middleware');
|
||||||
|
|
||||||
|
test('web middleware bypasses auth for internal player sync routes', () => {
|
||||||
|
const calls = [];
|
||||||
|
const app = {
|
||||||
|
middlewares: [],
|
||||||
|
use(...args) {
|
||||||
|
this.middlewares.push(args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerMiddleware(app, {
|
||||||
|
pool: {},
|
||||||
|
loadCurrentUser: async () => null,
|
||||||
|
requireAuth(req, _res, next) {
|
||||||
|
calls.push(req.path);
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
MEDIA_DIR: path.join(process.cwd(), 'media'),
|
||||||
|
UPLOADS_DIR: path.join(process.cwd(), 'media', 'uploads-test'),
|
||||||
|
THUMBNAILS_DIR: path.join(process.cwd(), 'media', 'thumbnails-test'),
|
||||||
|
ASSET_DIR: path.join(process.cwd(), 'src', 'web', 'public')
|
||||||
|
});
|
||||||
|
|
||||||
|
const authGate = app.middlewares[app.middlewares.length - 1][0];
|
||||||
|
const nextCalls = [];
|
||||||
|
|
||||||
|
authGate({ path: '/api/internal/sync/player-media' }, {}, function () {
|
||||||
|
nextCalls.push('media');
|
||||||
|
});
|
||||||
|
authGate({ path: '/api/internal/sync/player-font' }, {}, function () {
|
||||||
|
nextCalls.push('font');
|
||||||
|
});
|
||||||
|
authGate({ path: '/dashboard' }, {}, function () {
|
||||||
|
nextCalls.push('dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(nextCalls, ['media', 'font', 'dashboard']);
|
||||||
|
assert.deepEqual(calls, ['/dashboard']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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('client row keys prefer the client name', () => {
|
||||||
|
const sandbox = {
|
||||||
|
window: null,
|
||||||
|
Date,
|
||||||
|
Array,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
Boolean,
|
||||||
|
Object,
|
||||||
|
Math,
|
||||||
|
console
|
||||||
|
};
|
||||||
|
sandbox.window = sandbox;
|
||||||
|
|
||||||
|
loadScript(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'web-ui-helpers.js'), sandbox);
|
||||||
|
|
||||||
|
const helpers = sandbox.window.webUiHelpers;
|
||||||
|
assert.equal(helpers.getClientRowKey({ client_name: 'Conference Left', screen_slug: 'alpha', deviceId: 'device-123', id: 'conn-1', clientId: 'client-1' }), 'Conference Left');
|
||||||
|
assert.equal(helpers.getClientRowKey({ client_name: 'Conference Right', screen_slug: 'beta', deviceId: 'device-123', id: 'conn-2', clientId: 'client-2' }), 'Conference Right');
|
||||||
|
assert.equal(helpers.getClientRowKey({ screen_slug: 'alpha', id: 'conn-1', clientId: 'client-1' }), 'alpha');
|
||||||
|
assert.equal(helpers.getClientRowKey({ clientId: 'client-1' }), 'client-1');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user