Release 2.6.15

This commit is contained in:
2026-08-08 23:15:04 +01:00
parent 6c28a1c028
commit 49c72923b4
12 changed files with 177 additions and 48 deletions
+8
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
-25
View File
@@ -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', {
-11
View File
@@ -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;
}
+22 -2
View File
@@ -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) {
@@ -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),
+5 -4
View File
@@ -43,9 +43,9 @@
</section>
{{#if (hasPermission currentUser "dashboard.allow")}}
<div class="row pb-4">
<div class="row">
<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="dashboard-card-heading">
<h3 class="card-title">Quick actions</h3>
@@ -109,7 +109,7 @@
</div>
</div>
<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="dashboard-card-heading">
<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 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>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>
<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>
+14
View File
@@ -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"/);
+97
View File
@@ -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 });