Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c72923b4 | ||
|
|
6c28a1c028 | ||
|
|
c5172af62d | ||
|
|
cd8e333afd | ||
|
|
c3b5a0053c | ||
|
|
38565a533d | ||
|
|
5a08ccddbb |
@@ -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,44 @@
|
|||||||
|
|
||||||
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
|
## 2.6.10 - 2026-08-08
|
||||||
|
|
||||||
### 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"]
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# 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
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ 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
|
||||||
@@ -132,7 +133,7 @@ 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_URL` - public URL for the remote player
|
- `PLAYER_PUBLIC_URL` - public URL for the remote player
|
||||||
@@ -157,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. |
|
||||||
@@ -218,7 +220,7 @@ Each compose file creates its own named network:
|
|||||||
- 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 `BRIDGE_PUBLIC_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,22 +3,22 @@ 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:
|
networks:
|
||||||
- pulse_signage
|
- pulse_signage
|
||||||
ports:
|
ports:
|
||||||
- "8081:8081"
|
- "8081:8081"
|
||||||
environment:
|
environment:
|
||||||
|
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-remote}
|
||||||
PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
|
PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
|
||||||
BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:-}
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pulse-signage:
|
pulse-signage:
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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:
|
networks:
|
||||||
- pulse_signage
|
- pulse_signage
|
||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
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:
|
networks:
|
||||||
- pulse_signage
|
- pulse_signage
|
||||||
@@ -52,7 +52,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
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:
|
networks:
|
||||||
- pulse_signage
|
- pulse_signage
|
||||||
|
|||||||
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.10",
|
"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);
|
||||||
}) || '';
|
}) || '';
|
||||||
|
|||||||
+101
-8
@@ -92,6 +92,20 @@ function resolveSnapshotUpstreamBaseUrl(player) {
|
|||||||
return normalizeProxyBaseUrl(player && player.public_base_url) || null;
|
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) {
|
||||||
const key = String(slug || '').trim();
|
const key = String(slug || '').trim();
|
||||||
if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') {
|
if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') {
|
||||||
@@ -369,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.' });
|
||||||
}
|
}
|
||||||
@@ -513,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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -533,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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1054,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);
|
||||||
@@ -1076,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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1091,7 +1183,8 @@ module.exports = {
|
|||||||
start: start,
|
start: start,
|
||||||
resolveWebBaseUrl: resolveWebBaseUrl,
|
resolveWebBaseUrl: resolveWebBaseUrl,
|
||||||
resolveScreenCommandTargets: resolveScreenCommandTargets,
|
resolveScreenCommandTargets: resolveScreenCommandTargets,
|
||||||
resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl
|
resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl,
|
||||||
|
resolvePlayerSocketForDeviceId: resolvePlayerSocketForDeviceId
|
||||||
};
|
};
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
|
|||||||
+177
-40
@@ -22,15 +22,21 @@ async function start() {
|
|||||||
const PORT = Number(process.env.PLAYER_PORT || 8081);
|
const PORT = Number(process.env.PLAYER_PORT || 8081);
|
||||||
const PLAYER_PUBLIC_URL = String(process.env.PLAYER_PUBLIC_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 BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
const BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
||||||
|
const WEB_INTERNAL_URL = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, '');
|
||||||
const isRemotePlayer = Boolean(BRIDGE_PUBLIC_URL);
|
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_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 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,
|
||||||
@@ -66,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;
|
||||||
@@ -75,7 +110,7 @@ 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_URL || null,
|
publicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
bridgeBaseUrl: PLAYER_INTERNAL_URL || null,
|
bridgeBaseUrl: PLAYER_INTERNAL_URL || null,
|
||||||
bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null
|
bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null
|
||||||
});
|
});
|
||||||
@@ -99,29 +134,84 @@ async function start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function triggerWebMediaSync() {
|
async function triggerWebMediaSync() {
|
||||||
if (!isRemotePlayer || !BRIDGE_PUBLIC_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(`${BRIDGE_PUBLIC_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;
|
||||||
@@ -211,7 +301,7 @@ async function start() {
|
|||||||
common: common,
|
common: common,
|
||||||
playerRuntime: playerRuntime,
|
playerRuntime: playerRuntime,
|
||||||
onboardingStore: onboardingStore,
|
onboardingStore: onboardingStore,
|
||||||
playerPublicBaseUrl: PLAYER_PUBLIC_URL,
|
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
|
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
|
||||||
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
|
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
|
||||||
playerDeviceId: PLAYER_DEVICE_ID
|
playerDeviceId: PLAYER_DEVICE_ID
|
||||||
@@ -224,10 +314,10 @@ async function start() {
|
|||||||
playerRuntime: playerRuntime,
|
playerRuntime: playerRuntime,
|
||||||
playerPlaylistService: playerPlaylistService,
|
playerPlaylistService: playerPlaylistService,
|
||||||
rtmpStreamService: rtmpStreamService,
|
rtmpStreamService: rtmpStreamService,
|
||||||
playerPublicBaseUrl: PLAYER_PUBLIC_URL,
|
|
||||||
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
|
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
|
||||||
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
|
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
|
||||||
playerDeviceId: PLAYER_DEVICE_ID
|
playerDeviceId: PLAYER_DEVICE_ID,
|
||||||
|
onPlayerPublicBaseUrl: setPlayerPublicBaseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
function createThinClientWebSocketUrl() {
|
function createThinClientWebSocketUrl() {
|
||||||
@@ -275,6 +365,54 @@ async function start() {
|
|||||||
});
|
});
|
||||||
thinClientSocket = socket;
|
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) {
|
function sendSnapshot(slug) {
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
@@ -299,7 +437,7 @@ 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_URL,
|
publicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
internalBaseUrl: PLAYER_INTERNAL_URL
|
internalBaseUrl: PLAYER_INTERNAL_URL
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -307,38 +445,32 @@ async function start() {
|
|||||||
sendSnapshot(slug);
|
sendSnapshot(slug);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!webMediaSyncCompleted) {
|
|
||||||
triggerWebMediaSync().then(function (success) {
|
|
||||||
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_URL,
|
|
||||||
internalBaseUrl: PLAYER_INTERNAL_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({
|
||||||
@@ -354,9 +486,14 @@ async function start() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('close', function () {
|
socket.on('close', function () {
|
||||||
|
lastDisconnectAt = Date.now();
|
||||||
|
webFontSyncTriggered = false;
|
||||||
|
webFontSyncCompleted = false;
|
||||||
|
webMediaSyncCompleted = false;
|
||||||
clearTimers();
|
clearTimers();
|
||||||
thinClientSocket = null;
|
thinClientSocket = null;
|
||||||
reconnectTimer = setTimeout(connect, 5000);
|
refreshThinClientRegistration = null;
|
||||||
|
reconnectTimer = setTimeout(connect, PLAYER_AGENT_RECONNECT_DELAY_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', function () {
|
socket.on('error', function () {
|
||||||
@@ -402,7 +539,7 @@ async function start() {
|
|||||||
try {
|
try {
|
||||||
await recordPlayerHeartbeat(pool, {
|
await recordPlayerHeartbeat(pool, {
|
||||||
deviceId: PLAYER_DEVICE_ID,
|
deviceId: PLAYER_DEVICE_ID,
|
||||||
publicBaseUrl: PLAYER_PUBLIC_URL,
|
publicBaseUrl: getPlayerPublicBaseUrl(),
|
||||||
internalBaseUrl: PLAYER_INTERNAL_URL
|
internalBaseUrl: PLAYER_INTERNAL_URL
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -431,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_URL, PLAYER_INTERNAL_URL).catch(function (error) {
|
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,15 +16,16 @@ function normalizeDeviceId(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPublicBaseUrl(req, configuredUrl) {
|
function getPublicBaseUrl(req, configuredUrl) {
|
||||||
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_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();
|
||||||
return `${protocol}://${host}`.replace(/\/$/, '');
|
if (host) {
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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,10 +32,10 @@ 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 playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_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 playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||||
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
|
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_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.');
|
||||||
@@ -296,6 +297,13 @@ 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 (bridgeBaseUrl) {
|
if (bridgeBaseUrl) {
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () {
|
||||||
return backgroundTaskQueue.enqueueTask({
|
let players = [];
|
||||||
key: TASK.key,
|
try {
|
||||||
title: 'Initial font sync',
|
players = await fetchPlayerRegistrations(pool);
|
||||||
category: TASK.category,
|
} catch (error) {
|
||||||
taskType: 'font-sync',
|
console.warn('Unable to fetch player registrations for initial font sync:', error);
|
||||||
metadata: Object.assign({}, metadata || {}),
|
return null;
|
||||||
payload: {
|
}
|
||||||
mode: 'initial',
|
|
||||||
uploadDir: mediaDir,
|
const livePlayers = Array.isArray(players)
|
||||||
operations: collectFontLibrarySyncOperations(mediaDir)
|
? players.filter(function (player) {
|
||||||
},
|
return isRecentPlayerRegistration(player, 60);
|
||||||
persist: true
|
})
|
||||||
});
|
: [];
|
||||||
|
|
||||||
|
if (!livePlayers.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operations = collectFontLibrarySyncOperations(mediaDir);
|
||||||
|
return Promise.all(livePlayers.map(function (player) {
|
||||||
|
const metadata = normalizePlayerMetadata(player);
|
||||||
|
return backgroundTaskQueue.enqueueTask({
|
||||||
|
key: TASK.key,
|
||||||
|
title: 'Initial font sync',
|
||||||
|
category: TASK.category,
|
||||||
|
taskType: 'font-sync',
|
||||||
|
metadata: metadata,
|
||||||
|
payload: {
|
||||||
|
mode: 'initial',
|
||||||
|
uploadDir: mediaDir,
|
||||||
|
operations: operations,
|
||||||
|
playerIdentifier: metadata.playerIdentifier,
|
||||||
|
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
|
||||||
|
playerInternalBaseUrl: metadata.playerInternalBaseUrl
|
||||||
|
},
|
||||||
|
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 () {
|
||||||
return backgroundTaskQueue.enqueueTask({
|
let players = [];
|
||||||
key: TASK.key,
|
try {
|
||||||
title: 'Initial media sync',
|
players = await fetchPlayerRegistrations(pool);
|
||||||
category: TASK.category,
|
} catch (error) {
|
||||||
taskType: 'media-sync',
|
console.warn('Unable to fetch player registrations for initial media sync:', error);
|
||||||
metadata: Object.assign({}, metadata || {}),
|
return null;
|
||||||
payload: {
|
}
|
||||||
mode: 'initial',
|
|
||||||
uploadDir: mediaDir
|
const livePlayers = Array.isArray(players)
|
||||||
},
|
? players.filter(function (player) {
|
||||||
persist: true
|
return isRecentPlayerRegistration(player, 60);
|
||||||
});
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!livePlayers.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(livePlayers.map(function (player) {
|
||||||
|
const metadata = normalizePlayerMetadata(player);
|
||||||
|
return backgroundTaskQueue.enqueueTask({
|
||||||
|
key: TASK.key,
|
||||||
|
title: 'Initial media sync',
|
||||||
|
category: TASK.category,
|
||||||
|
taskType: 'media-sync',
|
||||||
|
metadata: metadata,
|
||||||
|
payload: {
|
||||||
|
mode: 'initial',
|
||||||
|
uploadDir: mediaDir,
|
||||||
|
playerIdentifier: metadata.playerIdentifier,
|
||||||
|
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
|
||||||
|
playerInternalBaseUrl: metadata.playerInternalBaseUrl
|
||||||
|
},
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,11 +99,44 @@ 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;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerTaskMetadata && playerTaskMetadata.playerActive !== false) {
|
||||||
return playerTaskMetadata;
|
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();
|
||||||
@@ -443,7 +527,7 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -453,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) {
|
||||||
@@ -474,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',
|
||||||
@@ -497,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;
|
||||||
}
|
}
|
||||||
@@ -507,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;
|
||||||
@@ -516,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',
|
||||||
@@ -538,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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -669,10 +763,27 @@ function createUploadSyncService(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
|
const players = await fetchLivePlayerRegistrations();
|
||||||
mode: 'playlist',
|
if (!players.length) {
|
||||||
operation: operation
|
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
|
||||||
});
|
mode: 'playlist',
|
||||||
|
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() {
|
||||||
@@ -733,44 +844,49 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -795,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) {
|
||||||
@@ -812,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) {
|
||||||
@@ -823,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();
|
||||||
@@ -834,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) {
|
||||||
@@ -870,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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
mode: 'initial',
|
? _req.body
|
||||||
uploadDir: mediaDir
|
: {};
|
||||||
|
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',
|
||||||
|
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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
@@ -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%;">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
|
|||||||
require('../src/common');
|
require('../src/common');
|
||||||
|
|
||||||
const originalWebBaseUrl = process.env.WEB_INTERNAL_URL;
|
const originalWebBaseUrl = process.env.WEB_INTERNAL_URL;
|
||||||
const { resolveWebBaseUrl, resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl } = 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) {
|
||||||
@@ -87,4 +87,16 @@ test('resolveSnapshotUpstreamBaseUrl falls back to the public player url for rem
|
|||||||
public_base_url: 'https://remote-player.example',
|
public_base_url: 'https://remote-player.example',
|
||||||
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
|
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
|
||||||
}), 'https://remote-player.example');
|
}), '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);
|
||||||
});
|
});
|
||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -274,4 +274,190 @@ test('stale player registrations stop media sync retries and warnings', async ()
|
|||||||
console.warn = originalWarn;
|
console.warn = originalWarn;
|
||||||
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 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user