Add player control-plane and dashboard updates
This commit is contained in:
@@ -120,7 +120,11 @@ function createDashboardStateService(options) {
|
||||
? await common.fetchPlayerRegistrations(pool)
|
||||
: [];
|
||||
screensData.forEach(function (screen) {
|
||||
ensurePlayerSnapshotSubscription(screen.slug);
|
||||
try {
|
||||
ensurePlayerSnapshotSubscription(screen.slug);
|
||||
} catch (_error) {
|
||||
// Keep building dashboard state when one player snapshot subscription fails.
|
||||
}
|
||||
});
|
||||
|
||||
const [onboardingRows] = await pool.query(
|
||||
|
||||
@@ -40,6 +40,14 @@ function normalizePlayerRowBaseUrl(player) {
|
||||
return normalizeBaseUrl(player && player.internal_base_url);
|
||||
}
|
||||
|
||||
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 createUploadSyncService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
@@ -68,7 +76,11 @@ function createUploadSyncService(options) {
|
||||
|
||||
async function getPlayerInternalBaseUrl() {
|
||||
const metadata = await getPlayerTaskMetadata();
|
||||
return metadata && metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
|
||||
if (!metadata || metadata.playerActive === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
|
||||
}
|
||||
|
||||
async function getPlayerTaskMetadata() {
|
||||
@@ -85,26 +97,45 @@ function createUploadSyncService(options) {
|
||||
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 = registeredPlayers.find(function (player) {
|
||||
const recentPlayers = registeredPlayers.filter(function (player) {
|
||||
return isRecentPlayerRegistration(player, 60);
|
||||
});
|
||||
const preferredPlayer = recentPlayers.find(function (player) {
|
||||
const internalBaseUrl = normalizePlayerRowBaseUrl(player);
|
||||
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
|
||||
}) || exactPlayer || registeredPlayers[0] || null;
|
||||
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(preferredPlayer);
|
||||
const resolvedPublicBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.public_base_url);
|
||||
const resolvedIdentifier = String(preferredPlayer && preferredPlayer.identifier || '').trim();
|
||||
if (resolvedInternalBaseUrl || resolvedPublicBaseUrl || resolvedIdentifier) {
|
||||
}) || recentPlayers.find(function (player) {
|
||||
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
|
||||
}) || recentPlayers[0] || null;
|
||||
if (preferredPlayer) {
|
||||
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(preferredPlayer);
|
||||
const resolvedPublicBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.public_base_url);
|
||||
const resolvedIdentifier = String(preferredPlayer && preferredPlayer.identifier || '').trim();
|
||||
playerInternalBaseUrl = resolvedInternalBaseUrl || null;
|
||||
playerTaskMetadata = {
|
||||
playerIdentifier: resolvedIdentifier || null,
|
||||
playerPublicBaseUrl: resolvedPublicBaseUrl || null,
|
||||
playerInternalBaseUrl: resolvedInternalBaseUrl || null,
|
||||
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null
|
||||
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null,
|
||||
playerActive: true
|
||||
};
|
||||
return playerTaskMetadata;
|
||||
}
|
||||
|
||||
if (registeredPlayers.length) {
|
||||
const stalePlayer = registeredPlayers.find(function (player) {
|
||||
return String(player && player.identifier || '').trim() === configuredPlayerIdentifier;
|
||||
}) || registeredPlayers[0] || null;
|
||||
const staleInternalBaseUrl = normalizePlayerRowBaseUrl(stalePlayer);
|
||||
const stalePublicBaseUrl = normalizeBaseUrl(stalePlayer && stalePlayer.public_base_url);
|
||||
const staleIdentifier = String(stalePlayer && stalePlayer.identifier || '').trim();
|
||||
playerInternalBaseUrl = staleInternalBaseUrl || null;
|
||||
playerTaskMetadata = {
|
||||
playerIdentifier: staleIdentifier || null,
|
||||
playerPublicBaseUrl: stalePublicBaseUrl || null,
|
||||
playerInternalBaseUrl: staleInternalBaseUrl || null,
|
||||
playerLabel: staleIdentifier || stalePublicBaseUrl || staleInternalBaseUrl || null,
|
||||
playerActive: false
|
||||
};
|
||||
return playerTaskMetadata;
|
||||
}
|
||||
@@ -117,7 +148,8 @@ function createUploadSyncService(options) {
|
||||
playerIdentifier: getConfiguredPlayerIdentifier() || null,
|
||||
playerPublicBaseUrl: null,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null
|
||||
playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null,
|
||||
playerActive: true
|
||||
};
|
||||
return playerTaskMetadata;
|
||||
})().then(function (metadata) {
|
||||
@@ -377,6 +409,10 @@ function createUploadSyncService(options) {
|
||||
return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT';
|
||||
}
|
||||
|
||||
function isPlayerUnavailableResponse(response) {
|
||||
return Boolean(response) && Number(response.status) === 503;
|
||||
}
|
||||
|
||||
function queuePlayerUploadSync(operation) {
|
||||
if (!operation || !operation.uploadPath) {
|
||||
return;
|
||||
@@ -404,13 +440,13 @@ function createUploadSyncService(options) {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir) {
|
||||
async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
|
||||
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
|
||||
if (!targetBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -435,7 +471,7 @@ function createUploadSyncService(options) {
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
@@ -444,7 +480,9 @@ function createUploadSyncService(options) {
|
||||
body: fileBuffer
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||
if (!isPlayerUnavailableResponse(response)) {
|
||||
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -456,13 +494,13 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) {
|
||||
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
|
||||
if (!targetBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -475,7 +513,7 @@ function createUploadSyncService(options) {
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||
});
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -483,7 +521,9 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||
if (!isPlayerUnavailableResponse(response)) {
|
||||
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -500,9 +540,10 @@ function createUploadSyncService(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
|
||||
for (let i = 0; i < uniqueRefs.length; i += 1) {
|
||||
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir);
|
||||
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl);
|
||||
if (!success) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
@@ -683,15 +724,23 @@ function createUploadSyncService(options) {
|
||||
const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata
|
||||
? pendingEntries[0].metadata
|
||||
: await getPlayerTaskMetadata();
|
||||
if (playerMetadata && playerMetadata.playerActive === false) {
|
||||
pendingPlayerUploadSyncs.clear();
|
||||
pendingPlayerUploadSyncRetryLogAt = 0;
|
||||
return;
|
||||
}
|
||||
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
|
||||
? playerMetadata.playerInternalBaseUrl
|
||||
: await getPlayerInternalBaseUrl();
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
for (let i = 0; i < pendingEntries.length; i += 1) {
|
||||
const operation = pendingEntries[i];
|
||||
let success = false;
|
||||
if (operation.type === 'delete') {
|
||||
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir);
|
||||
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
|
||||
} else {
|
||||
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir);
|
||||
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl);
|
||||
}
|
||||
if (success) {
|
||||
successCount += 1;
|
||||
|
||||
@@ -25,6 +25,29 @@ 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(/\/$/, '');
|
||||
@@ -57,10 +80,10 @@ function createPlayerActionService(options) {
|
||||
})
|
||||
: null;
|
||||
const registeredPlayers = Array.isArray(players) ? players : [];
|
||||
const preferredPlayer = registeredPlayers.find(function (player) {
|
||||
const preferredPlayer = exactPlayer || registeredPlayers.find(function (player) {
|
||||
const internalBaseUrl = normalizeBaseUrl(player && player.internal_base_url);
|
||||
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
|
||||
}) || exactPlayer || registeredPlayers[0] || null;
|
||||
}) || registeredPlayers[0] || null;
|
||||
const resolvedBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.internal_base_url);
|
||||
if (resolvedBaseUrl) {
|
||||
playerInternalBaseUrl = resolvedBaseUrl;
|
||||
@@ -87,24 +110,26 @@ function createPlayerActionService(options) {
|
||||
return playerInternalBaseUrlPromise;
|
||||
}
|
||||
|
||||
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||
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 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)}/commands`, {
|
||||
const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -126,6 +151,11 @@ function createPlayerActionService(options) {
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -164,29 +194,66 @@ function createPlayerActionService(options) {
|
||||
method: 'GET',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
|
||||
});
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
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 response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(function () { return ''; });
|
||||
const error = new Error(errorText || `Unable to load screen connections for ${slug}.`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json().catch(function () {
|
||||
return { connections: [] };
|
||||
});
|
||||
return {
|
||||
screen: screen,
|
||||
screenSlug: slug,
|
||||
count: mergedConnections.length,
|
||||
connections: mergedConnections,
|
||||
degraded: degraded
|
||||
};
|
||||
}
|
||||
|
||||
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
|
||||
@@ -234,6 +301,7 @@ function createPlayerActionService(options) {
|
||||
forwardPlayerCommand: forwardPlayerCommand,
|
||||
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
|
||||
getScreenConnections: getScreenConnections,
|
||||
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
|
||||
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
||||
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
||||
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
|
||||
|
||||
Reference in New Issue
Block a user