Add player control-plane and dashboard updates

This commit is contained in:
2026-08-07 22:35:27 +01:00
parent a4f8a807ff
commit 2bd47fb96a
36 changed files with 5081 additions and 263 deletions
+220 -40
View File
@@ -6,11 +6,48 @@ const crypto = require('crypto');
const multer = require('multer');
const { createRequestAuthHeaders } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('./font-library');
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
function normalizeUploadRoot(uploadDir) {
return path.resolve(String(uploadDir || '').trim());
}
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 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;
@@ -20,55 +57,144 @@ function createUploadSyncService(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
const MAX_FIELD_BYTES = 10 * 1024 * 1024;
const PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS = 60000;
const pendingPlayerUploadSyncs = new Map();
let pendingPlayerUploadSyncFlushTimer = null;
let pendingPlayerUploadSyncFlushInFlight = null;
let pendingPlayerUploadSyncRetryLogAt = 0;
const pendingPlaylistUploadSyncs = new Map();
let pendingPlaylistUploadSyncFlushTimer = null;
let pendingPlaylistUploadSyncFlushInFlight = null;
let playerInternalBaseUrl = null;
let playerInternalBaseUrlPromise = null;
let playerTaskMetadata = null;
let playerTaskMetadataPromise = null;
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
throw new Error('createUploadSyncService requires the upload dependencies.');
}
async function getPlayerInternalBaseUrl() {
if (playerInternalBaseUrl) {
return playerInternalBaseUrl;
const metadata = await getPlayerTaskMetadata();
if (!metadata || metadata.playerActive === false) {
return null;
}
if (playerInternalBaseUrlPromise) {
return playerInternalBaseUrlPromise;
return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
}
async function getPlayerTaskMetadata() {
if (playerTaskMetadata) {
return playerTaskMetadata;
}
playerInternalBaseUrlPromise = (async function () {
if (!pool) {
return configuredPlayerInternalBaseUrl || null;
}
if (playerTaskMetadataPromise) {
return playerTaskMetadataPromise;
}
playerTaskMetadataPromise = (async function () {
try {
const [rows] = await pool.query(
`SELECT internal_base_url
FROM d_players
WHERE device_id = '1'
LIMIT 1`
);
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
if (pool && typeof fetchPlayerRegistrations === 'function') {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const players = await fetchPlayerRegistrations(pool);
const registeredPlayers = Array.isArray(players) ? players : [];
const recentPlayers = registeredPlayers.filter(function (player) {
return isRecentPlayerRegistration(player, 60);
});
const preferredPlayer = recentPlayers.find(function (player) {
const internalBaseUrl = normalizePlayerRowBaseUrl(player);
return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl);
}) || 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,
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;
}
}
} catch (_error) {
return configuredPlayerInternalBaseUrl || null;
}
})().then(function (baseUrl) {
playerInternalBaseUrl = baseUrl || null;
playerInternalBaseUrlPromise = null;
return playerInternalBaseUrl;
playerInternalBaseUrl = configuredPlayerInternalBaseUrl || null;
playerTaskMetadata = {
playerIdentifier: getConfiguredPlayerIdentifier() || null,
playerPublicBaseUrl: null,
playerInternalBaseUrl: playerInternalBaseUrl,
playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null,
playerActive: true
};
return playerTaskMetadata;
})().then(function (metadata) {
playerTaskMetadataPromise = null;
return metadata || null;
}, function () {
playerInternalBaseUrlPromise = null;
return configuredPlayerInternalBaseUrl || null;
playerTaskMetadataPromise = null;
return {
playerIdentifier: getConfiguredPlayerIdentifier() || null,
playerPublicBaseUrl: null,
playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null,
playerLabel: getConfiguredPlayerIdentifier() || configuredPlayerInternalBaseUrl || null
};
});
return playerInternalBaseUrlPromise;
return playerTaskMetadataPromise;
}
function formatPlayerTaskLabel(metadata) {
const playerLabel = String(metadata && metadata.playerLabel || '').trim();
if (playerLabel) {
return playerLabel;
}
const playerIdentifier = String(metadata && metadata.playerIdentifier || '').trim();
if (playerIdentifier) {
return playerIdentifier;
}
const playerPublicBaseUrl = String(metadata && metadata.playerPublicBaseUrl || '').trim();
if (playerPublicBaseUrl) {
return playerPublicBaseUrl;
}
return '';
}
function logMediaSyncSummary(level, message, metadata, details) {
const suffix = formatPlayerTaskLabel(metadata);
const logger = level === 'warn' ? console.warn : console.info;
if (details !== undefined) {
logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`, details);
return;
}
logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`);
}
function createUploadMiddleware(uploadDir) {
@@ -278,6 +404,15 @@ function createUploadSyncService(options) {
return Boolean(localUploadDir);
}
function isPlayerUnavailableError(error) {
const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase();
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;
@@ -305,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;
}
@@ -336,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',
@@ -345,23 +480,27 @@ 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;
} catch (error) {
console.warn('Unable to sync upload to player:', relativePath, error);
if (!isPlayerUnavailableError(error)) {
console.warn('Unable to sync upload to player:', relativePath, error);
}
return false;
}
}
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;
}
@@ -374,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',
@@ -382,12 +521,16 @@ 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;
} catch (error) {
console.warn('Unable to remove upload from player:', relativePath, error);
if (!isPlayerUnavailableError(error)) {
console.warn('Unable to remove upload from player:', relativePath, error);
}
return false;
}
}
@@ -397,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',
@@ -552,6 +696,10 @@ function createUploadSyncService(options) {
pendingPlaylistUploadSyncs.delete(operation.key);
}
if (pendingEntries.length) {
logMediaSyncSummary('info', `Playlist sync flushed ${pendingEntries.length} task${pendingEntries.length === 1 ? '' : 's'}`, pendingEntries[0] && pendingEntries[0].metadata);
}
})().finally(function () {
pendingPlaylistUploadSyncFlushInFlight = null;
if (pendingPlaylistUploadSyncs.size) {
@@ -573,18 +721,47 @@ function createUploadSyncService(options) {
pendingPlayerUploadSyncFlushInFlight = (async function () {
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values());
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;
pendingPlayerUploadSyncs.delete(operation.uploadPath);
} else {
failureCount += 1;
}
}
if (successCount) {
logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, playerMetadata);
}
if (failureCount) {
const now = Date.now();
if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) {
pendingPlayerUploadSyncRetryLogAt = now;
logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, playerMetadata);
}
} else if (!pendingPlayerUploadSyncs.size) {
pendingPlayerUploadSyncRetryLogAt = 0;
}
})().finally(function () {
pendingPlayerUploadSyncFlushInFlight = null;
if (pendingPlayerUploadSyncs.size) {
@@ -680,12 +857,14 @@ function createUploadSyncService(options) {
safePayload.operation = Object.assign({}, safePayload.operation);
delete safePayload.operation.pool;
}
const playerMetadata = await getPlayerTaskMetadata();
const definition = {
key: taskKey,
title: title,
category: 'media-sync',
taskType: 'media-sync',
metadata: Object.assign({}, playerMetadata || {}),
payload: safePayload,
persist: true
};
@@ -723,7 +902,8 @@ function createUploadSyncService(options) {
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
runMediaSyncTask: runMediaSyncTask,
queueMediaSyncTask: queueMediaSyncTask
queueMediaSyncTask: queueMediaSyncTask,
getPlayerTaskMetadata: getPlayerTaskMetadata
};
}