467 lines
18 KiB
JavaScript
467 lines
18 KiB
JavaScript
// Admin client command routes for connected screens.
|
|
|
|
const { commitDeviceBinding } = require('#src/player/onboarding');
|
|
|
|
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;
|
|
const broadcastDashboardState = deps.broadcastDashboardState;
|
|
const requirePermission = deps.requirePermission;
|
|
|
|
function normalizeExplicitPlayerBaseUrl(value) {
|
|
return String(value || '').trim().replace(/\/$/, '');
|
|
}
|
|
|
|
function normalizeConnectionBaseUrl(connection) {
|
|
return normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl);
|
|
}
|
|
|
|
function normalizeBaseUrl(value) {
|
|
return String(value || '').trim().replace(/\/$/, '');
|
|
}
|
|
|
|
async function fetchPlayerRegistrations() {
|
|
if (typeof common.fetchPlayerRegistrations !== 'function') {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const registrations = await common.fetchPlayerRegistrations(pool);
|
|
return Array.isArray(registrations) ? registrations : [];
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function fetchScreenConnections(screenSlug) {
|
|
if (typeof getScreenConnections !== 'function') {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const response = await getScreenConnections(screenSlug);
|
|
return Array.isArray(response && response.connections) ? response.connections : [];
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function fetchAllScreenTargets() {
|
|
const [rows] = await pool.query(
|
|
`SELECT slug
|
|
FROM d_screens
|
|
WHERE slug IS NOT NULL
|
|
ORDER BY slug ASC`
|
|
);
|
|
|
|
return (rows || [])
|
|
.map(function (row) {
|
|
return {
|
|
slug: String(row && row.slug ? row.slug : '').trim()
|
|
};
|
|
})
|
|
.filter(function (row) {
|
|
return Boolean(row.slug);
|
|
});
|
|
}
|
|
|
|
async function resolvePlayerBaseUrlsForConnections(connections) {
|
|
const connectionList = Array.isArray(connections) ? connections : [];
|
|
const seen = new Set();
|
|
const registrations = await fetchPlayerRegistrations();
|
|
|
|
return connectionList.map(function (connection) {
|
|
const selectedPublicBaseUrl = normalizeConnectionBaseUrl(connection);
|
|
if (!selectedPublicBaseUrl || seen.has(selectedPublicBaseUrl)) {
|
|
return '';
|
|
}
|
|
seen.add(selectedPublicBaseUrl);
|
|
const matchedPlayer = registrations.find(function (player) {
|
|
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === selectedPublicBaseUrl;
|
|
}) || null;
|
|
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || selectedPublicBaseUrl;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
async function resolvePlayerBaseUrlForExplicitPublicBaseUrl(playerBaseUrl) {
|
|
const normalizedPublicBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
|
|
if (!normalizedPublicBaseUrl) {
|
|
return '';
|
|
}
|
|
|
|
const registrations = await fetchPlayerRegistrations();
|
|
const matchedPlayer = registrations.find(function (player) {
|
|
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === normalizedPublicBaseUrl;
|
|
}) || null;
|
|
|
|
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || normalizedPublicBaseUrl;
|
|
}
|
|
|
|
async function forwardPlayerCommandForConnections(screenSlug, connections, commandPayload, connectionId, deviceId) {
|
|
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections);
|
|
if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
|
const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) {
|
|
return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined);
|
|
}));
|
|
|
|
return {
|
|
ok: true,
|
|
sent: results.filter(function (result) {
|
|
return result.status === 'fulfilled';
|
|
}).length,
|
|
results: results
|
|
};
|
|
}
|
|
|
|
return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined);
|
|
}
|
|
|
|
async function forwardPlayerCommandForPlayerBaseUrl(screenSlug, playerBaseUrl, commandPayload, connectionId, deviceId) {
|
|
const targetBaseUrl = await resolvePlayerBaseUrlForExplicitPublicBaseUrl(playerBaseUrl);
|
|
if (targetBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
|
return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined);
|
|
}
|
|
|
|
return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
|
|
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 playerBaseUrl = String((req.body && req.body.playerBaseUrl) || req.query.playerBaseUrl || '').trim();
|
|
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
|
? req.body.blackout
|
|
: req.query.blackout;
|
|
|
|
if (!slug) {
|
|
return res.status(400).json({ error: 'Screen slug is required' });
|
|
}
|
|
if (!command) {
|
|
return res.status(400).json({ error: 'Command is required' });
|
|
}
|
|
|
|
if (slug === '__all__') {
|
|
if (command !== 'reload' && command !== 'pause' && command !== 'blackout') {
|
|
return res.status(400).json({ error: 'This command requires a specific screen.' });
|
|
}
|
|
|
|
const targets = await fetchAllScreenTargets();
|
|
if (!targets.length) {
|
|
if (typeof broadcastDashboardState === 'function') {
|
|
await broadcastDashboardState();
|
|
}
|
|
|
|
return res.json({
|
|
ok: true,
|
|
allScreens: true,
|
|
command: command,
|
|
targetScreenCount: 0,
|
|
targetPlayerCount: 0,
|
|
sent: 0
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (command === 'pause' && req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused') && commandPayload && typeof commandPayload === 'object') {
|
|
commandPayload.paused = req.body.paused;
|
|
}
|
|
|
|
const results = await Promise.allSettled(targets.map(async function (target) {
|
|
const liveConnections = await fetchScreenConnections(target.slug);
|
|
if (liveConnections.length) {
|
|
return forwardPlayerCommandForConnections(target.slug, liveConnections, commandPayload);
|
|
}
|
|
|
|
return forwardPlayerCommand(target.slug, commandPayload);
|
|
}));
|
|
|
|
const sentCount = results.reduce(function (total, result) {
|
|
if (result.status !== 'fulfilled') {
|
|
return total;
|
|
}
|
|
|
|
const sentValue = Number(result.value && typeof result.value.sent === 'number' ? result.value.sent : 1);
|
|
return total + (Number.isFinite(sentValue) && sentValue > 0 ? sentValue : 1);
|
|
}, 0);
|
|
|
|
if (typeof broadcastDashboardState === 'function') {
|
|
await broadcastDashboardState();
|
|
}
|
|
|
|
return res.json({
|
|
ok: true,
|
|
allScreens: true,
|
|
command: command,
|
|
targetScreenCount: targets.length,
|
|
targetPlayerCount: sentCount,
|
|
sent: sentCount,
|
|
blackout: command === 'blackout' ? Boolean(commandPayload.blackout) : undefined,
|
|
paused: command === 'pause' ? Boolean(commandPayload.paused) : undefined
|
|
});
|
|
}
|
|
|
|
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [slug]);
|
|
if (!screenRows.length) {
|
|
return res.status(404).json({ error: 'Screen not found' });
|
|
}
|
|
|
|
if (command === 'setclientname') {
|
|
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
|
|
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
|
if (!deviceId) {
|
|
return res.status(400).json({ error: 'Device ID is required' });
|
|
}
|
|
if (!clientName) {
|
|
return res.status(400).json({ error: 'Client name is required' });
|
|
}
|
|
|
|
const [currentRows] = await pool.query(
|
|
`SELECT client_name
|
|
FROM d_onboarding_devices
|
|
WHERE device_id = ?
|
|
LIMIT 1`,
|
|
[deviceId]
|
|
);
|
|
const onboardingRow = currentRows[0] || null;
|
|
const currentName = String(onboardingRow && onboardingRow.client_name ? onboardingRow.client_name : '').trim();
|
|
if (currentName && currentName.toLowerCase() === clientName.toLowerCase()) {
|
|
return res.json({
|
|
screen: screenRows[0],
|
|
screenSlug: slug,
|
|
command: command,
|
|
connectionId: connectionId || null,
|
|
deviceId: deviceId,
|
|
clientName: currentName,
|
|
ok: true,
|
|
unchanged: true
|
|
});
|
|
}
|
|
if (typeof withClientNameReservation !== 'function') {
|
|
return res.status(500).json({ error: 'Client name reservation is unavailable.' });
|
|
}
|
|
|
|
return withClientNameReservation(pool, clientName, async function () {
|
|
let liveConnections = [];
|
|
try {
|
|
const [screenSlugs] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
|
|
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
|
|
const screenSlug = String(row && row.slug ? row.slug : '').trim();
|
|
if (!screenSlug || typeof getScreenConnections !== 'function') {
|
|
return [];
|
|
}
|
|
try {
|
|
const liveResponse = await getScreenConnections(screenSlug);
|
|
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}));
|
|
liveConnections = liveResults.flat();
|
|
} catch (_error) {
|
|
liveConnections = [];
|
|
}
|
|
|
|
const available = await isClientNameAvailable(pool, clientName, deviceId, liveConnections);
|
|
if (!available) {
|
|
return res.status(409).json({ error: 'Client name already exists.' });
|
|
}
|
|
|
|
if (!onboardingRow) {
|
|
if (playerBaseUrl) {
|
|
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
|
|
command: command,
|
|
clientName: clientName,
|
|
clientId: connectionId || deviceId || null,
|
|
deviceId: deviceId || null
|
|
}, connectionId || deviceId || undefined, deviceId || null);
|
|
} else {
|
|
await forwardPlayerCommandForConnections(slug, liveConnections, {
|
|
command: command,
|
|
clientName: clientName,
|
|
clientId: connectionId || deviceId || null,
|
|
deviceId: deviceId || null
|
|
}, connectionId || deviceId || undefined, deviceId || null);
|
|
}
|
|
|
|
if (typeof broadcastDashboardState === 'function') {
|
|
await broadcastDashboardState();
|
|
}
|
|
|
|
return res.json({
|
|
screen: screenRows[0],
|
|
screenSlug: slug,
|
|
command: command,
|
|
connectionId: connectionId || null,
|
|
deviceId: deviceId,
|
|
clientName: clientName,
|
|
ok: true,
|
|
liveOnly: true
|
|
});
|
|
}
|
|
|
|
const [updateResult] = await pool.query(
|
|
`UPDATE d_onboarding_devices pod
|
|
JOIN d_screens s ON s.id = pod.screen_id
|
|
SET pod.client_name = ?, pod.modified_at = CURRENT_TIMESTAMP
|
|
WHERE s.slug = ? AND pod.device_id = ?`,
|
|
[clientName, slug, deviceId]
|
|
);
|
|
if (!updateResult.affectedRows) {
|
|
return res.status(404).json({ error: 'Client not found' });
|
|
}
|
|
|
|
return res.json({
|
|
screen: screenRows[0],
|
|
screenSlug: slug,
|
|
command: command,
|
|
connectionId: connectionId || null,
|
|
deviceId: deviceId,
|
|
clientName: clientName,
|
|
ok: true
|
|
});
|
|
});
|
|
}
|
|
|
|
if (command === 'moveclient') {
|
|
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
|
|
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
|
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
|
|
|
|
if (!deviceId) {
|
|
return res.status(400).json({ error: 'Device ID is required' });
|
|
}
|
|
if (!targetScreenSlug) {
|
|
return res.status(400).json({ error: 'Target screen is required' });
|
|
}
|
|
|
|
const [currentRows] = await pool.query(
|
|
`SELECT d.client_name, s.slug AS current_screen_slug
|
|
FROM d_onboarding_devices d
|
|
LEFT JOIN d_screens s ON s.id = d.screen_id
|
|
WHERE d.device_id = ?
|
|
LIMIT 1`,
|
|
[deviceId]
|
|
);
|
|
const onboardingRow = currentRows[0] || null;
|
|
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim();
|
|
const currentScreenSlug = String(onboardingRow && onboardingRow.current_screen_slug || '').trim();
|
|
|
|
if (!resolvedClientName) {
|
|
return res.status(400).json({ error: 'Client name is required' });
|
|
}
|
|
|
|
if (currentScreenSlug && currentScreenSlug === targetScreenSlug) {
|
|
return res.json({
|
|
screen: screenRows[0],
|
|
screenSlug: slug,
|
|
command: command,
|
|
connectionId: connectionId || null,
|
|
deviceId: deviceId,
|
|
clientName: resolvedClientName,
|
|
targetScreenSlug: targetScreenSlug,
|
|
ok: true,
|
|
unchanged: true
|
|
});
|
|
}
|
|
|
|
if (typeof commitDeviceBinding !== 'function') {
|
|
return res.status(500).json({ error: 'Client binding is unavailable.' });
|
|
}
|
|
|
|
const liveConnections = await fetchScreenConnections(slug);
|
|
|
|
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
|
|
let targetPlayerUrl = '';
|
|
const liveConnection = Array.isArray(liveConnections)
|
|
? liveConnections.find(function (connection) {
|
|
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
|
|
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
|
|
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
|
|
}) || liveConnections[0] || null
|
|
: null;
|
|
const sourcePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || '');
|
|
if (sourcePlayerBaseUrl) {
|
|
targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`;
|
|
}
|
|
if (!targetPlayerUrl) {
|
|
targetPlayerUrl = `/screen/${encodeURIComponent(targetScreenSlug)}`;
|
|
}
|
|
|
|
if (playerBaseUrl) {
|
|
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
|
|
command: 'redirect',
|
|
url: targetPlayerUrl
|
|
}, connectionId || deviceId || undefined, deviceId || null);
|
|
} else {
|
|
await forwardPlayerCommandForConnections(slug, liveConnections, {
|
|
command: 'redirect',
|
|
url: targetPlayerUrl
|
|
}, connectionId || deviceId || undefined, deviceId || null);
|
|
}
|
|
|
|
if (typeof broadcastDashboardState === 'function') {
|
|
await broadcastDashboardState();
|
|
}
|
|
|
|
return res.json({
|
|
screen: screenRows[0],
|
|
screenSlug: slug,
|
|
command: command,
|
|
connectionId: connectionId || null,
|
|
deviceId: deviceId,
|
|
clientName: status ? status.client_name : resolvedClientName,
|
|
targetScreenSlug: targetScreenSlug,
|
|
playerUrl: targetPlayerUrl,
|
|
ok: true
|
|
});
|
|
}
|
|
|
|
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 liveConnections = await fetchScreenConnections(slug);
|
|
const result = playerBaseUrl
|
|
? await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, commandPayload, connectionId, null)
|
|
: await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null);
|
|
|
|
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 : {}));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
};
|