Release 2.6.25
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m12s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 30s

This commit is contained in:
2026-08-10 01:04:49 +01:00
parent 4491c15215
commit f9425fc640
10 changed files with 62 additions and 61 deletions
+6
View File
@@ -2,6 +2,12 @@
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.25 - 2026-08-10
### Fixed
- Screen group edit now keeps the slug locked after creation, so existing screen group URLs remain stable.
## 2.6.24 - 2026-08-10 ## 2.6.24 - 2026-08-10
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.6.24", "version": "2.6.25",
"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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.6.24", "version": "2.6.25",
"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",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.6.24", "version": "2.6.25",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
@@ -189,6 +189,15 @@ function refresh() {
const nextActiveSlides = getActiveSlidesFrom(nextSlides); const nextActiveSlides = getActiveSlidesFrom(nextSlides);
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides); const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp); const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
if (window.initialData && typeof window.initialData === 'object') {
window.initialData.screen = data.screen || window.initialData.screen || null;
window.initialData.playlist = data.playlist || null;
window.initialData.slides = nextSlides;
window.initialData.rssFeeds = Array.isArray(data.rssFeeds) ? data.rssFeeds : [];
window.initialData.apiSources = Array.isArray(data.apiSources) ? data.apiSources : [];
window.initialData.timetableGroups = Array.isArray(data.timetableGroups) ? data.timetableGroups : [];
window.initialData.revision = nextSignature;
}
savePlaylistSnapshot({ savePlaylistSnapshot({
slides: nextSlides, slides: nextSlides,
signature: nextSignature, signature: nextSignature,
+9 -28
View File
@@ -26,10 +26,6 @@ module.exports = function registerManageRoutes(app, deps) {
return text.slice(0, limit); return text.slice(0, limit);
} }
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function normalizeExplicitPlayerBaseUrl(value) { function normalizeExplicitPlayerBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, ''); return String(value || '').trim().replace(/\/$/, '');
} }
@@ -246,39 +242,24 @@ module.exports = function registerManageRoutes(app, deps) {
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) { if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
return res.status(400).send('A screen with that name already exists.'); return res.status(400).send('A screen with that name already exists.');
} }
const slugInput = typeof common.validateMaxLength === 'function'
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
: readLimitedText(req.body.slug || '', 255);
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null; const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
const previousPlaylistId = screen.playlist_id; const previousPlaylistId = screen.playlist_id;
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id); const slug = String(screen.slug || '').trim();
const previousSlug = String(screen.slug || '').trim(); const previousSlug = String(screen.slug || '').trim();
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]); await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
if (previousPlaylistId !== playlistId && previousSlug) { if (previousPlaylistId !== playlistId && previousSlug) {
await notifyPlayerScreens([previousSlug], 'refresh'); await notifyPlayerScreens([previousSlug], 'refresh');
} }
if (previousSlug && previousSlug !== slug) { if (previousSlug && previousSlug !== slug) {
const previousScreenTargets = await pool.query( const previousConnections = await fetchLiveConnectionsForScreen(previousSlug);
`SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url const redirectPayload = {
FROM d_screens s command: 'redirect',
LEFT JOIN d_players p ON p.device_id = s.player_id url: `/screen/${encodeURIComponent(slug)}`
WHERE s.slug = ? };
LIMIT 1`, if (previousConnections.length) {
[previousSlug] await forwardPlayerCommandForConnections(previousSlug, previousConnections, redirectPayload);
);
const previousTargetRow = previousScreenTargets[0] && previousScreenTargets[0][0] || null;
const previousInternalBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.internal_base_url) || '';
const previousPublicBaseUrl = normalizeBaseUrl(previousTargetRow && previousTargetRow.public_base_url) || '';
if (previousInternalBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
await forwardPlayerCommandToBaseUrl(previousInternalBaseUrl, previousSlug, {
command: 'redirect',
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
});
} else { } else {
await forwardPlayerCommand(previousSlug, { await forwardPlayerCommand(previousSlug, redirectPayload);
command: 'redirect',
url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
});
} }
} }
redirectAfterSave(req, res, '/screens?edit=' + screen.id, { redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
+1 -1
View File
@@ -20,7 +20,7 @@
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="screen-slug" class="form-label">Slug</label> <label for="screen-slug" class="form-label">Slug</label>
<input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" maxlength="128" data-limit-text-length placeholder="front-desk-tv" /> <input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" maxlength="128" data-limit-text-length placeholder="front-desk-tv" {{#if isEdit}}disabled aria-disabled="true" title="Slug cannot be changed after creation"{{/if}} />
</div> </div>
<div class="col-12"> <div class="col-12">
<label for="screen-playlist" class="form-label">Playlist</label> <label for="screen-playlist" class="form-label">Playlist</label>
+11
View File
@@ -39,6 +39,11 @@ test('playlist refresh queues updates until the next slide transition', async ()
pausedRemainingMs: null, pausedRemainingMs: null,
app: null, app: null,
currentPlaylistEtag: '', currentPlaylistEtag: '',
initialData: {
rssFeeds: [{ id: 1 }],
apiSources: [{ id: 2 }],
timetableGroups: [{ id: 3 }]
},
activeSlidesCacheKey: '', activeSlidesCacheKey: '',
activeSlidesCacheValue: [], activeSlidesCacheValue: [],
renderCacheViewportKey: '', renderCacheViewportKey: '',
@@ -94,6 +99,9 @@ test('playlist refresh queues updates until the next slide transition', async ()
this.responseText = JSON.stringify({ this.responseText = JSON.stringify({
signature: 'next-signature', signature: 'next-signature',
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }], slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
rssFeeds: [{ id: 10 }],
apiSources: [{ id: 20 }],
timetableGroups: [{ id: 30 }],
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false } playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
}); });
if (typeof this.onreadystatechange === 'function') { if (typeof this.onreadystatechange === 'function') {
@@ -133,6 +141,9 @@ test('playlist refresh queues updates until the next slide transition', async ()
assert.equal(calls.showCurrent, 0); assert.equal(calls.showCurrent, 0);
assert.equal(sandbox.currentPlaylistSignature, 'old-signature'); assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
assert.equal(sandbox.slides[0].disable_audio, undefined); assert.equal(sandbox.slides[0].disable_audio, undefined);
assert.deepEqual(sandbox.initialData.rssFeeds, [{ id: 10 }]);
assert.deepEqual(sandbox.initialData.apiSources, [{ id: 20 }]);
assert.deepEqual(sandbox.initialData.timetableGroups, [{ id: 30 }]);
assert.equal(calls.logDebug.some((entry) => entry.includes('Unable to load screen playlist.')), false); assert.equal(calls.logDebug.some((entry) => entry.includes('Unable to load screen playlist.')), false);
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true); assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
}); });
+3 -28
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
const registerManageRoutes = require('../src/web/routes/admin/manage'); const registerManageRoutes = require('../src/web/routes/admin/manage');
test('screen update redirects and forwards redirect when the slug changes', async () => { test('screen update keeps the existing slug on edit', async () => {
const handlers = {}; const handlers = {};
const app = { const app = {
post(path, ...routeHandlers) { post(path, ...routeHandlers) {
@@ -14,37 +14,13 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
const pool = { const pool = {
async query(sql) { async query(sql) {
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url') && sql.includes('WHERE s.slug = ?')) {
return [[{
slug: 'alpha',
player_id: 'player-a',
public_base_url: 'http://player.local',
internal_base_url: 'http://player.internal'
}]];
}
if (sql.includes('SELECT id, name, slug, playlist_id')) { if (sql.includes('SELECT id, name, slug, playlist_id')) {
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]]; return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
} }
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) { if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?')) {
return [[
{
slug: 'alpha',
player_id: 'player-a',
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
slug: 'beta',
player_id: 'player-b',
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
]];
}
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?')) {
return [{ affectedRows: 1 }]; return [{ affectedRows: 1 }];
} }
return [[]]; throw new Error(`Unexpected SQL: ${sql}`);
} }
}; };
@@ -137,7 +113,6 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
assert.equal(res.redirectedTo, '/screens?edit=42'); assert.equal(res.redirectedTo, '/screens?edit=42');
assert.deepEqual(calls, [ assert.deepEqual(calls, [
{ kind: 'forwardPlayerCommandToBaseUrl', baseUrl: 'http://player.internal', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
{ kind: 'redirectAfterSave', url: '/screens?edit=42' } { kind: 'redirectAfterSave', url: '/screens?edit=42' }
]); ]);
}); });
+20 -1
View File
@@ -48,7 +48,22 @@ test('screen edit page includes shared launcher downloads and base player url',
pages, pages,
buildDashboardState: async () => ({ screens: [] }), buildDashboardState: async () => ({ screens: [] }),
getScreenDeleteBlockMessage: async () => '', getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [], getScreenConnections: async (slug) => {
if (slug !== 'demo-conference') {
return { connections: [] };
}
return {
connections: [
{
id: 'conn-1',
clientId: 'conn-1',
deviceId: 'device-1',
playerPublicBaseUrl: 'http://player.example/'
}
]
};
},
playerPublicBaseUrl: 'http://player.example', playerPublicBaseUrl: 'http://player.example',
requirePermission() { requirePermission() {
return function (_req, _res, next) { return function (_req, _res, next) {
@@ -265,6 +280,8 @@ test('screen edit page renders player urls as an adminlte table', async () => {
name: 'Demo Conference', name: 'Demo Conference',
slug: 'demo-conference', slug: 'demo-conference',
playlist_id: null, playlist_id: null,
slug_update_confirm_live_connection_count: 2,
slug_update_confirm_message: 'Are you sure you want to update the slug? This will refresh all screens using this slug.',
player_urls: [ player_urls: [
{ {
identifier: 'player-alpha', identifier: 'player-alpha',
@@ -283,6 +300,8 @@ test('screen edit page renders player urls as an adminlte table', async () => {
assert.match(html, /card-body table-responsive p-0/); assert.match(html, /card-body table-responsive p-0/);
assert.match(html, /table table-striped w-100 mb-0/); assert.match(html, /table table-striped w-100 mb-0/);
assert.match(html, /player-alpha/); assert.match(html, /player-alpha/);
assert.match(html, /<input[^>]+id="screen-slug"[^>]+disabled/);
assert.match(html, /Slug cannot be changed after creation/);
}); });
test('screen launcher downloads are shared and attached', async () => { test('screen launcher downloads are shared and attached', async () => {