Compare commits

..
6 Commits
Author SHA1 Message Date
lzstealth 4491c15215 Release 2.6.24 2026-08-10 00:32:28 +01:00
lzstealth 08b06941a4 Adjust dashboard card spacing 2026-08-09 13:53:38 +01:00
lzstealth c0c05f76e3 Make API and RSS refresh notifications change-only 2026-08-09 13:44:17 +01:00
lzstealth 78a34e4105 Release 2.6.23 2026-08-09 13:38:30 +01:00
lzstealth 3c3864e6ac Sync build package versions 2026-08-09 13:06:55 +01:00
lzstealth 6d8bf0f5f0 Release v2.6.22 2026-08-09 13:06:02 +01:00
21 changed files with 613 additions and 29 deletions
+1
View File
@@ -3,6 +3,7 @@
## Versioning and releases ## Versioning and releases
- Treat `package.json` as the source of truth for the application version. - Treat `package.json` as the source of truth for the application version.
- Keep `package.json`, `build/package.player.json`, and `build/package.web.json` on the same version number.
- When the app version changes, update `CHANGELOG.md` in the same change. - When the app version changes, update `CHANGELOG.md` in the same change.
- Keep database migration versions aligned with the release they actually belong to. - Keep database migration versions aligned with the release they actually belong to.
- If only part of a migration batch belongs to a newer release, split that batch into a separate migration entry instead of relabeling the earlier release. - If only part of a migration batch belongs to a newer release, split that batch into a separate migration entry instead of relabeling the earlier release.
+24
View File
@@ -2,6 +2,30 @@
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.24 - 2026-08-10
### Fixed
- Deferred playlist updates now keep the current slide index when the next playlist snapshot is applied, so playback no longer jumps back to the first slide mid-cycle.
## 2.6.23 - 2026-08-09
### Fixed
- Dashboard quick-action and kiosk-launcher cards now use row spacing instead of extra card padding, so the layout stays consistent at large widths.
- API and RSS refresh jobs now notify affected player screens only when the refreshed data actually changes, so unchanged polls no longer trigger redundant player refreshes.
## 2.6.22 - 2026-08-09
### Changed
- Startup now logs the previously detected schema version, the current app version, and whether pending migrations exist.
### Fixed
- Direct-to-URL player sessions now generate and persist a stable onboarding device id in session storage, so connected screens can still be moved and renamed independently without going through the onboarding flow first.
- The connected-clients move action now tolerates rows that only have a live connection id, which keeps move operations working for screens that skipped onboarding.
## 2.6.21 - 2026-08-09 ## 2.6.21 - 2026-08-09
### Changed ### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.6.21", "version": "2.6.24",
"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.21", "version": "2.6.24",
"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.21", "version": "2.6.24",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
+3 -2
View File
@@ -1,5 +1,5 @@
const { version: appVersion } = require('#root/package.json'); const { version: appVersion } = require('#root/package.json');
const { compareVersions, detectSchemaVersion, recordSchemaVersion, runMigrations } = require('./migrations'); const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('./migrations');
// Snapshot only: keep this file aligned with the current schema state. // Snapshot only: keep this file aligned with the current schema state.
async function ensureSchema(pool, options) { async function ensureSchema(pool, options) {
@@ -16,7 +16,8 @@ async function ensureSchema(pool, options) {
try { try {
const currentVersion = await detectSchemaVersion(pool); const currentVersion = await detectSchemaVersion(pool);
const updateRequired = compareVersions(currentVersion, appVersion) < 0; const pendingMigrations = await getPendingMigrations(pool, { currentVersion: currentVersion });
const updateRequired = pendingMigrations.length > 0;
console.info('[schema] previous=' + currentVersion + ' current=' + appVersion + ' update=' + (updateRequired ? 'yes' : 'no')); console.info('[schema] previous=' + currentVersion + ' current=' + appVersion + ' update=' + (updateRequired ? 'yes' : 'no'));
+13 -6
View File
@@ -806,8 +806,7 @@ function compareVersions(leftVersion, rightVersion) {
return 0; return 0;
} }
async function runMigrations(pool, options) { async function getPendingMigrations(pool, options) {
// Only run migrations that are newer than the installed schema version and not beyond the app version.
const targetVersion = String(appVersion || '0.0.0').trim(); const targetVersion = String(appVersion || '0.0.0').trim();
const currentVersion = String(options && options.currentVersion || '0.0.0').trim(); const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id'); const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id');
@@ -828,15 +827,23 @@ async function runMigrations(pool, options) {
effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17'; effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17';
} }
for (const migration of VERSIONED_MIGRATIONS) { return VERSIONED_MIGRATIONS.filter(function (migration) {
if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) { return compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0;
await migration.run(pool); });
} }
async function runMigrations(pool, options) {
// Only run migrations that are newer than the installed schema version and not beyond the app version.
const pendingMigrations = await getPendingMigrations(pool, options);
for (const migration of pendingMigrations) {
await migration.run(pool);
} }
} }
module.exports = { module.exports = {
appVersion: appVersion, appVersion: appVersion,
getPendingMigrations: getPendingMigrations,
runMigrations: runMigrations, runMigrations: runMigrations,
detectSchemaVersion: detectSchemaVersion, detectSchemaVersion: detectSchemaVersion,
recordSchemaVersion: recordSchemaVersion, recordSchemaVersion: recordSchemaVersion,
+6 -1
View File
@@ -23,7 +23,12 @@
function getOnboardingDeviceId() { function getOnboardingDeviceId() {
try { try {
var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey); var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey);
return String(storedDeviceId || '').trim(); if (storedDeviceId) {
return String(storedDeviceId || '').trim();
}
var nextDeviceId = window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'device-' + Date.now() + '-' + Math.random().toString(16).slice(2);
setSessionStorageItem(onboardingDeviceIdStorageKey, nextDeviceId);
return String(nextDeviceId || '').trim();
} catch (_error) { } catch (_error) {
return ''; return '';
} }
+6 -1
View File
@@ -70,6 +70,7 @@ function applyPendingPlaylistUpdate() {
if (!pendingPlaylistUpdate) { if (!pendingPlaylistUpdate) {
return false; return false;
} }
var nextIndex = Number(index || 0);
slides = pendingPlaylistUpdate.slides; slides = pendingPlaylistUpdate.slides;
currentPlaylistSignature = pendingPlaylistUpdate.signature; currentPlaylistSignature = pendingPlaylistUpdate.signature;
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides; currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
@@ -80,7 +81,11 @@ function applyPendingPlaylistUpdate() {
templateLayoutCache = Object.create(null); templateLayoutCache = Object.create(null);
templateRenderPlanCache = Object.create(null); templateRenderPlanCache = Object.create(null);
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight; renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
index = 0; const activeSlides = getCurrentActiveSlides();
if (!Number.isFinite(nextIndex) || nextIndex < 0) {
nextIndex = 0;
}
index = activeSlides.length ? Math.min(nextIndex, activeSlides.length - 1) : 0;
logDebug('Applied updated playlist on slide transition.'); logDebug('Applied updated playlist on slide transition.');
return true; return true;
} }
+1
View File
@@ -207,6 +207,7 @@ async function start() {
mediaDir: webConfig.mediaDir, mediaDir: webConfig.mediaDir,
backgroundTaskQueue: backgroundTaskQueue, backgroundTaskQueue: backgroundTaskQueue,
webBootstrap: webBootstrap, webBootstrap: webBootstrap,
notifyPlayerScreens: notifyPlayerScreens,
loadCurrentUser: loadCurrentUser, loadCurrentUser: loadCurrentUser,
initializeBackgroundTasks: initializeBackgroundTasks, initializeBackgroundTasks: initializeBackgroundTasks,
captureSlideThumbnail: captureSlideThumbnail, captureSlideThumbnail: captureSlideThumbnail,
@@ -24,7 +24,7 @@ function registerDataSourceRefreshTask(options) {
if (!apiSource) { if (!apiSource) {
throw new Error('API source not found.'); throw new Error('API source not found.');
} }
return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null); return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null, options.notifyPlayerScreens);
} }
if (sourceType === 'rss-feed') { if (sourceType === 'rss-feed') {
@@ -32,7 +32,7 @@ function registerDataSourceRefreshTask(options) {
if (!rssFeed) { if (!rssFeed) {
throw new Error('RSS feed not found.'); throw new Error('RSS feed not found.');
} }
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null); return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null, options.notifyPlayerScreens);
} }
throw new Error('Unsupported data source refresh task.'); throw new Error('Unsupported data source refresh task.');
@@ -34,7 +34,7 @@ function registerRecurringDataSourceRefreshes(options) {
sourceName: apiSource.name sourceName: apiSource.name
}, },
run: function () { run: function () {
return refreshApiSource(pool, common, apiSource, null); return refreshApiSource(pool, common, apiSource, null, options.notifyPlayerScreens);
} }
}); });
}); });
@@ -52,7 +52,7 @@ function registerRecurringDataSourceRefreshes(options) {
sourceName: rssFeed.name sourceName: rssFeed.name
}, },
run: function () { run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null, options.notifyPlayerScreens);
} }
}); });
}); });
@@ -115,11 +115,11 @@ function createDataSourceTaskService(options) {
} }
async function refreshApiSourceInBackground(apiSourceId, actorId) { async function refreshApiSourceInBackground(apiSourceId, actorId) {
return refreshApiSource(pool, common, apiSourceId, actorId); return refreshApiSource(pool, common, apiSourceId, actorId, options.notifyPlayerScreens);
} }
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) { async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId); return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, options.notifyPlayerScreens);
} }
return { return {
@@ -33,13 +33,13 @@ function scheduleStartupDataSourceRefreshes(options) {
(apiSourcesData.apiSources || []).forEach(function (apiSource) { (apiSourcesData.apiSources || []).forEach(function (apiSource) {
startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () { startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () {
return refreshApiSource(pool, common, apiSource, null); return refreshApiSource(pool, common, apiSource, null, options.notifyPlayerScreens);
})); }));
}); });
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () { startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null, options.notifyPlayerScreens);
})); }));
}); });
+127 -2
View File
@@ -1,4 +1,112 @@
async function refreshApiSource(pool, common, apiSourceOrId, actorId) { async function getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId) {
const [slideRows] = await connection.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
const slideIds = [];
const seenSlideIds = new Set();
slideRows.forEach(function (row) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
if (!content || typeof content !== 'object') {
return;
}
const stack = [content];
while (stack.length) {
const value = stack.pop();
if (!value || typeof value !== 'object') {
continue;
}
if (Array.isArray(value)) {
value.forEach(function (item) {
stack.push(item);
});
continue;
}
if (Object.prototype.hasOwnProperty.call(value, slideMatchKey) && Number(value[slideMatchKey]) === Number(sourceId)) {
const slideId = Number(row.id);
if (Number.isFinite(slideId) && slideId > 0 && !seenSlideIds.has(slideId)) {
seenSlideIds.add(slideId);
slideIds.push(slideId);
}
break;
}
Object.keys(value).forEach(function (key) {
stack.push(value[key]);
});
}
});
if (!slideIds.length) {
return [];
}
const [screenRows] = await connection.query(
`SELECT DISTINCT s.slug
FROM d_screens s
JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
WHERE ps.slide_id IN (?)
AND s.slug IS NOT NULL`,
[slideIds]
);
return screenRows.map(function (row) {
return String(row.slug || '').trim();
}).filter(Boolean);
}
async function notifyAffectedScreens(connection, common, notifyPlayerScreens, slideMatchKey, sourceId) {
if (typeof notifyPlayerScreens !== 'function') {
return;
}
const slugs = await getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId);
if (!slugs.length) {
return;
}
await notifyPlayerScreens(slugs, 'refresh');
}
function normalizeSnapshotValue(value) {
return String(value || '').trim();
}
async function hasRssFeedChanged(connection, rssFeedId, items) {
const [rows] = await connection.query(
`SELECT item_json
FROM i_rss_feed_items
WHERE rss_feed_id = ?
ORDER BY position ASC, id ASC`,
[rssFeedId]
);
const currentSnapshots = (rows || []).map(function (row) {
return normalizeSnapshotValue(row && row.item_json);
});
const nextSnapshots = (Array.isArray(items) ? items : []).map(function (item) {
return normalizeSnapshotValue(JSON.stringify(item || {}));
});
if (currentSnapshots.length !== nextSnapshots.length) {
return true;
}
for (let index = 0; index < currentSnapshots.length; index += 1) {
if (currentSnapshots[index] !== nextSnapshots[index]) {
return true;
}
}
return false;
}
function hasApiSourceChanged(apiSource, responseDetails) {
return normalizeSnapshotValue(apiSource && apiSource.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson);
}
async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlayerScreens) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object' const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object'
@@ -25,6 +133,14 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id] [new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id]
); );
await connection.commit(); await connection.commit();
if (!pullError && hasApiSourceChanged(apiSource, responseDetails)) {
try {
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'source_id', apiSource.id);
} catch (notifyError) {
console.warn('[data-source-refresh] Unable to notify players after API source refresh ' + apiSource.id + ':', notifyError);
}
}
} catch (error) { } catch (error) {
try { try {
await connection.rollback(); await connection.rollback();
@@ -37,7 +153,7 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
} }
} }
async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId) { async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, notifyPlayerScreens) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
let updatedItems = []; let updatedItems = [];
@@ -50,11 +166,20 @@ async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actor
} }
await connection.beginTransaction(); await connection.beginTransaction();
const rssFeedChanged = await hasRssFeedChanged(connection, rssFeedId, updatedItems);
if (typeof common.replaceRssFeedItems === 'function') { if (typeof common.replaceRssFeedItems === 'function') {
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems); await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
} }
await connection.commit(); await connection.commit();
if (!pullError && rssFeedChanged) {
try {
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'feed_id', rssFeedId);
} catch (notifyError) {
console.warn('[data-source-refresh] Unable to notify players after RSS feed refresh ' + rssFeedId + ':', notifyError);
}
}
if (pullError) { if (pullError) {
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError); console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
} }
+1
View File
@@ -22,6 +22,7 @@ async function initializeWebServer(options) {
pool: pool, pool: pool,
common: common, common: common,
backgroundTaskQueue: backgroundTaskQueue, backgroundTaskQueue: backgroundTaskQueue,
notifyPlayerScreens: options && options.notifyPlayerScreens ? options.notifyPlayerScreens : null,
uploadSyncService: webBootstrap.uploadSyncService, uploadSyncService: webBootstrap.uploadSyncService,
captureSlideThumbnail: captureSlideThumbnail, captureSlideThumbnail: captureSlideThumbnail,
mediaDir: mediaDir, mediaDir: mediaDir,
+2 -2
View File
@@ -227,7 +227,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
} }
if (command === 'setclientname') { if (command === 'setclientname') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim(); const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim(); const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
if (!deviceId) { if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' }); return res.status(400).json({ error: 'Device ID is required' });
@@ -344,7 +344,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
} }
if (command === 'moveclient') { if (command === 'moveclient') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim(); const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim(); const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim(); const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
+4 -4
View File
@@ -44,8 +44,8 @@
{{#if (hasPermission currentUser "dashboard.allow")}} {{#if (hasPermission currentUser "dashboard.allow")}}
<div class="row"> <div class="row">
<div class="col-12 col-xl-8"> <div class="col-12 col-xl-8 mb-4">
<div class="card card-outline card-primary dashboard-actions-card pb-4"> <div class="card card-outline card-primary dashboard-actions-card">
<div class="card-header"> <div class="card-header">
<div class="dashboard-card-heading"> <div class="dashboard-card-heading">
<h3 class="card-title">Quick actions</h3> <h3 class="card-title">Quick actions</h3>
@@ -108,8 +108,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-xl-4"> <div class="col-12 col-xl-4 mb-4">
<div class="card card-outline card-secondary dashboard-launcher-card h-100 pb-4"> <div class="card card-outline card-secondary dashboard-launcher-card">
<div class="card-header"> <div class="card-header">
<div class="dashboard-card-heading"> <div class="dashboard-card-heading">
<h3 class="card-title">Kiosk launchers</h3> <h3 class="card-title">Kiosk launchers</h3>
+180
View File
@@ -0,0 +1,180 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { refreshApiSource, refreshRssFeed } = require('../src/web/lib/data-source-refresh');
function createConnection(options) {
const state = Object.assign({
rssItemSnapshots: [],
slideRows: [],
screenRows: []
}, options || {});
const calls = [];
return {
calls,
async beginTransaction() {
calls.push(['beginTransaction']);
},
async commit() {
calls.push(['commit']);
},
async rollback() {
calls.push(['rollback']);
},
release() {
calls.push(['release']);
},
async query(sql, params) {
calls.push(['query', sql, params]);
if (sql.includes('SELECT item_json')) {
return [state.rssItemSnapshots.map(function (itemJson) {
return { item_json: itemJson };
})];
}
if (sql.includes('SELECT id, content_json FROM c_slides')) {
return [state.slideRows];
}
if (sql.includes('SELECT DISTINCT s.slug')) {
return [state.screenRows];
}
return [[]];
}
};
}
test('refreshApiSource notifies players only when the API snapshot changes', async () => {
const connection = createConnection({
slideRows: [{ id: 10, content_json: JSON.stringify({ source_id: 7 }) }],
screenRows: [{ slug: 'screen-a' }]
});
const notifyCalls = [];
const pool = {
async getConnection() {
return connection;
}
};
const common = {
async fetchApiSourceResponse() {
return {
responseJson: JSON.stringify({ value: 'new' }, null, 2),
responseStatus: 200,
responseContentType: 'application/json'
};
},
parseJsonSafe(value) {
return JSON.parse(value);
}
};
await refreshApiSource(pool, common, {
id: 7,
last_response_json: JSON.stringify({ value: 'old' }, null, 2)
}, 42, async function (slugs, payload) {
notifyCalls.push({ slugs, payload });
});
assert.deepEqual(notifyCalls, [{ slugs: ['screen-a'], payload: 'refresh' }]);
});
test('refreshApiSource skips notifications when the API snapshot is unchanged', async () => {
const connection = createConnection({
slideRows: [{ id: 10, content_json: JSON.stringify({ source_id: 7 }) }],
screenRows: [{ slug: 'screen-a' }]
});
const notifyCalls = [];
const pool = {
async getConnection() {
return connection;
}
};
const snapshot = JSON.stringify({ value: 'same' }, null, 2);
const common = {
async fetchApiSourceResponse() {
return {
responseJson: snapshot,
responseStatus: 200,
responseContentType: 'application/json'
};
},
parseJsonSafe(value) {
return JSON.parse(value);
}
};
await refreshApiSource(pool, common, {
id: 7,
last_response_json: snapshot
}, 42, async function (slugs, payload) {
notifyCalls.push({ slugs, payload });
});
assert.deepEqual(notifyCalls, []);
});
test('refreshRssFeed notifies players only when the RSS items change', async () => {
const connection = createConnection({
rssItemSnapshots: [JSON.stringify({ title: 'Old item' })],
slideRows: [{ id: 11, content_json: JSON.stringify({ feed_id: 9 }) }],
screenRows: [{ slug: 'screen-b' }]
});
const notifyCalls = [];
const pool = {
async getConnection() {
return connection;
}
};
const common = {
async fetchRssFeedItems() {
return [{ title: 'New item' }];
},
async replaceRssFeedItems() {
return 1;
},
parseJsonSafe(value) {
return JSON.parse(value);
}
};
await refreshRssFeed(pool, common, 9, 'https://example.com/feed.xml', 10, 42, async function (slugs, payload) {
notifyCalls.push({ slugs, payload });
});
assert.deepEqual(notifyCalls, [{ slugs: ['screen-b'], payload: 'refresh' }]);
});
test('refreshRssFeed skips notifications when the RSS items are unchanged', async () => {
const snapshot = JSON.stringify({ title: 'Same item' });
const connection = createConnection({
rssItemSnapshots: [snapshot],
slideRows: [{ id: 11, content_json: JSON.stringify({ feed_id: 9 }) }],
screenRows: [{ slug: 'screen-b' }]
});
const notifyCalls = [];
const pool = {
async getConnection() {
return connection;
}
};
const common = {
async fetchRssFeedItems() {
return [{ title: 'Same item' }];
},
async replaceRssFeedItems() {
return 1;
},
parseJsonSafe(value) {
return JSON.parse(value);
}
};
await refreshRssFeed(pool, common, 9, 'https://example.com/feed.xml', 10, 42, async function (slugs, payload) {
notifyCalls.push({ slugs, payload });
});
assert.deepEqual(notifyCalls, []);
});
+116
View File
@@ -279,6 +279,122 @@ test('single-slide playlists re-render the active slide instead of refreshing af
assert.deepEqual(sandbox.slides, [{ id: 2, duration_seconds: 12 }]); assert.deepEqual(sandbox.slides, [{ id: 2, duration_seconds: 12 }]);
}); });
test('deferred playlist updates preserve the current slide index', async () => {
const sandbox = {
window: null,
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout,
clearTimeout,
console,
currentPlaylistSignature: 'old-signature',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
slug: 'test2',
pendingPlaylistUpdate: null,
slides: [
{ id: 1, duration_seconds: 12 },
{ id: 2, duration_seconds: 12 },
{ id: 3, duration_seconds: 12 }
],
lastRenderedSlide: { id: 2, duration_seconds: 12 },
index: 1,
timer: null,
slideExpiresAt: null,
pausedRemainingMs: null,
app: null,
currentPlaylistEtag: '',
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
renderCacheViewportKey: '',
slideMarkupCache: Object.create(null),
templateLayoutCache: Object.create(null),
templateRenderPlanCache: Object.create(null),
getCurrentActiveSlides() {
return sandbox.slides;
},
getPlaylistRevision(data) {
return data.signature;
},
normalizeSlide(slide) {
return slide;
},
getActiveSlidesFrom(slideList) {
return slideList;
},
savePlaylistSnapshot() {},
markRefreshHealthy() {},
setOfflineBannerVisible() {},
scheduleRefreshRetry() {},
syncWebpagePreloads() {},
syncRtmpWarmups() {},
clearActiveSlidesCache() {},
showCurrent() {},
sendCommandState() {},
logDebug() {}
};
sandbox.window = sandbox;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
const script = fs.readFileSync(scriptPath, 'utf8');
const prelude = `
var pendingPlaylistUpdate = null;
var slides = [
{ id: 1, duration_seconds: 12 },
{ id: 2, duration_seconds: 12 },
{ id: 3, duration_seconds: 12 }
];
var currentPlaylistSignature = 'old-signature';
var currentPlaylistFadeBetweenSlides = false;
var currentPlaylistSkipUnavailableRtmp = false;
var currentPlaylistEtag = '';
var lastRenderedSlide = { id: 2, duration_seconds: 12 };
var index = 1;
var timer = null;
var slideExpiresAt = null;
var pausedRemainingMs = null;
var app = null;
var activeSlidesCacheKey = '';
var activeSlidesCacheValue = [];
var renderCacheViewportKey = '';
var slideMarkupCache = Object.create(null);
var templateLayoutCache = Object.create(null);
var templateRenderPlanCache = Object.create(null);
`;
vm.runInNewContext(prelude + '\n' + script, sandbox, { filename: scriptPath });
sandbox.pendingPlaylistUpdate = {
slides: [
{ id: 10, duration_seconds: 12 },
{ id: 11, duration_seconds: 12 },
{ id: 12, duration_seconds: 12 }
],
signature: 'next-signature',
fadeBetweenSlides: false,
skipUnavailableRtmp: false
};
const applied = sandbox.applyPendingPlaylistUpdate();
assert.equal(applied, true);
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
assert.equal(sandbox.pendingPlaylistUpdate, null);
assert.deepEqual(sandbox.slides, [
{ id: 10, duration_seconds: 12 },
{ id: 11, duration_seconds: 12 },
{ id: 12, duration_seconds: 12 }
]);
assert.equal(sandbox.index, 1);
});
test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => { test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => {
const calls = { const calls = {
showCurrent: 0, showCurrent: 0,
+41
View File
@@ -296,3 +296,44 @@ test('onboarding device ids stay scoped to the tab session', () => {
assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'tab-device-id'); assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'tab-device-id');
assert.equal(localStorage.getItem('pulse-signage-player-device-id'), 'shared-device-id'); assert.equal(localStorage.getItem('pulse-signage-player-device-id'), 'shared-device-id');
}); });
test('player onboarding device ids are generated when missing', () => {
const sessionStorage = (() => {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
sessionStorage,
localStorage: sessionStorage,
crypto: {
randomUUID() {
return 'generated-device-id';
}
},
WebSocket: { OPEN: 1 },
sendCommandState() {}
};
sandbox.window = sandbox;
loadHtmlScript(path.join(__dirname, '..', 'src', 'player', 'player-client-name.script.html'), sandbox);
assert.equal(sandbox.getOnboardingDeviceId(), 'generated-device-id');
assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'generated-device-id');
});
+77
View File
@@ -0,0 +1,77 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { getPendingMigrations } = require('../src/db/migrations');
function createPool(responses) {
const queries = [];
return {
queries,
async query(sql, params) {
queries.push([sql, params]);
const text = String(sql);
for (const response of responses) {
if (response.match(text, params)) {
return response.result;
}
}
return [[]];
}
};
}
test('pending migrations are empty when the schema already matches the app version', async () => {
const pool = createPool([
{
match(sql) {
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
},
result: [[{ column_count: 0 }]]
},
{
match(_sql, params) {
return Array.isArray(params) && params[0] === 'i_schedule_groups';
},
result: [[{ table_count: 0 }]]
},
{
match(_sql, params) {
return Array.isArray(params) && params[0] === 'i_schedule_entries';
},
result: [[{ table_count: 0 }]]
}
]);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.21' });
assert.equal(pendingMigrations.length, 0);
});
test('pending migrations are reported when an older schema still needs scripts', async () => {
const pool = createPool([
{
match(sql) {
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
},
result: [[{ column_count: 0 }]]
},
{
match(_sql, params) {
return Array.isArray(params) && params[0] === 'i_schedule_groups';
},
result: [[{ table_count: 1 }]]
},
{
match(_sql, params) {
return Array.isArray(params) && params[0] === 'i_schedule_entries';
},
result: [[{ table_count: 1 }]]
}
]);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.17' });
assert.ok(pendingMigrations.length > 0);
});