Release v2.14.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
lzstealth committed 2026-10-11 18:54:43 +01:00
1 parent 635e1a9942
commit c3299abaac
174 files changed
+4483 -1687

No files matched your search

+3 -3
View File
@@ -1,7 +1,7 @@
// Entry point for the web auth helper modules.
module.exports = {
createSessionService: require('./session').createSessionService,
getRequestOrigin: require('./session').getRequestOrigin,
rbacData: require('./rbac-data')
createSessionService: require('#src/web/lib/auth/session').createSessionService,
getRequestOrigin: require('#src/web/lib/auth/session').getRequestOrigin,
rbacData: require('#src/web/lib/auth/rbac-data')
};
@@ -1,4 +1,4 @@
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('#src/web/lib/data-source-refresh');
const TASK = {
taskType: 'data-source-refresh'
@@ -8,8 +8,8 @@ const TASK = {
intervalMs: null
};
const { normalizeIntervalMs } = require('../queue');
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
const { normalizeIntervalMs } = require('#src/web/lib/background-tasks/queue');
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('#src/web/lib/data-source-refresh');
function registerRecurringDataSourceRefreshes(options) {
const pool = options && options.pool;
@@ -11,7 +11,7 @@ const TASK = {
const {
collectFontLibraryDirectoryUploadPaths,
collectFontLibrarySyncOperations
} = require('../../media/font-library');
} = require('#src/web/lib/media/font-library');
function registerFontSweepTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
@@ -1,4 +1,4 @@
const { collectLocalControlUsers } = require('../../local-control-users');
const { collectLocalControlUsers } = require('#src/web/lib/local-control-users');
const TASK = {
key: 'player-control-sync',
@@ -1,5 +1,5 @@
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
const { normalizeIntervalMs } = require('../queue');
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('#src/web/lib/data-source-refresh');
const { normalizeIntervalMs } = require('#src/web/lib/background-tasks/queue');
const TASK = {
key: 'startup-data-source-refresh',
@@ -1,18 +1,13 @@
const { collectFontLibrarySyncOperations } = require('../../media/font-library');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
const { fetchPlayerRegistrations, isRecentPlayerRegistration } = require('#src/data/player-registry');
const TASK = {
key: 'initial-font-sync',
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;
}
// Queue a one-off font sync for each live player on startup. The task keeps the resolved player
// metadata in the payload so retries and background workers do not need to rediscover the same URL.
function normalizePlayerMetadata(player) {
const playerIdentifier = String(player && player.identifier || '').trim();
@@ -3,15 +3,11 @@ const TASK = {
category: 'media-sync',
};
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
// Queue a one-off media sync for each live player when the web app starts up. We intentionally
// do the target selection here rather than inside the task itself so the task payload remains
// explicit and reusable for later retries.
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
const { fetchPlayerRegistrations, isRecentPlayerRegistration } = require('#src/data/player-registry');
function normalizePlayerMetadata(player) {
const playerIdentifier = String(player && player.identifier || '').trim();
+3 -8
View File
@@ -1,6 +1,7 @@
// Helpers for normalizing dashboard state and date values.
const { WebSocket } = require('ws');
const { isRecentPlayerRegistration } = require('#src/data/player-registry');
function normalizeClientName(value) {
return String(value || '').trim();
@@ -52,14 +53,8 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
});
}
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;
}
// Only build launcher entries for recent registrations with a public URL. This keeps the kiosk
// dashboard list focused on active devices and avoids presenting stale players as launch targets.
function buildKioskLauncherPlayers(playerRegistrations, staleSeconds) {
return (Array.isArray(playerRegistrations) ? playerRegistrations : [])
.filter(function (player) {
+3 -3
View File
@@ -1,9 +1,9 @@
// Media helper entry point for upload sync and thumbnail capture.
module.exports = {
createUploadSyncService: require('./upload-sync').createUploadSyncService,
createUploadSyncService: require('#src/web/lib/media/upload-sync').createUploadSyncService,
captureSlideThumbnail: function captureSlideThumbnail(options) {
return require('./slide-thumbnails').captureSlideThumbnail(options);
return require('#src/web/lib/media/slide-thumbnails').captureSlideThumbnail(options);
},
fontLibrary: require('./font-library')
fontLibrary: require('#src/web/lib/media/font-library')
};
+124
View File
@@ -2,6 +2,8 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const sharp = require('sharp');
const MEDIA_MIME_TYPES = {
'.gif': 'image/gif',
@@ -17,6 +19,121 @@ const MEDIA_MIME_TYPES = {
'.webm': 'video/webm'
};
const OPTIMIZABLE_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384];
function getImageVariantPath(filePath, dimension) {
const extension = path.extname(filePath);
const baseName = path.basename(filePath, extension);
return path.join(path.dirname(filePath), baseName + '.variant-' + dimension + extension);
}
async function removeImageVariants(filePath) {
const normalizedPath = String(filePath || '').trim();
const extension = path.extname(normalizedPath).toLowerCase();
if (!normalizedPath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
return;
}
await Promise.all(IMAGE_VARIANT_DIMENSIONS.map(function (dimension) {
return fs.promises.unlink(getImageVariantPath(normalizedPath, dimension)).catch(function (error) {
if (error && error.code !== 'ENOENT') {
throw error;
}
});
}));
}
async function createImageVariants(file) {
const filePath = String(file && file.path || '').trim();
const extension = path.extname(filePath).toLowerCase();
if (!filePath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
return [];
}
const maxDimension = Math.max.apply(null, IMAGE_VARIANT_DIMENSIONS);
const originalStats = await fs.promises.stat(filePath);
const metadata = await sharp(filePath, { failOn: 'none' }).metadata();
const sourceDimension = Math.max(Number(metadata.width) || 0, Number(metadata.height) || 0);
const variants = [];
for (const dimension of IMAGE_VARIANT_DIMENSIONS) {
if (dimension > maxDimension || dimension >= sourceDimension) {
continue;
}
const transformer = sharp(filePath, { failOn: 'none' })
.rotate()
.resize({ width: dimension, height: dimension, fit: 'inside', withoutEnlargement: true });
if (extension === '.jpg' || extension === '.jpeg') {
transformer.jpeg({ quality: 82, progressive: true, mozjpeg: true });
} else if (extension === '.png') {
transformer.png({ compressionLevel: 9, adaptiveFiltering: true });
} else {
transformer.webp({ quality: 82, effort: 5 });
}
const variantBuffer = await transformer.toBuffer();
if (!variantBuffer.length || variantBuffer.length >= originalStats.size) {
continue;
}
const variantPath = getImageVariantPath(filePath, dimension);
const temporaryPath = variantPath + '.partial-' + crypto.randomUUID();
try {
await fs.promises.writeFile(temporaryPath, variantBuffer);
await fs.promises.rename(temporaryPath, variantPath);
variants.push({
width: dimension,
path: '/media/uploads/' + path.basename(variantPath),
fileSize: variantBuffer.length
});
} finally {
await fs.promises.unlink(temporaryPath).catch(function () {});
}
}
return variants;
}
async function optimizeUploadedImage(file, options) {
const filePath = String(file && file.path || '').trim();
const extension = path.extname(filePath).toLowerCase();
if (!filePath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
return file;
}
const requestedMaxDimension = Number(options && options.maxDimension);
const maxDimension = Number.isInteger(requestedMaxDimension) && requestedMaxDimension > 0
? requestedMaxDimension
: 4096;
const originalStats = await fs.promises.stat(filePath);
const transformer = sharp(filePath, { failOn: 'none' })
.rotate()
.resize({ width: maxDimension, height: maxDimension, fit: 'inside', withoutEnlargement: true });
if (extension === '.jpg' || extension === '.jpeg') {
transformer.jpeg({ quality: 82, progressive: true, mozjpeg: true });
} else if (extension === '.png') {
transformer.png({ compressionLevel: 9, adaptiveFiltering: true });
} else {
transformer.webp({ quality: 82, effort: 5 });
}
const optimizedBuffer = await transformer.toBuffer();
if (!optimizedBuffer.length || optimizedBuffer.length >= originalStats.size) {
return file;
}
const temporaryPath = filePath + '.optimized-' + crypto.randomUUID();
try {
await fs.promises.writeFile(temporaryPath, optimizedBuffer);
await fs.promises.rename(temporaryPath, filePath);
file.size = optimizedBuffer.length;
return file;
} finally {
await fs.promises.unlink(temporaryPath).catch(function () {});
}
}
function normalizeMediaPath(value) {
const mediaPath = String(value || '').trim();
return mediaPath.startsWith('/media/') ? mediaPath : null;
@@ -103,6 +220,9 @@ async function syncMediaAssetsFromDirectory(pool, uploadDir, userId) {
continue;
}
const originalName = String(entry.name || '').trim();
if (/\.variant-\d+\.(?:jpe?g|png|webp)$/i.test(originalName)) {
continue;
}
const mimeType = MEDIA_MIME_TYPES[path.extname(originalName).toLowerCase()];
if (!mimeType) {
continue;
@@ -163,11 +283,15 @@ async function removePendingMediaAssets(pool, uploadDir, mediaPaths) {
throw error;
}
});
await removeImageVariants(filePath);
}
}
module.exports = {
normalizeMediaPath,
createImageVariants,
removeImageVariants,
optimizeUploadedImage,
registerMediaAsset,
registerMediaAssets,
fetchMediaAssets,
@@ -156,6 +156,10 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
}
if (regionType === 'chart') {
return '<div class="slide-preview-region slide-preview-chart-region" style="' + region.baseStyle + 'position:relative;background:transparent;"><canvas data-chart-json="' + escapeHtml(JSON.stringify(regionContent || {})) + '" aria-label="' + escapeHtml(region.label || region.region_key || 'chart') + '"></canvas></div>';
}
return buildTextRegionMarkup(region, regionContent);
}
@@ -188,6 +192,7 @@ function buildThumbnailPreviewPayload(slide, options) {
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
apiSources: Array.isArray(options && options.apiSources) ? options.apiSources : [],
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl)
};
+74 -56
View File
@@ -1,53 +1,29 @@
// Upload sync helpers for mirroring web media to player storage.
// This service owns the upload queue and deferred player-target selection, while the shared
// player registry helper owns the repeated URL/heartbeat resolution rules used elsewhere.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const { createRequestAuthHeaders } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('./font-library');
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
const {
fetchPlayerRegistrations,
getConfiguredPlayerIdentifier,
isLocalLikeBaseUrl,
isRecentPlayerRegistration,
normalizeBaseUrl
} = require('#src/data/player-registry');
function normalizeUploadRoot(uploadDir) {
return path.resolve(String(uploadDir || '').trim());
}
function isLocalLikeBaseUrl(value) {
let host = '';
try {
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
} catch (_error) {
return false;
}
return host === 'localhost'
|| host === '127.0.0.1'
|| host === '::1'
|| host === 'host.docker.internal'
|| host === 'player'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url);
}
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
function createUploadSyncService(options) {
const pool = options && options.pool;
const common = options && options.common;
@@ -297,6 +273,30 @@ function createUploadSyncService(options) {
return path.join(mediaRoot, relativePath);
}
async function getUploadPathsWithVariants(uploadPath, uploadDir) {
const normalizedPath = normalizeUploadReference(uploadPath);
const sourcePath = resolveUploadFilePath(uploadDir, normalizedPath);
if (!normalizedPath || !sourcePath || !/\.(?:jpe?g|png|webp)$/i.test(normalizedPath)) {
return normalizedPath ? [normalizedPath] : [];
}
const extension = path.extname(sourcePath);
const baseName = path.basename(sourcePath, extension);
const directory = path.dirname(sourcePath);
let entries = [];
try {
entries = await fs.promises.readdir(directory, { withFileTypes: true });
} catch (_error) {
return [normalizedPath];
}
const variantPattern = new RegExp('^' + baseName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&') + '\\.variant-\\d+' + extension.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&') + '$', 'i');
return [normalizedPath].concat(entries
.filter(function (entry) { return entry && entry.isFile() && variantPattern.test(entry.name); })
.map(function (entry) { return normalizedPath.replace(/[^/]+$/, entry.name); })
.sort());
}
function collectUploadReferencesFromValue(value, refs) {
if (!value) {
return refs;
@@ -584,8 +584,8 @@ function createUploadSyncService(options) {
}
}
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
async function removeSingleUploadFileFromPlayer(uploadPath, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
if (!uploadPath) {
return false;
}
@@ -632,6 +632,17 @@ function createUploadSyncService(options) {
}
}
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
const uploadPaths = await getUploadPathsWithVariants(uploadPath, localUploadDir);
let success = true;
for (let index = 0; index < uploadPaths.length; index += 1) {
if (!await removeSingleUploadFileFromPlayer(uploadPaths[index], resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier)) {
success = false;
}
}
return success;
}
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir, preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
if (!shouldMirrorUploads(localUploadDir)) {
return;
@@ -640,19 +651,23 @@ function createUploadSyncService(options) {
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl);
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
for (let i = 0; i < uniqueRefs.length; i += 1) {
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier);
if (!success) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uniqueRefs[i],
uploadDir: localUploadDir,
playerIdentifier: preferredPlayerIdentifier,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl,
metadata: preferredPlayerIdentifier || preferredPlayerInternalBaseUrl ? {
playerIdentifier: preferredPlayerIdentifier || null,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl || null
} : null
});
const uploadPaths = await getUploadPathsWithVariants(uniqueRefs[i], localUploadDir);
for (let pathIndex = 0; pathIndex < uploadPaths.length; pathIndex += 1) {
const uploadPath = uploadPaths[pathIndex];
const success = await pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier);
if (!success) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uploadPath,
uploadDir: localUploadDir,
playerIdentifier: preferredPlayerIdentifier,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl,
metadata: preferredPlayerIdentifier || preferredPlayerInternalBaseUrl ? {
playerIdentifier: preferredPlayerIdentifier || null,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl || null
} : null
});
}
}
}
}
@@ -938,14 +953,17 @@ function createUploadSyncService(options) {
}).forEach(function (uploadPath) {
uploadRefs.add(uploadPath);
});
Array.from(uploadRefs).forEach(function (uploadPath) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uploadPath,
uploadDir: uploadDir,
metadata: playerMetadata
for (const uploadPath of uploadRefs) {
const uploadPaths = await getUploadPathsWithVariants(uploadPath, uploadDir);
uploadPaths.forEach(function (variantPath) {
queuePlayerUploadSync({
type: 'put',
uploadPath: variantPath,
uploadDir: uploadDir,
metadata: playerMetadata
});
});
});
}
fontLibraryOperations.forEach(function (operation) {
if (!operation || !operation.uploadPath) {
return;
+8 -33
View File
@@ -1,39 +1,14 @@
// Commands and synchronization actions forwarded from the web app to players.
// Keep the routing layer separate from the actual player device commands so the web app can
// resolve a target player URL without mixing in connection or device-specific logic.
const { createRequestAuthHeaders } = require('#src/request-auth');
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
function isLocalLikeBaseUrl(value) {
let host = '';
try {
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
} catch (_error) {
return false;
}
return host === 'localhost'
|| host === '127.0.0.1'
|| host === '::1'
|| host === 'host.docker.internal'
|| host === 'player'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
const {
getConfiguredPlayerIdentifier,
isLocalLikeBaseUrl,
isRecentPlayerRegistration,
normalizeBaseUrl
} = require('#src/data/player-registry');
function createPlayerActionService(options) {
const pool = options && options.pool;
+1 -1
View File
@@ -6,7 +6,7 @@ const path = require('path');
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js'];
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js', 'js/regions/chart-data.js', 'vendor/chart.js/chart.umd.js'];
function withAssetVersion(scriptPath, assetVersion) {
if (!assetVersion) {