Release 2.6.14

This commit is contained in:
2026-08-08 21:49:22 +01:00
parent c5172af62d
commit 6c28a1c028
6 changed files with 187 additions and 30 deletions
+6
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": {
+83 -21
View File
@@ -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,12 +763,29 @@ function createUploadSyncService(options) {
return;
}
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() {
if (pendingPlaylistUploadSyncFlushInFlight) {
return pendingPlaylistUploadSyncFlushInFlight;
@@ -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();
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 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.clear();
pendingPlayerUploadSyncRetryLogAt = 0;
return;
pendingPlayerUploadSyncs.delete(pendingKey);
continue;
}
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
? playerMetadata.playerInternalBaseUrl
: await getPlayerInternalBaseUrl(playerMetadata && playerMetadata.playerIdentifier, playerMetadata && playerMetadata.playerInternalBaseUrl);
let successCount = 0;
let failureCount = 0;
for (let i = 0; i < pendingEntries.length; i += 1) {
const operation = pendingEntries[i];
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) {
+89
View File
@@ -275,3 +275,92 @@ test('stale player registrations stop media sync retries and warnings', async ()
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 });
}
});