Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9425fc640 | ||
|
|
4491c15215 | ||
|
|
08b06941a4 | ||
|
|
c0c05f76e3 | ||
|
|
78a34e4105 |
@@ -3,6 +3,7 @@
|
||||
## Versioning and releases
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-player",
|
||||
"version": "2.6.22",
|
||||
"version": "2.6.25",
|
||||
"private": false,
|
||||
"description": "Pulse Signage player application bundle",
|
||||
"main": "src/common.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-web",
|
||||
"version": "2.6.22",
|
||||
"version": "2.6.25",
|
||||
"private": false,
|
||||
"description": "Pulse Signage web and bridge application bundle",
|
||||
"main": "src/common.js",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.6.22",
|
||||
"version": "2.6.25",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
|
||||
@@ -70,6 +70,7 @@ function applyPendingPlaylistUpdate() {
|
||||
if (!pendingPlaylistUpdate) {
|
||||
return false;
|
||||
}
|
||||
var nextIndex = Number(index || 0);
|
||||
slides = pendingPlaylistUpdate.slides;
|
||||
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
||||
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
||||
@@ -80,7 +81,11 @@ function applyPendingPlaylistUpdate() {
|
||||
templateLayoutCache = Object.create(null);
|
||||
templateRenderPlanCache = Object.create(null);
|
||||
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.');
|
||||
return true;
|
||||
}
|
||||
@@ -184,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,
|
||||
|
||||
@@ -207,6 +207,7 @@ async function start() {
|
||||
mediaDir: webConfig.mediaDir,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
webBootstrap: webBootstrap,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
initializeBackgroundTasks: initializeBackgroundTasks,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
|
||||
@@ -24,7 +24,7 @@ function registerDataSourceRefreshTask(options) {
|
||||
if (!apiSource) {
|
||||
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') {
|
||||
@@ -32,7 +32,7 @@ function registerDataSourceRefreshTask(options) {
|
||||
if (!rssFeed) {
|
||||
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.');
|
||||
|
||||
@@ -34,7 +34,7 @@ function registerRecurringDataSourceRefreshes(options) {
|
||||
sourceName: apiSource.name
|
||||
},
|
||||
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
|
||||
},
|
||||
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) {
|
||||
return refreshApiSource(pool, common, apiSourceId, actorId);
|
||||
return refreshApiSource(pool, common, apiSourceId, actorId, options.notifyPlayerScreens);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -33,13 +33,13 @@ function scheduleStartupDataSourceRefreshes(options) {
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
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) {
|
||||
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);
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
try {
|
||||
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]
|
||||
);
|
||||
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) {
|
||||
try {
|
||||
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();
|
||||
try {
|
||||
let updatedItems = [];
|
||||
@@ -50,11 +166,20 @@ async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actor
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
const rssFeedChanged = await hasRssFeedChanged(connection, rssFeedId, updatedItems);
|
||||
if (typeof common.replaceRssFeedItems === 'function') {
|
||||
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||
}
|
||||
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) {
|
||||
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ async function initializeWebServer(options) {
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
notifyPlayerScreens: options && options.notifyPlayerScreens ? options.notifyPlayerScreens : null,
|
||||
uploadSyncService: webBootstrap.uploadSyncService,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
mediaDir: mediaDir,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
|
||||
{{#if (hasPermission currentUser "dashboard.allow")}}
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card card-outline card-primary dashboard-actions-card pb-4">
|
||||
<div class="col-12 col-xl-8 mb-4">
|
||||
<div class="card card-outline card-primary dashboard-actions-card">
|
||||
<div class="card-header">
|
||||
<div class="dashboard-card-heading">
|
||||
<h3 class="card-title">Quick actions</h3>
|
||||
@@ -108,8 +108,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card card-outline card-secondary dashboard-launcher-card h-100 pb-4">
|
||||
<div class="col-12 col-xl-4 mb-4">
|
||||
<div class="card card-outline card-secondary dashboard-launcher-card">
|
||||
<div class="card-header">
|
||||
<div class="dashboard-card-heading">
|
||||
<h3 class="card-title">Kiosk launchers</h3>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<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 class="col-12">
|
||||
<label for="screen-playlist" class="form-label">Playlist</label>
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -279,6 +290,122 @@ test('single-slide playlists re-render the active slide instead of refreshing af
|
||||
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 () => {
|
||||
const calls = {
|
||||
showCurrent: 0,
|
||||
|
||||
@@ -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' }
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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, /<input[^>]+id="screen-slug"[^>]+disabled/);
|
||||
assert.match(html, /Slug cannot be changed after creation/);
|
||||
});
|
||||
|
||||
test('screen launcher downloads are shared and attached', async () => {
|
||||
|
||||
Reference in New Issue
Block a user