Release 2.9.0

This commit is contained in:
2026-08-28 02:53:59 +01:00
parent 9097d45d6a
commit 3de0e89e94
67 changed files with 1767 additions and 98 deletions
+3 -3
View File
@@ -59,7 +59,7 @@ function buildAuthHeaders(source) {
async function fetchApiSourcesData(pool) {
const [apiSources] = await pool.query(
'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC'
'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC'
);
return { apiSources: apiSources };
@@ -67,7 +67,7 @@ async function fetchApiSourcesData(pool) {
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC',
selectSql: 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_api_sources',
searchColumns: ['name', 'api_url', 'last_pull_error'],
searchTerm: searchTerm,
@@ -91,7 +91,7 @@ async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, so
async function fetchApiSourceById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?',
'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?',
[id]
);
+2
View File
@@ -40,6 +40,8 @@ const SETTING_DEFINITIONS = [
,{ key: 'data-sources.rss_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' }
,{ key: 'data-sources.api_default_interval_value', type: 'integer', min: 1, defaultValue: 60 }
,{ key: 'data-sources.api_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' }
,{ key: 'weather.open_meteo_api_key', type: 'string', defaultValue: '' }
,{ key: 'weather.pirate_weather_api_key', type: 'string', defaultValue: '' }
];
const DEFINITIONS_BY_KEY = new Map(SETTING_DEFINITIONS.map(function (definition) {
+7
View File
@@ -7,6 +7,7 @@ const { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./timetables');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload } = require('./weather');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens');
const { fetchPlayerRegistrations } = require('./player-registry');
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
@@ -59,6 +60,12 @@ module.exports = {
buildRssFeedPayload,
fetchRssFeedItems,
replaceRssFeedItems,
fetchWeatherLocationsData,
fetchWeatherLocationsPage,
fetchWeatherLocationById,
fetchWeatherLocationSuggestions,
fetchWeatherLocationForecast,
buildWeatherLocationPayload,
fetchScreenById,
fetchScreenEditData,
fetchScreenPlayerUrls,
+3 -3
View File
@@ -17,7 +17,7 @@ function normalizeUpdateIntervalUnit(value) {
async function fetchRssFeedsData(pool) {
const [rssFeeds] = await pool.query(
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC'
'SELECT id, name, feed_url, enabled, update_interval_value, update_interval_unit, item_limit, last_pulled_at, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC'
);
return { rssFeeds: rssFeeds };
@@ -25,7 +25,7 @@ async function fetchRssFeedsData(pool) {
async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC',
selectSql: 'SELECT id, name, feed_url, enabled, update_interval_value, update_interval_unit, item_limit, last_pulled_at, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_rss_feeds',
searchColumns: ['name', 'feed_url'],
searchTerm: searchTerm,
@@ -48,7 +48,7 @@ async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sort
async function fetchRssFeedById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?',
'SELECT id, name, feed_url, enabled, update_interval_value, update_interval_unit, item_limit, last_pulled_at, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?',
[id]
);
+16 -1
View File
@@ -444,7 +444,22 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
font_size: style.font_size,
font_color: style.font_color
};
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api'].includes(String(region.region_type || '').trim())) {
} else if (region.region_type === 'weather') {
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const locationId = body[`region_weather_location_id_${region.id}`];
const submittedForecastMode = body[`region_weather_forecast_mode_${region.id}`];
const forecastMode = ['current', 'hourly'].includes(submittedForecastMode) ? submittedForecastMode : 'daily';
const style = getTextRegionStyle(body, region, existingContent);
content[region.region_key] = {
type: 'weather',
value: body[`region_text_${region.id}`] !== undefined ? String(body[`region_text_${region.id}`] || '') : String(current.value || ''),
weather_location_id: locationId === undefined || locationId === null ? (current.weather_location_id || null) : (locationId === '' ? null : Number(locationId)),
forecast_mode: body[`region_weather_forecast_mode_${region.id}`] === undefined ? (['current', 'hourly'].includes(current.forecast_mode) ? current.forecast_mode : 'daily') : forecastMode,
font_family: style.font_family,
font_size: style.font_size,
font_color: style.font_color
};
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather'].includes(String(region.region_type || '').trim())) {
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
const suffix = '_' + region.id;
const generic = {};
+67
View File
@@ -0,0 +1,67 @@
// Convert cached weather snapshots for display without refetching.
function convertTemperature(value, fromUnit, toUnit) {
const number = Number(value);
if (!Number.isFinite(number) || fromUnit === toUnit) return value;
const converted = toUnit === 'fahrenheit' ? number * 9 / 5 + 32 : (number - 32) * 5 / 9;
return Math.round(converted * 10) / 10;
}
function convertWind(value, fromUnit, toUnit) {
const number = Number(value);
if (!Number.isFinite(number) || fromUnit === toUnit) return value;
const metresPerSecond = fromUnit === 'mph' ? number * 0.44704 : fromUnit === 'kmh' ? number / 3.6 : number;
const converted = toUnit === 'mph' ? metresPerSecond / 0.44704 : toUnit === 'kmh' ? metresPerSecond * 3.6 : metresPerSecond;
return Math.round(converted * 10) / 10;
}
function convertPrecipitation(value, fromUnit, toUnit) {
const number = Number(value);
if (!Number.isFinite(number) || fromUnit === toUnit) return value;
const converted = toUnit === 'inch' ? number / 25.4 : number * 25.4;
return Math.round(converted * 100) / 100;
}
function temperatureUnitFromLabel(label) {
return /f/i.test(String(label || '')) ? 'fahrenheit' : 'celsius';
}
function windUnitFromLabel(label) {
const value = String(label || '').toLowerCase();
return value.includes('mph') ? 'mph' : value.includes('m/s') ? 'ms' : 'kmh';
}
function precipitationUnitFromLabel(label) {
return /in/i.test(String(label || '')) ? 'inch' : 'mm';
}
function convertField(data, fields, converter, fromUnit, toUnit) {
fields.forEach(function (field) {
if (data[field] === undefined || data[field] === null) return;
data[field] = Array.isArray(data[field])
? data[field].map(function (value) { return converter(value, fromUnit, toUnit); })
: converter(data[field], fromUnit, toUnit);
});
}
function convertWeatherSnapshot(snapshot, targetUnits) {
const source = snapshot && typeof snapshot === 'object' ? snapshot : {};
const target = Object.assign({ temperature: 'celsius', wind: 'kmh', precipitation: 'mm' }, targetUnits || {});
const result = JSON.parse(JSON.stringify(source));
const currentUnits = source.current_units || {};
const hourlyUnits = source.hourly_units || currentUnits;
const dailyUnits = source.daily_units || currentUnits;
convertField(result.current || {}, ['temperature_2m', 'apparent_temperature'], convertTemperature, temperatureUnitFromLabel(currentUnits.temperature_2m), target.temperature);
convertField(result.current || {}, ['wind_speed_10m'], convertWind, windUnitFromLabel(currentUnits.wind_speed_10m), target.wind);
convertField(result.current || {}, ['precipitation', 'rain'], convertPrecipitation, precipitationUnitFromLabel(currentUnits.precipitation), target.precipitation);
convertField(result.hourly || {}, ['temperature_2m'], convertTemperature, temperatureUnitFromLabel(hourlyUnits.temperature_2m), target.temperature);
convertField(result.hourly || {}, ['wind_speed_10m'], convertWind, windUnitFromLabel(hourlyUnits.wind_speed_10m), target.wind);
convertField(result.hourly || {}, ['precipitation'], convertPrecipitation, precipitationUnitFromLabel(hourlyUnits.precipitation), target.precipitation);
convertField(result.daily || {}, ['temperature_2m_max', 'temperature_2m_min'], convertTemperature, temperatureUnitFromLabel(dailyUnits.temperature_2m_max), target.temperature);
convertField(result.daily || {}, ['wind_speed_10m_max'], convertWind, windUnitFromLabel(dailyUnits.wind_speed_10m_max), target.wind);
convertField(result.daily || {}, ['precipitation_sum'], convertPrecipitation, precipitationUnitFromLabel(dailyUnits.precipitation_sum), target.precipitation);
return result;
}
module.exports = { convertWeatherSnapshot };
+138
View File
@@ -0,0 +1,138 @@
// Weather location data access and form normalization.
const { fetchPagedRows, validateMaxLength } = require('./utils');
const { fetchAppSettings } = require('./app-settings');
const NAME_MAX_LENGTH = 255;
const LOCATION_MAX_LENGTH = 255;
const TIMEZONE_MAX_LENGTH = 128;
const PROVIDERS = ['open-meteo', 'pirate-weather'];
const TEMPERATURE_UNITS = ['celsius', 'fahrenheit'];
const WIND_UNITS = ['kmh', 'mph', 'ms'];
const PRECIPITATION_UNITS = ['mm', 'inch'];
async function fetchWeatherLocationSuggestions(query) {
const search = validateMaxLength(query, LOCATION_MAX_LENGTH, 'Location search');
if (!search) {
return [];
}
const response = await fetch('https://geocoding-api.open-meteo.com/v1/search?name=' + encodeURIComponent(search) + '&count=8&language=en&format=json', {
headers: { Accept: 'application/json', 'User-Agent': 'Pulse Signage weather location lookup' }
});
if (!response.ok) {
throw new Error('Weather location lookup failed.');
}
const data = await response.json();
return (Array.isArray(data.results) ? data.results : []).map(function (result) {
return {
label: [result.name, result.admin1, result.country].filter(Boolean).join(', '),
latitude: Number(result.latitude),
longitude: Number(result.longitude),
timezone: String(result.timezone || '')
};
}).filter(function (result) {
return result.label && Number.isFinite(result.latitude) && Number.isFinite(result.longitude) && result.timezone;
});
}
function normalizeChoice(value, choices, fallback) {
const normalized = String(value || '').trim().toLowerCase();
return choices.includes(normalized) ? normalized : fallback;
}
async function fetchWeatherLocationsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, created_at, modified_at FROM i_weather_locations ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_weather_locations',
searchColumns: ['name', 'location_label', 'timezone', 'provider'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
location: 'location_label',
provider: 'provider',
interval: ['update_interval_value', 'update_interval_unit'],
last_pulled: 'last_pulled_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ weatherLocations: paged.rows }, paged);
}
async function fetchWeatherLocationsData(pool) {
const [rows] = await pool.query('SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_json, created_at, modified_at FROM i_weather_locations ORDER BY modified_at DESC, id DESC');
return { weatherLocations: rows };
}
async function fetchWeatherLocationById(pool, id) {
const [rows] = await pool.query('SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_json, created_at, modified_at, created_by, modified_by FROM i_weather_locations WHERE id = ?', [id]);
return rows[0] || null;
}
async function fetchWeatherLocationForecast(pool, location) {
const source = location || {};
const settings = await fetchAppSettings(pool);
const latitude = Number(source.latitude);
const longitude = Number(source.longitude);
const temperatureUnit = source.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius';
const windUnit = source.wind_unit === 'mph' ? 'mph' : source.wind_unit === 'ms' ? 'ms' : 'kmh';
const precipitationUnit = source.precipitation_unit === 'inch' ? 'inch' : 'mm';
let url;
let headers = { Accept: 'application/json', 'User-Agent': 'Pulse Signage weather reader' };
if (source.provider === 'pirate-weather') {
const apiKey = String(settings['weather.pirate_weather_api_key'] || '').trim();
if (!apiKey) throw new Error('Pirate Weather API key is not configured.');
url = 'https://api.pirateweather.net/forecast/' + encodeURIComponent(apiKey) + '/' + latitude + ',' + longitude + '?units=' + (temperatureUnit === 'fahrenheit' ? 'us' : 'si');
} else {
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), timezone: String(source.timezone || 'auto'), forecast_days: '7', current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,weather_code,wind_speed_10m,wind_direction_10m,uv_index,cloud_cover', hourly: 'temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,uv_index,cloud_cover', daily: 'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max,precipitation_sum,wind_speed_10m_max,uv_index_max,cloud_cover_mean', temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit });
const apiKey = String(settings['weather.open_meteo_api_key'] || '').trim();
if (apiKey) params.set('apikey', apiKey);
url = 'https://api.open-meteo.com/v1/forecast?' + params.toString();
}
const response = await fetch(url, { headers: headers });
if (!response.ok) throw new Error('Weather provider returned HTTP ' + response.status + '.');
const snapshot = await response.json();
return { snapshot: snapshot, responseJson: JSON.stringify(snapshot), fetchedAt: new Date() };
}
function buildWeatherLocationPayload(req, existingLocation) {
const body = req && req.body ? req.body : {};
const fallback = existingLocation || {};
const name = validateMaxLength(body.name || fallback.name || '', NAME_MAX_LENGTH, 'Weather location name');
const locationLabel = validateMaxLength(body.location_label || fallback.location_label || '', LOCATION_MAX_LENGTH, 'Location label');
const latitude = Number(body.latitude !== undefined ? body.latitude : fallback.latitude);
const longitude = Number(body.longitude !== undefined ? body.longitude : fallback.longitude);
const timezone = validateMaxLength(body.timezone || fallback.timezone || '', TIMEZONE_MAX_LENGTH, 'Timezone');
const provider = normalizeChoice(body.provider || fallback.provider, PROVIDERS, 'open-meteo');
const temperatureUnit = normalizeChoice(body.temperature_unit || fallback.temperature_unit, TEMPERATURE_UNITS, 'celsius');
const windUnit = normalizeChoice(body.wind_unit || fallback.wind_unit, WIND_UNITS, 'kmh');
const precipitationUnit = normalizeChoice(body.precipitation_unit || fallback.precipitation_unit, PRECIPITATION_UNITS, 'mm');
const updateIntervalValue = Number(body.update_interval_value || fallback.update_interval_value || 30);
const updateIntervalUnit = normalizeChoice(body.update_interval_unit || fallback.update_interval_unit, ['minutes', 'hours'], 'minutes');
if (!name || !locationLabel || !timezone) {
const error = new Error('Name, location label, and timezone are required.');
error.statusCode = 400;
throw error;
}
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90 || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
const error = new Error('Latitude must be between -90 and 90, and longitude must be between -180 and 180.');
error.statusCode = 400;
throw error;
}
if (!Number.isInteger(updateIntervalValue) || updateIntervalValue < 1 || updateIntervalValue > 1440) {
const error = new Error('Update interval must be a whole number between 1 and 1440.');
error.statusCode = 400;
throw error;
}
return { name, locationLabel, latitude, longitude, timezone, provider, temperatureUnit, windUnit, precipitationUnit, updateIntervalValue, updateIntervalUnit };
}
module.exports = { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload, PROVIDERS, TEMPERATURE_UNITS, WIND_UNITS, PRECIPITATION_UNITS };