Files
pulse-signage/src/web/lib/player-actions.js
T
2026-08-08 13:02:31 +01:00

366 lines
13 KiB
JavaScript

const { createRequestAuthHeaders } = require('#src/request-auth');
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
function isLocalLikeBaseUrl(value) {
let host = '';
try {
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
} catch (_error) {
return false;
}
return host === 'localhost'
|| host === '127.0.0.1'
|| host === '::1'
|| host === 'host.docker.internal'
|| host === 'player'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
function createPlayerActionService(options) {
const pool = options && options.pool;
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const configuredBridgeInternalBaseUrl = String(options && options.bridgeInternalBaseUrl || '').trim().replace(/\/$/, '');
const common = options && options.common;
if (!common) {
throw new Error('createPlayerActionService requires the player action dependencies.');
}
let playerInternalBaseUrl = null;
let playerInternalBaseUrlPromise = null;
async function getPlayerInternalBaseUrl() {
if (playerInternalBaseUrl) {
return playerInternalBaseUrl;
}
if (playerInternalBaseUrlPromise) {
return playerInternalBaseUrlPromise;
}
playerInternalBaseUrlPromise = (async function () {
try {
if (pool && typeof fetchPlayerRegistrations === 'function') {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const players = await fetchPlayerRegistrations(pool);
const exactPlayer = Array.isArray(players)
? players.find(function (player) {
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
})
: null;
const registeredPlayers = Array.isArray(players) ? players : [];
const preferredPlayer = exactPlayer || registeredPlayers.find(function (player) {
const internalBaseUrl = normalizeBaseUrl(player && player.internal_base_url);
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
}) || registeredPlayers[0] || null;
const resolvedBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.internal_base_url);
if (resolvedBaseUrl) {
playerInternalBaseUrl = resolvedBaseUrl;
return resolvedBaseUrl;
}
}
} catch (_error) {
}
if (configuredPlayerInternalBaseUrl) {
playerInternalBaseUrl = configuredPlayerInternalBaseUrl;
return configuredPlayerInternalBaseUrl;
}
return null;
})().then(function (baseUrl) {
playerInternalBaseUrlPromise = null;
return baseUrl || null;
}, function () {
playerInternalBaseUrlPromise = null;
return configuredPlayerInternalBaseUrl || null;
});
return playerInternalBaseUrlPromise;
}
async function forwardPlayerCommandToBaseUrl(baseUrl, slug, commandOrPayload, connectionId) {
const targetBaseUrl = normalizeBaseUrl(baseUrl);
if (!targetBaseUrl) {
throw new Error('Unable to resolve the player internal base URL.');
}
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
: { command: commandOrPayload };
if (connectionId) {
payload.connectionId = connectionId;
}
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
body: payload
});
const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...authHeaders
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to send command to player ${slug}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return { ok: true };
});
}
async function fetchScreenConnectionsFromBaseUrl(baseUrl, slug) {
const targetBaseUrl = normalizeBaseUrl(baseUrl);
if (!targetBaseUrl) {
return null;
}
const authHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
});
const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
method: 'GET',
headers: {
Accept: 'application/json',
...authHeaders
}
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to fetch connections for player ${slug}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return null;
});
}
async function forwardPlayerCommandToDevice(deviceId, commandOrPayload) {
const targetDeviceId = String(deviceId || '').trim();
const targetBridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl);
if (!targetDeviceId) {
throw new Error('Device ID is required.');
}
if (!targetBridgeBaseUrl) {
throw new Error('Unable to resolve the player bridge base URL.');
}
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
: { command: commandOrPayload };
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: `/api/players/${encodeURIComponent(targetDeviceId)}/commands`,
body: payload
});
const response = await fetch(`${targetBridgeBaseUrl}/api/players/${encodeURIComponent(targetDeviceId)}/commands`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...authHeaders
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to send command to player ${targetDeviceId}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return { ok: true };
});
}
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId);
}
async function forwardAnnouncementRefresh(slug) {
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: `/api/screens/${encodeURIComponent(slug)}/announcements/refresh`,
body: { command: 'announcement-refresh' }
});
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
if (!resolvedPlayerInternalBaseUrl) {
throw new Error('Unable to resolve the player internal base URL.');
}
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/announcements/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...authHeaders
},
body: JSON.stringify({ command: 'announcement-refresh' })
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to refresh announcements for player ${slug}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return { ok: true };
});
}
async function getScreenConnections(slug) {
const bridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl);
const playerBaseUrl = await getPlayerInternalBaseUrl();
const targetBaseUrls = Array.from(new Set([bridgeBaseUrl, playerBaseUrl].map(normalizeBaseUrl).filter(Boolean)));
if (!targetBaseUrls.length) {
throw new Error('Unable to resolve the player internal base URL.');
}
const results = await Promise.allSettled(targetBaseUrls.map(function (baseUrl) {
return fetchScreenConnectionsFromBaseUrl(baseUrl, slug);
}));
const successfulResults = results.filter(function (result) {
return result.status === 'fulfilled' && result.value;
}).map(function (result) {
return result.value;
});
if (!successfulResults.length) {
const rejection = results.find(function (result) {
return result.status === 'rejected';
});
throw rejection ? rejection.reason : new Error(`Unable to fetch connections for player ${slug}.`);
}
const mergedConnections = [];
const seenKeys = new Set();
successfulResults.forEach(function (result) {
const connections = Array.isArray(result && result.connections) ? result.connections : [];
connections.forEach(function (connection) {
const key = [
String(connection && connection.id || '').trim(),
String(connection && connection.clientId || '').trim(),
String(connection && connection.deviceId || '').trim(),
String(connection && connection.playerPublicBaseUrl || '').trim()
].join('|');
if (!key || seenKeys.has(key)) {
return;
}
seenKeys.add(key);
mergedConnections.push(connection);
});
});
return {
screen: successfulResults.find(function (result) {
return Boolean(result && result.screen);
}) ? successfulResults.find(function (result) {
return Boolean(result && result.screen);
}).screen : null,
screenSlug: slug,
count: mergedConnections.length,
connections: mergedConnections,
degraded: results.some(function (result) {
return result.status === 'fulfilled' && Boolean(result.value && result.value.degraded);
})
};
}
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM d_onboarding_devices WHERE screen_id = ?', [screen.id]);
if (Number(rows[0] && rows[0].ref_count) > 0) {
return 'This screen is still linked to onboarding devices.';
}
if (typeof getScreenConnections === 'function' && String(screen && screen.slug ? screen.slug : '').trim()) {
try {
const response = await getScreenConnections(screen.slug);
const liveConnections = Array.isArray(response && response.connections) ? response.connections : [];
if (liveConnections.length > 0) {
return 'This screen is still in use by connected players.';
}
} catch (_error) {
// Keep the delete guard based on onboarding references if live connection lookup fails.
}
}
return '';
}
async function getSlideDeleteBlockMessage(pool, slide) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_playlist_slides WHERE slide_id = ?', [slide.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This slide is still used by one or more playlists.' : '';
}
async function getTemplateDeleteBlockMessage(pool, template) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_slides WHERE template_id = ?', [template.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This template is still used by one or more slides.' : '';
}
async function getCanvasSizeDeleteBlockMessage(pool, canvasSize) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSize.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This canvas size is still used by one or more templates.' : '';
}
async function getPlaylistDeleteBlockMessage(pool, playlist) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM d_screens WHERE playlist_id = ?', [playlist.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This playlist is still assigned to one or more screens.' : '';
}
return {
forwardPlayerCommand: forwardPlayerCommand,
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
getScreenConnections: getScreenConnections,
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
forwardPlayerCommandToDevice: forwardPlayerCommandToDevice,
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
getCanvasSizeDeleteBlockMessage: getCanvasSizeDeleteBlockMessage,
getPlaylistDeleteBlockMessage: getPlaylistDeleteBlockMessage
};
}
module.exports = { createPlayerActionService };