313 lines
11 KiB
JavaScript
313 lines
11 KiB
JavaScript
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 }; |