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
+120 -10
View File
@@ -6,6 +6,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable;
const withClientNameReservation = deps.withClientNameReservation;
@@ -13,11 +14,74 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
const requirePermission = deps.requirePermission;
const ALL_SCREENS_SLUG = '__all__';
async function resolveScreenPlayerBaseUrls(screenSlug, connectionId) {
if (typeof getScreenConnections !== 'function' || !screenSlug) {
return [];
}
try {
const response = await getScreenConnections(screenSlug);
const liveConnections = Array.isArray(response && response.connections) ? response.connections : [];
if (!liveConnections.length) {
return [];
}
const normalizedConnectionId = String(connectionId || '').trim();
const liveConnection = normalizedConnectionId
? liveConnections.find(function (connection) {
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
return candidateConnectionId === normalizedConnectionId || candidateDeviceId === normalizedConnectionId;
})
: null;
const targetConnections = liveConnection ? [liveConnection] : liveConnections;
return Array.from(new Set(targetConnections.map(function (connection) {
return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
}).filter(Boolean)));
} catch (_error) {
return [];
}
}
function normalizeExplicitPlayerBaseUrl(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 resolveAllPlayerBaseUrls() {
if (!common || typeof common.fetchPlayerRegistrations !== 'function') {
return [];
}
try {
const players = await common.fetchPlayerRegistrations(pool);
return Array.from(new Set((Array.isArray(players) ? players : [])
.filter(function (player) {
return isRecentPlayerRegistration(player, 60);
})
.map(function (player) {
return String(player && player.public_base_url || '').trim().replace(/\/$/, '');
})
.filter(Boolean)));
} catch (_error) {
return [];
}
}
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
try {
const slug = String(req.params.slug || '').trim();
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
const explicitPlayerBaseUrl = normalizeExplicitPlayerBaseUrl((req.body && (req.body.playerBaseUrl || req.body.playerPublicBaseUrl)) || req.query.playerBaseUrl || req.query.playerPublicBaseUrl || '');
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: req.query.blackout;
@@ -47,7 +111,16 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
await Promise.all(screenRows.map(function (screenRow) {
return forwardPlayerCommand(String(screenRow && screenRow.slug || '').trim(), commandPayload);
const screenSlug = String(screenRow && screenRow.slug || '').trim();
return resolveScreenPlayerBaseUrls(screenSlug, connectionId).then(function (playerBaseUrls) {
if (playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
return Promise.all(playerBaseUrls.map(function (playerBaseUrl) {
return forwardPlayerCommandToBaseUrl(playerBaseUrl, screenSlug, commandPayload, connectionId || undefined);
}));
}
return forwardPlayerCommand(screenSlug, commandPayload);
});
}));
if (typeof broadcastDashboardState === 'function') {
@@ -74,6 +147,28 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(404).json({ error: 'Screen not found' });
}
if (explicitPlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function' && command !== 'moveclient') {
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: { command: command };
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
commandPayload.blackout = blackoutValue;
}
const result = await forwardPlayerCommandToBaseUrl(explicitPlayerBaseUrl, slug, commandPayload, connectionId || undefined);
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json(Object.assign({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null
}, result && typeof result === 'object' ? result : {}));
}
if (command === 'setclientname') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
@@ -256,17 +351,25 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
})
: null;
const targetBaseUrl = String(
const sourcePlayerBaseUrl = String(
explicitPlayerBaseUrl ||
(liveConnection && liveConnection.playerPublicBaseUrl) ||
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
''
).trim().replace(/\/$/, '');
const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
const targetPlayerUrl = sourcePlayerBaseUrl ? `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
await forwardPlayerCommand(slug, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined);
if (sourcePlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
await forwardPlayerCommandToBaseUrl(sourcePlayerBaseUrl, slug, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined);
} else {
await forwardPlayerCommand(slug, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined);
}
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
@@ -292,9 +395,16 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
commandPayload.blackout = blackoutValue;
}
const result = connectionId
? await forwardPlayerCommand(slug, commandPayload, connectionId)
: await forwardPlayerCommand(slug, commandPayload);
const playerBaseUrls = await resolveScreenPlayerBaseUrls(slug, connectionId);
const result = playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function'
? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) {
return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandPayload, connectionId);
})).then(function (results) {
return Array.isArray(results) && results.length ? results[0] : { ok: true };
})
: (connectionId
? await forwardPlayerCommand(slug, commandPayload, connectionId)
: await forwardPlayerCommand(slug, commandPayload));
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
+2
View File
@@ -33,6 +33,7 @@ function registerRoutes(app, deps) {
common: deps.common,
pages: deps.pages,
mediaDir: deps.mediaDir,
formatDashboardDate: deps.formatDashboardDate,
buildDashboardState: deps.buildDashboardState,
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
requirePermission: deps.requirePermission,
@@ -141,6 +142,7 @@ function registerSignageRoutes(app, deps) {
pool: deps.pool,
common: deps.common,
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
getScreenConnections: deps.playerActionService.getScreenConnections,
isClientNameAvailable: deps.isClientNameAvailable,
withClientNameReservation: deps.withClientNameReservation,
+25 -6
View File
@@ -6,7 +6,7 @@ module.exports = function registerClientsRoutes(app, deps) {
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const { buildPagination } = require('../../../lib/pagination');
const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
const { compareSortValues, createSearchMatcher, getComparableSortValue } = require('../../../lib/list-query');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
@@ -29,13 +29,32 @@ module.exports = function registerClientsRoutes(app, deps) {
connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); }
};
if (!accessors[normalizedSortKey]) {
return Array.isArray(clients) ? clients.slice() : [];
function compareValues(leftValue, rightValue) {
return compareSortValues(getComparableSortValue(leftValue), getComparableSortValue(rightValue));
}
return sortRows(clients, function (client) {
return accessors[normalizedSortKey](client);
}, normalizedDirection);
const sortKeys = accessors[normalizedSortKey]
? [normalizedSortKey]
: ['client'];
if (sortKeys[0] === 'client') {
sortKeys.push('ip');
} else if (sortKeys[0] === 'ip') {
sortKeys.push('client');
}
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
for (let index = 0; index < sortKeys.length; index += 1) {
const sortKeyName = sortKeys[index];
const comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
if (comparison !== 0) {
return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison;
}
}
return 0;
});
}
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
@@ -142,11 +142,14 @@ function getVideoDurationSeconds(slide) {
return null;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
const videoRegions = Object.keys(parsed).map((key) => parsed[key]).filter((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
const duration = videoRegions.reduce((longest, region) => {
const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000;
return regionDuration > longest ? regionDuration : longest;
}, 0);
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
+16 -3
View File
@@ -12,6 +12,14 @@ module.exports = function registerScreensRoutes(app, deps) {
const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission;
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;
}
const LIST_PAGE_SIZE = 25;
const batTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.bat');
const shTemplatePath = require.resolve('#root/scripts/pulse-signage-kiosk.sh');
@@ -87,13 +95,18 @@ module.exports = function registerScreensRoutes(app, deps) {
? await common.fetchPlayerRegistrations(pool)
: [];
return (Array.isArray(playerRegistrations) ? playerRegistrations : []).map(function (player) {
return (Array.isArray(playerRegistrations) ? playerRegistrations : []).filter(function (player) {
return isRecentPlayerRegistration(player, 60);
}).map(function (player) {
const baseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, '');
const playerUrl = screenPlayerUrl(screen && screen.slug ? screen.slug : '', baseUrl);
return Object.assign({}, player, {
return {
identifier: String(player && player.identifier || '').trim(),
public_base_url: baseUrl || null,
player_url: playerUrl || null
});
};
}).sort(function (left, right) {
return String(left && left.identifier || '').localeCompare(String(right && right.identifier || ''), undefined, { sensitivity: 'base', numeric: true });
});
}