diff --git a/CHANGELOG.md b/CHANGELOG.md index dc43d90..97c35f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ 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 diff --git a/build/package.player.json b/build/package.player.json index 1d125bf..d0bdea5 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.6.14", + "version": "2.6.15", "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 21785da..83df718 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.6.14", + "version": "2.6.15", "private": false, "description": "Pulse Signage web and bridge application bundle", "main": "src/common.js", diff --git a/package-lock.json b/package-lock.json index 6571521..bed175e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.6.13", + "version": "2.6.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.6.13", + "version": "2.6.15", "dependencies": { "@sparticuz/chromium": "^137.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 2b8994f..ccd8ad2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.6.14", + "version": "2.6.15", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js index 681012e..c18c193 100644 --- a/src/player-bridge/index.js +++ b/src/player-bridge/index.js @@ -532,12 +532,6 @@ async function start() { 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 response = await sendPlayerCommand({ command: 'media-put', @@ -545,13 +539,6 @@ async function start() { bodyBase64: bodyBuffer.toString('base64') }, 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); } catch (error) { logBridge('Player media upload failed', { @@ -574,23 +561,11 @@ async function start() { return res.status(400).json({ error: 'Device ID is required.' }); } - logBridge('Forwarding media delete to player', { - deviceId: deviceId, - relativePath: relativePath - }); - const response = await sendPlayerCommand({ command: 'media-delete', relativePath: relativePath }, 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); } catch (error) { logBridge('Player media delete failed', { diff --git a/src/player.js b/src/player.js index 25cabd7..12f82e8 100644 --- a/src/player.js +++ b/src/player.js @@ -252,10 +252,6 @@ async function start() { if (!filePath) { response.error = 'Invalid media path.'; } else { - console.info('[player] Removing media file', { - relativePath: relativePath, - filePath: filePath - }); try { await fs.promises.unlink(filePath); } catch (error) { @@ -264,10 +260,6 @@ async function start() { } } 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) { const screenSlug = String(payload.screenSlug || payload.slug || '').trim(); @@ -467,9 +459,6 @@ async function start() { } if (parsedMessage && String(parsedMessage.type || '').trim() === 'registered') { - console.info('[player] Bridge registration acknowledged', { - playerIdentifier: PLAYER_DEVICE_ID - }); sendHeartbeat(); return; } diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 2abc674..65d7001 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -959,9 +959,29 @@ function createUploadSyncService(options) { if (operation.previousUploadRefs.length) { 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); - })); + }); + 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) { diff --git a/src/web/routes/settings/background-tasks-page.js b/src/web/routes/settings/background-tasks-page.js index 8a4ef69..444c64d 100644 --- a/src/web/routes/settings/background-tasks-page.js +++ b/src/web/routes/settings/background-tasks-page.js @@ -167,6 +167,31 @@ function buildTaskPlayerLabel(metadata) { 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) { const tasks = (data && data.tasks) || []; 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 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, { sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter), diff --git a/src/web/views/signage/dashboard/index.hbs b/src/web/views/signage/dashboard/index.hbs index d02c83b..ad23372 100644 --- a/src/web/views/signage/dashboard/index.hbs +++ b/src/web/views/signage/dashboard/index.hbs @@ -43,9 +43,9 @@ {{#if (hasPermission currentUser "dashboard.allow")}} -
These downloads are scripts, not applications: the Windows download is a .bat file and the Linux download is a .sh file.
diff --git a/test/background-tasks-page.test.js b/test/background-tasks-page.test.js index 02bf4f0..ef396a8 100644 --- a/test/background-tasks-page.test.js +++ b/test/background-tasks-page.test.js @@ -23,6 +23,18 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks, 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', 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-local-datetime-format="24h"/); 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.match(queueDateSearchHtml, /Example task/); assert.match(scheduledHtml, /data-local-datetime-format="24h"/); diff --git a/test/upload-sync.test.js b/test/upload-sync.test.js index 75a2d58..e4e725b 100644 --- a/test/upload-sync.test.js +++ b/test/upload-sync.test.js @@ -276,6 +276,103 @@ test('stale player registrations stop media sync retries and warnings', async () } }); +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 });