From 6c28a1c028067c5b0973b9de306d8691e2edbf1c Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sat, 8 Aug 2026 21:49:22 +0100 Subject: [PATCH] Release 2.6.14 --- CHANGELOG.md | 6 ++ build/package.player.json | 2 +- build/package.web.json | 2 +- package.json | 2 +- src/web/lib/media/upload-sync.js | 116 ++++++++++++++++++++++++------- test/upload-sync.test.js | 89 ++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 363913a..dc43d90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/build/package.player.json b/build/package.player.json index 604489e..1d125bf 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.6.13", + "version": "2.6.14", "private": false, "description": "Pulse Signage player application bundle", "main": "src/common.js", diff --git a/build/package.web.json b/build/package.web.json index a71e64e..21785da 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.6.13", + "version": "2.6.14", "private": false, "description": "Pulse Signage web and bridge application bundle", "main": "src/common.js", diff --git a/package.json b/package.json index e44513e..2b8994f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.6.13", + "version": "2.6.14", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 098ecd7..2abc674 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -453,6 +453,23 @@ function createUploadSyncService(options) { 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) { const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase(); return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT'; @@ -462,16 +479,33 @@ function createUploadSyncService(options) { 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) { if (!operation || !operation.uploadPath) { 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', uploadPath: normalizeUploadReference(operation.uploadPath), uploadDir: operation.uploadDir || null, - metadata: operation.metadata || null + metadata: metadata, + playerIdentifier: playerIdentifier || null, + playerInternalBaseUrl: playerInternalBaseUrl || null }); schedulePendingPlayerUploadSyncFlush(); @@ -592,20 +626,26 @@ function createUploadSyncService(options) { } } - async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) { + async function syncUploadRefsToPlayer(uploadRefs, localUploadDir, preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) { if (!shouldMirrorUploads(localUploadDir)) { return; } - const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); + 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); + const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier); if (!success) { queuePlayerUploadSync({ type: 'put', 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 queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', { - mode: 'playlist', - operation: operation - }); + const players = await fetchLivePlayerRegistrations(); + if (!players.length) { + 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() { @@ -787,22 +844,27 @@ function createUploadSyncService(options) { } pendingPlayerUploadSyncFlushInFlight = (async function () { - const pendingEntries = Array.from(pendingPlayerUploadSyncs.values()); - const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata - ? pendingEntries[0].metadata - : await getPlayerTaskMetadata(); - if (playerMetadata && playerMetadata.playerActive === false) { - pendingPlayerUploadSyncs.clear(); - pendingPlayerUploadSyncRetryLogAt = 0; - return; - } - const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl - ? playerMetadata.playerInternalBaseUrl - : await getPlayerInternalBaseUrl(playerMetadata && playerMetadata.playerIdentifier, playerMetadata && playerMetadata.playerInternalBaseUrl); + const pendingEntries = Array.from(pendingPlayerUploadSyncs.entries()); + const firstOperation = pendingEntries.length && pendingEntries[0] ? pendingEntries[0][1] : null; + const summaryPlayerMetadata = firstOperation && firstOperation.metadata + ? firstOperation.metadata + : await getPlayerTaskMetadata(firstOperation && firstOperation.playerIdentifier, firstOperation && firstOperation.playerInternalBaseUrl); let successCount = 0; let failureCount = 0; 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; if (operation.type === 'delete') { success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier); @@ -811,20 +873,20 @@ function createUploadSyncService(options) { } if (success) { successCount += 1; - pendingPlayerUploadSyncs.delete(operation.uploadPath); + pendingPlayerUploadSyncs.delete(pendingKey); } else { failureCount += 1; } } 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) { const now = Date.now(); if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) { pendingPlayerUploadSyncRetryLogAt = now; - logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, playerMetadata); + logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, summaryPlayerMetadata); } } else if (!pendingPlayerUploadSyncs.size) { pendingPlayerUploadSyncRetryLogAt = 0; @@ -892,7 +954,7 @@ function createUploadSyncService(options) { const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload); if (operation.nextUploadRefs.length) { - await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir); + await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl); } if (operation.previousUploadRefs.length) { diff --git a/test/upload-sync.test.js b/test/upload-sync.test.js index c670ecc..75a2d58 100644 --- a/test/upload-sync.test.js +++ b/test/upload-sync.test.js @@ -274,4 +274,93 @@ test('stale player registrations stop media sync retries and warnings', async () console.warn = originalWarn; 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 }); + } }); \ No newline at end of file