// Refresh data sources and notify only the screens whose rendered data changed. 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); } function isDifferentHour(previousPulledAt, currentPulledAt) { const previous = new Date(previousPulledAt); const current = new Date(currentPulledAt); if (!Number.isFinite(previous.getTime())) { return false; } return previous.getFullYear() !== current.getFullYear() || previous.getMonth() !== current.getMonth() || previous.getDate() !== current.getDate() || previous.getHours() !== current.getHours(); } function hasWeatherLocationChanged(location, responseDetails, refreshedAt) { return normalizeSnapshotValue(location && location.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson) || isDifferentHour(location && location.last_pulled_at, refreshedAt); } async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlayerScreens) { const connection = await pool.getConnection(); try { const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object' ? apiSourceOrId : typeof common.fetchApiSourceById === 'function' ? await common.fetchApiSourceById(pool, Number(apiSourceOrId)) : null; if (!apiSource) { throw new Error('API source not found.'); } let responseDetails = null; let pullError = ''; try { responseDetails = await common.fetchApiSourceResponse(apiSource); } catch (error) { pullError = String(error && error.message ? error.message : 'Unable to load API response.'); } await connection.beginTransaction(); await connection.query( 'UPDATE i_api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?', [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(); } catch (_rollbackError) { // Ignore rollback failures and surface the original error. } throw error; } finally { connection.release(); } } async function refreshWeatherLocation(pool, common, weatherLocationOrId, actorId, notifyPlayerScreens) { const connection = await pool.getConnection(); try { const location = weatherLocationOrId && typeof weatherLocationOrId === 'object' ? weatherLocationOrId : await common.fetchWeatherLocationById(pool, Number(weatherLocationOrId)); if (!location) throw new Error('Weather location not found.'); let result = null; let pullError = ''; try { result = await common.fetchWeatherLocationForecast(pool, location); } catch (error) { pullError = String(error && error.message ? error.message : 'Unable to load weather forecast.'); } const refreshedAt = new Date(); await connection.beginTransaction(); await connection.query( 'UPDATE i_weather_locations SET last_pulled_at = ?, last_pull_error = ?, last_response_json = COALESCE(?, last_response_json), modified_by = ? WHERE id = ?', [refreshedAt, pullError || null, result ? result.responseJson : null, actorId, location.id] ); await connection.commit(); if (pullError) { console.error('[data-source-refresh] Weather location refresh completed with an error for location ' + location.id + ': ' + pullError); } else if (hasWeatherLocationChanged(location, result, refreshedAt) && typeof notifyPlayerScreens === 'function') { try { await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'weather_location_id', location.id); } catch (notifyError) { console.warn('[data-source-refresh] Unable to notify players after weather refresh ' + location.id + ':', notifyError); } } } catch (error) { try { await connection.rollback(); } catch (_rollbackError) { } throw error; } finally { connection.release(); } } async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, notifyPlayerScreens) { const connection = await pool.getConnection(); try { let updatedItems = []; let pullError = ''; try { updatedItems = await common.fetchRssFeedItems(feedUrl, itemLimit); } catch (error) { pullError = String(error && error.message ? error.message : 'Unable to load feed items.'); } await connection.beginTransaction(); const rssFeedChanged = await hasRssFeedChanged(connection, rssFeedId, updatedItems); if (typeof common.replaceRssFeedItems === 'function') { await common.replaceRssFeedItems(connection, rssFeedId, updatedItems); } await connection.query( 'UPDATE i_rss_feeds SET last_pulled_at = ? WHERE id = ?', [new Date(), rssFeedId] ); 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); } } catch (error) { try { await connection.rollback(); } catch (_rollbackError) { // Ignore rollback failures and surface the original error. } throw error; } finally { connection.release(); } } module.exports = { refreshApiSource: refreshApiSource, refreshRssFeed: refreshRssFeed, refreshWeatherLocation: refreshWeatherLocation };