// Commands and synchronization actions forwarded from the web app to players. 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 resolvePlayerBaseUrlForPublicUrl(playerPublicBaseUrl) { const normalizedPublicBaseUrl = normalizeBaseUrl(playerPublicBaseUrl); if (!normalizedPublicBaseUrl) { return ''; } try { const players = await fetchPlayerRegistrations(pool); const matchedPlayer = (Array.isArray(players) ? players : []).find(function (player) { return normalizeBaseUrl(player && player.public_base_url) === normalizedPublicBaseUrl; }); const registeredInternalBaseUrl = normalizeBaseUrl(matchedPlayer && matchedPlayer.internal_base_url); if (registeredInternalBaseUrl) { return registeredInternalBaseUrl; } } catch (_error) { } if (isLocalLikeBaseUrl(normalizedPublicBaseUrl) && configuredPlayerInternalBaseUrl) { return configuredPlayerInternalBaseUrl; } return normalizedPublicBaseUrl; } async function forwardAnnouncementRefresh(slug) { try { const screenState = await getScreenConnections(slug); const deviceIds = Array.from(new Set((screenState && Array.isArray(screenState.connections) ? screenState.connections : []).map(function (connection) { return String(connection && connection.playerDeviceId || '').trim(); }).filter(Boolean))); if (deviceIds.length) { const deviceResults = await Promise.all(deviceIds.map(function (deviceId) { return forwardPlayerCommandToDevice(deviceId, { command: 'announcement-refresh', screenSlug: slug }); })); return deviceResults; } } catch (_error) { } const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); const targetBaseUrls = Array.from(new Set([ resolvedPlayerInternalBaseUrl, configuredBridgeInternalBaseUrl ].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(async function (targetBaseUrl) { const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: `/api/screens/${encodeURIComponent(slug)}/announcements/refresh`, body: { command: 'announcement-refresh' } }); const response = await fetch(`${targetBaseUrl}/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 }; }); })); const successfulResults = results.filter(function (result) { return result.status === 'fulfilled'; }); if (!successfulResults.length) { throw (results[0] && results[0].reason) || new Error(`Unable to refresh announcements for player ${slug}.`); } return successfulResults.map(function (result) { return result.value; }); } 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, resolvePlayerBaseUrlForPublicUrl: resolvePlayerBaseUrlForPublicUrl, forwardAnnouncementRefresh: forwardAnnouncementRefresh, getScreenConnections: getScreenConnections, forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl, forwardPlayerCommandToDevice: forwardPlayerCommandToDevice, getScreenDeleteBlockMessage: getScreenDeleteBlockMessage, getSlideDeleteBlockMessage: getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage, getCanvasSizeDeleteBlockMessage: getCanvasSizeDeleteBlockMessage, getPlaylistDeleteBlockMessage: getPlaylistDeleteBlockMessage }; } module.exports = { createPlayerActionService };