Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c72923b4 | ||
|
|
6c28a1c028 |
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
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
|
## 2.6.13 - 2026-08-08
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.6.13",
|
"version": "2.6.15",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"main": "src/common.js",
|
"main": "src/common.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.6.13",
|
"version": "2.6.15",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"main": "src/common.js",
|
"main": "src/common.js",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.6.13",
|
"version": "2.6.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.6.13",
|
"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.13",
|
"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": {
|
||||||
|
|||||||
@@ -532,12 +532,6 @@ async function start() {
|
|||||||
return res.status(400).json({ error: 'Device ID is required.' });
|
return res.status(400).json({ error: 'Device ID is required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
logBridge('Forwarding media upload to player', {
|
|
||||||
deviceId: deviceId,
|
|
||||||
relativePath: relativePath,
|
|
||||||
contentLength: Buffer.isBuffer(req.body) ? req.body.length : 0
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
||||||
@@ -545,13 +539,6 @@ async function start() {
|
|||||||
bodyBase64: bodyBuffer.toString('base64')
|
bodyBase64: bodyBuffer.toString('base64')
|
||||||
}, deviceId);
|
}, deviceId);
|
||||||
|
|
||||||
logBridge('Player media upload completed', {
|
|
||||||
deviceId: deviceId,
|
|
||||||
relativePath: relativePath,
|
|
||||||
ok: Boolean(response && response.ok),
|
|
||||||
status: response && response.status ? response.status : null
|
|
||||||
});
|
|
||||||
|
|
||||||
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', {
|
logBridge('Player media upload failed', {
|
||||||
@@ -574,23 +561,11 @@ async function start() {
|
|||||||
return res.status(400).json({ error: 'Device ID is required.' });
|
return res.status(400).json({ error: 'Device ID is required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
logBridge('Forwarding media delete to player', {
|
|
||||||
deviceId: deviceId,
|
|
||||||
relativePath: relativePath
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await sendPlayerCommand({
|
const response = await sendPlayerCommand({
|
||||||
command: 'media-delete',
|
command: 'media-delete',
|
||||||
relativePath: relativePath
|
relativePath: relativePath
|
||||||
}, deviceId);
|
}, deviceId);
|
||||||
|
|
||||||
logBridge('Player media delete completed', {
|
|
||||||
deviceId: deviceId,
|
|
||||||
relativePath: relativePath,
|
|
||||||
ok: Boolean(response && response.ok),
|
|
||||||
status: response && response.status ? response.status : null
|
|
||||||
});
|
|
||||||
|
|
||||||
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', {
|
logBridge('Player media delete failed', {
|
||||||
|
|||||||
@@ -252,10 +252,6 @@ async function start() {
|
|||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
response.error = 'Invalid media path.';
|
response.error = 'Invalid media path.';
|
||||||
} else {
|
} else {
|
||||||
console.info('[player] Removing media file', {
|
|
||||||
relativePath: relativePath,
|
|
||||||
filePath: filePath
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -264,10 +260,6 @@ async function start() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
response.ok = true;
|
response.ok = true;
|
||||||
console.info('[player] Media file removed', {
|
|
||||||
relativePath: relativePath,
|
|
||||||
filePath: filePath
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) !== -1) {
|
} else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) !== -1) {
|
||||||
const screenSlug = String(payload.screenSlug || payload.slug || '').trim();
|
const screenSlug = String(payload.screenSlug || payload.slug || '').trim();
|
||||||
@@ -467,9 +459,6 @@ async function start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsedMessage && String(parsedMessage.type || '').trim() === 'registered') {
|
if (parsedMessage && String(parsedMessage.type || '').trim() === 'registered') {
|
||||||
console.info('[player] Bridge registration acknowledged', {
|
|
||||||
playerIdentifier: PLAYER_DEVICE_ID
|
|
||||||
});
|
|
||||||
sendHeartbeat();
|
sendHeartbeat();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,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';
|
||||||
@@ -462,16 +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: operation.metadata || null
|
metadata: metadata,
|
||||||
|
playerIdentifier: playerIdentifier || null,
|
||||||
|
playerInternalBaseUrl: playerInternalBaseUrl || null
|
||||||
});
|
});
|
||||||
|
|
||||||
schedulePendingPlayerUploadSyncFlush();
|
schedulePendingPlayerUploadSyncFlush();
|
||||||
@@ -592,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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -723,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() {
|
||||||
@@ -787,22 +844,27 @@ 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(playerMetadata && playerMetadata.playerIdentifier, playerMetadata && playerMetadata.playerInternalBaseUrl);
|
|
||||||
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, playerMetadata && playerMetadata.playerIdentifier);
|
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier);
|
||||||
@@ -811,20 +873,20 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
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;
|
||||||
@@ -892,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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"/);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user