Add player control-plane and dashboard updates

This commit is contained in:
2026-08-07 22:23:46 +01:00
parent 433be06bc7
commit 0801c119ae
36 changed files with 2301 additions and 228 deletions
+5 -1
View File
@@ -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(
+76 -27
View File
@@ -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;
+93 -25
View File
@@ -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,