From c0c05f76e3669507ce65a1d6201a94621cd09ccd Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sun, 9 Aug 2026 13:44:17 +0100 Subject: [PATCH] Make API and RSS refresh notifications change-only --- CHANGELOG.md | 2 +- src/web/lib/data-source-refresh.js | 42 ++++++- test/data-source-refresh.test.js | 180 +++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 test/data-source-refresh.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f5eb8..b83191f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Fixed -- API and RSS refresh jobs now notify affected player screens after a successful update, so API-backed slides pick up new data without waiting for a manual page reload. +- 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 diff --git a/src/web/lib/data-source-refresh.js b/src/web/lib/data-source-refresh.js index 7556d99..4bbebb0 100644 --- a/src/web/lib/data-source-refresh.js +++ b/src/web/lib/data-source-refresh.js @@ -69,6 +69,43 @@ async function notifyAffectedScreens(connection, common, notifyPlayerScreens, sl 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 { @@ -97,7 +134,7 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlay ); await connection.commit(); - if (!pullError) { + if (!pullError && hasApiSourceChanged(apiSource, responseDetails)) { try { await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'source_id', apiSource.id); } catch (notifyError) { @@ -129,12 +166,13 @@ 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) { + if (!pullError && rssFeedChanged) { try { await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'feed_id', rssFeedId); } catch (notifyError) { diff --git a/test/data-source-refresh.test.js b/test/data-source-refresh.test.js new file mode 100644 index 0000000..64a65e6 --- /dev/null +++ b/test/data-source-refresh.test.js @@ -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, []); +});