const { createRequestAuthHeaders } = require('#src/request-auth'); const { fetchPlayerRegistrations, 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; } async function fetchRecentPlayerRegistrations(pool) { if (!pool || typeof fetchPlayerRegistrations !== 'function') { return []; } try { const players = await fetchPlayerRegistrations(pool); return (Array.isArray(players) ? players : []).filter(function (player) { return isRecentPlayerRegistration(player, 60); }); } catch (_error) { return []; } } function createPlayerActionService(options) { const pool = options && options.pool; const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').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 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 authHeaders = createRequestAuthHeaders({ method: 'GET', pathname: `/api/screens/${encodeURIComponent(slug)}/connections` }); const recentPlayers = await fetchRecentPlayerRegistrations(pool); const targetBaseUrls = Array.from(new Set((recentPlayers.length ? recentPlayers : []).map(function (player) { return normalizeBaseUrl(player && player.public_base_url); }).filter(Boolean))); if (!targetBaseUrls.length) { const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); if (resolvedPlayerInternalBaseUrl) { targetBaseUrls.push(resolvedPlayerInternalBaseUrl); } } if (!targetBaseUrls.length) { throw new Error('Unable to resolve the player internal base URL.'); } const results = await Promise.all(targetBaseUrls.map(async function (baseUrl) { const response = await fetch(`${baseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, { method: 'GET', headers: { Accept: 'application/json', ...authHeaders } }); if (!response.ok) { return null; } return response.json().catch(function () { return null; }); })); const mergedConnections = []; let screen = null; let degraded = false; results.forEach(function (result) { if (!result) { degraded = true; return; } if (!screen && result.screen) { screen = result.screen; } if (Array.isArray(result.connections)) { mergedConnections.push.apply(mergedConnections, result.connections); } if (result.degraded) { degraded = true; } }); return { screen: screen, screenSlug: slug, count: mergedConnections.length, connections: mergedConnections, degraded: 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, getScreenDeleteBlockMessage: getScreenDeleteBlockMessage, getSlideDeleteBlockMessage: getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage, getCanvasSizeDeleteBlockMessage: getCanvasSizeDeleteBlockMessage, getPlaylistDeleteBlockMessage: getPlaylistDeleteBlockMessage }; } module.exports = { createPlayerActionService };