diff --git a/CHANGELOG.md b/CHANGELOG.md index 613bcd0..38c2ef3 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.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 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index abd59ca..bf2612f 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.6.24", + "version": "2.6.25", "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 f6ab8b9..0c7f88c 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.6.24", + "version": "2.6.25", "private": false, "description": "Pulse Signage web and bridge application bundle", "main": "src/common.js", diff --git a/package.json b/package.json index 63de599..33a6e24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.6.24", + "version": "2.6.25", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/player/public/js/player-page-playback.js b/src/player/public/js/player-page-playback.js index 129f769..0e21088 100644 --- a/src/player/public/js/player-page-playback.js +++ b/src/player/public/js/player-page-playback.js @@ -189,6 +189,15 @@ function refresh() { const nextActiveSlides = getActiveSlidesFrom(nextSlides); const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides); 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({ slides: nextSlides, signature: nextSignature, diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js index b11eb53..77992b3 100644 --- a/src/web/routes/admin/manage.js +++ b/src/web/routes/admin/manage.js @@ -26,10 +26,6 @@ module.exports = function registerManageRoutes(app, deps) { return text.slice(0, limit); } - function normalizeBaseUrl(value) { - return String(value || '').trim().replace(/\/$/, ''); - } - function normalizeExplicitPlayerBaseUrl(value) { 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)) { 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 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(); 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) { await notifyPlayerScreens([previousSlug], 'refresh'); } if (previousSlug && previousSlug !== slug) { - const previousScreenTargets = await pool.query( - `SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url - FROM d_screens s - LEFT JOIN d_players p ON p.device_id = s.player_id - WHERE s.slug = ? - LIMIT 1`, - [previousSlug] - ); - 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)}` - }); + const previousConnections = await fetchLiveConnectionsForScreen(previousSlug); + const redirectPayload = { + command: 'redirect', + url: `/screen/${encodeURIComponent(slug)}` + }; + if (previousConnections.length) { + await forwardPlayerCommandForConnections(previousSlug, previousConnections, redirectPayload); } else { - await forwardPlayerCommand(previousSlug, { - command: 'redirect', - url: previousPublicBaseUrl ? `${previousPublicBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}` - }); + await forwardPlayerCommand(previousSlug, redirectPayload); } } redirectAfterSave(req, res, '/screens?edit=' + screen.id, { diff --git a/src/web/views/signage/screens/form.hbs b/src/web/views/signage/screens/form.hbs index 93a3368..e9a9002 100644 --- a/src/web/views/signage/screens/form.hbs +++ b/src/web/views/signage/screens/form.hbs @@ -20,7 +20,7 @@
- +
diff --git a/test/player-page-playback.test.js b/test/player-page-playback.test.js index 713b5c7..120b151 100644 --- a/test/player-page-playback.test.js +++ b/test/player-page-playback.test.js @@ -39,6 +39,11 @@ test('playlist refresh queues updates until the next slide transition', async () pausedRemainingMs: null, app: null, currentPlaylistEtag: '', + initialData: { + rssFeeds: [{ id: 1 }], + apiSources: [{ id: 2 }], + timetableGroups: [{ id: 3 }] + }, activeSlidesCacheKey: '', activeSlidesCacheValue: [], renderCacheViewportKey: '', @@ -94,6 +99,9 @@ test('playlist refresh queues updates until the next slide transition', async () this.responseText = JSON.stringify({ signature: 'next-signature', 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 } }); 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(sandbox.currentPlaylistSignature, 'old-signature'); 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('applying on next slide transition')), true); }); diff --git a/test/web-manage-routes.test.js b/test/web-manage-routes.test.js index 47ed1aa..595fd13 100644 --- a/test/web-manage-routes.test.js +++ b/test/web-manage-routes.test.js @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); 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 app = { post(path, ...routeHandlers) { @@ -14,37 +14,13 @@ test('screen update redirects and forwards redirect when the slug changes', asyn const pool = { 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')) { 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')) { - 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 = ?')) { + if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?')) { 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.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' } ]); }); diff --git a/test/web-screens-routes.test.js b/test/web-screens-routes.test.js index f4c2989..8903aed 100644 --- a/test/web-screens-routes.test.js +++ b/test/web-screens-routes.test.js @@ -48,7 +48,22 @@ test('screen edit page includes shared launcher downloads and base player url', pages, buildDashboardState: async () => ({ screens: [] }), 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', requirePermission() { return function (_req, _res, next) { @@ -265,6 +280,8 @@ test('screen edit page renders player urls as an adminlte table', async () => { name: 'Demo Conference', slug: 'demo-conference', 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: [ { 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, /table table-striped w-100 mb-0/); assert.match(html, /player-alpha/); + assert.match(html, /]+id="screen-slug"[^>]+disabled/); + assert.match(html, /Slug cannot be changed after creation/); }); test('screen launcher downloads are shared and attached', async () => {