Add multi-player and remote bridge support
This commit is contained in:
+186
-14
@@ -3,7 +3,7 @@
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
||||
|
||||
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
||||
@@ -12,6 +12,17 @@ function isTransientDbError(error) {
|
||||
return Boolean(error && TRANSIENT_DB_ERROR_CODES.indexOf(String(error.code || '').trim()) !== -1);
|
||||
}
|
||||
|
||||
function isBridgeFetchError(error) {
|
||||
const message = String(error && error.message || '').toLowerCase();
|
||||
return Boolean(error && (
|
||||
message.indexOf('fetch failed') !== -1 ||
|
||||
message.indexOf('network error') !== -1 ||
|
||||
message.indexOf('econnreset') !== -1 ||
|
||||
message.indexOf('econnrefused') !== -1 ||
|
||||
message.indexOf('enotfound') !== -1
|
||||
));
|
||||
}
|
||||
|
||||
function registerPlayerRoutes(app, options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const common = options && options.common ? options.common : null;
|
||||
@@ -22,14 +33,68 @@ function registerPlayerRoutes(app, options) {
|
||||
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
|
||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const playerIdentifier = String(options && options.playerIdentifier || '1').trim() || '1';
|
||||
const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
|
||||
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
|
||||
|
||||
if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) {
|
||||
throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and rtmpStreamService.');
|
||||
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
|
||||
throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.');
|
||||
}
|
||||
|
||||
if (!thinClientBaseUrl && (!pool || !playerPlaylistService)) {
|
||||
throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless thinClientBaseUrl is configured.');
|
||||
}
|
||||
|
||||
const sharedSecret = getSharedSecret();
|
||||
|
||||
async function fetchThinClient(req, pathname, options) {
|
||||
if (!thinClientBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestOptions = options && typeof options === 'object' ? options : {};
|
||||
const method = String(requestOptions.method || req.method || 'GET').trim().toUpperCase();
|
||||
const body = Object.prototype.hasOwnProperty.call(requestOptions, 'body') ? requestOptions.body : undefined;
|
||||
const requestPathname = String(pathname || '').split('?')[0];
|
||||
const headers = Object.assign({}, requestOptions.headers || {}, createRequestAuthHeaders({
|
||||
method: method,
|
||||
pathname: requestPathname,
|
||||
body: body
|
||||
}));
|
||||
|
||||
if (req.headers['x-pulse-page-auth']) {
|
||||
headers['x-pulse-page-auth'] = String(req.headers['x-pulse-page-auth']).trim();
|
||||
}
|
||||
if (req.headers['if-none-match']) {
|
||||
headers['if-none-match'] = String(req.headers['if-none-match']).trim();
|
||||
}
|
||||
if (requestOptions.contentType) {
|
||||
headers['content-type'] = requestOptions.contentType;
|
||||
}
|
||||
|
||||
return fetch(new URL(pathname, thinClientBaseUrl).toString(), {
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonResponse(response) {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = String(response.headers && typeof response.headers.get === 'function' ? response.headers.get('content-type') : '').toLowerCase();
|
||||
if (contentType.indexOf('application/json') === -1 && contentType.indexOf('+json') === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requirePageAuth(allowedScopes) {
|
||||
return function (req, res, next) {
|
||||
if (!sharedSecret) {
|
||||
@@ -114,6 +179,26 @@ function registerPlayerRoutes(app, options) {
|
||||
});
|
||||
|
||||
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
|
||||
if (thinClientBaseUrl) {
|
||||
void fetch(new URL('/api/media/config', thinClientBaseUrl).toString(), {
|
||||
method: 'GET',
|
||||
headers: createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: '/api/media/config'
|
||||
})
|
||||
}).then(async function (response) {
|
||||
res.status(response.status);
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType) {
|
||||
res.type(contentType);
|
||||
}
|
||||
res.send(await response.text());
|
||||
}).catch(function (_error) {
|
||||
res.status(502).json({ error: 'Thin client unavailable.' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
mediaDir: mediaDir,
|
||||
uploadDir: path.join(mediaDir, 'uploads')
|
||||
@@ -213,9 +298,35 @@ function registerPlayerRoutes(app, options) {
|
||||
app.get('/screen/:slug', function (req, res) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.set('Pragma', 'no-cache');
|
||||
if (thinClientBaseUrl) {
|
||||
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(req.params.slug || '').trim() }).token;
|
||||
void fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), {
|
||||
method: 'GET',
|
||||
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
|
||||
}).then(async function (response) {
|
||||
if (!response || response.status >= 400) {
|
||||
res.set('X-Player-Offline', '1');
|
||||
return res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
}
|
||||
const data = await readJsonResponse(response);
|
||||
if (!data) {
|
||||
res.set('X-Player-Offline', '1');
|
||||
return res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
}
|
||||
res.send(common.renderPlayerPage(req.params.slug, data));
|
||||
}).catch(function (error) {
|
||||
if (!isBridgeFetchError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
res.set('X-Player-Offline', '1');
|
||||
res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { bindPlayerToScreen } = require('./onboarding');
|
||||
if (playerIdentifier) {
|
||||
void bindPlayerToScreen(pool, playerIdentifier, req.params.slug)
|
||||
if (playerDeviceId) {
|
||||
void bindPlayerToScreen(pool, playerDeviceId, req.params.slug)
|
||||
.catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
@@ -231,6 +342,19 @@ function registerPlayerRoutes(app, options) {
|
||||
|
||||
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
if (thinClientBaseUrl) {
|
||||
const response = await fetchThinClient(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response) {
|
||||
return res.status(502).send('Thin client unavailable');
|
||||
}
|
||||
res.status(response.status);
|
||||
res.set('Cache-Control', response.headers.get('cache-control') || 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.type(response.headers.get('content-type') || 'text/html; charset=utf-8');
|
||||
return res.send(await response.text());
|
||||
}
|
||||
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
@@ -273,6 +397,29 @@ function registerPlayerRoutes(app, options) {
|
||||
|
||||
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
if (thinClientBaseUrl) {
|
||||
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response) {
|
||||
return res.status(502).json({ error: 'Thin client unavailable.' });
|
||||
}
|
||||
res.status(response.status);
|
||||
const etag = response.headers.get('etag');
|
||||
const cacheControl = response.headers.get('cache-control');
|
||||
if (etag) {
|
||||
res.set('ETag', etag);
|
||||
}
|
||||
if (cacheControl) {
|
||||
res.set('Cache-Control', cacheControl);
|
||||
}
|
||||
if (response.status === 304) {
|
||||
return res.end();
|
||||
}
|
||||
res.type(response.headers.get('content-type') || 'application/json');
|
||||
return res.send(await response.text());
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
|
||||
if (!data.screen) {
|
||||
@@ -293,6 +440,29 @@ function registerPlayerRoutes(app, options) {
|
||||
|
||||
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
|
||||
try {
|
||||
if (thinClientBaseUrl) {
|
||||
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response) {
|
||||
return res.status(502).json({ error: 'Thin client unavailable.' });
|
||||
}
|
||||
res.status(response.status);
|
||||
const etag = response.headers.get('etag');
|
||||
const cacheControl = response.headers.get('cache-control');
|
||||
if (etag) {
|
||||
res.set('ETag', etag);
|
||||
}
|
||||
if (cacheControl) {
|
||||
res.set('Cache-Control', cacheControl);
|
||||
}
|
||||
if (response.status === 304) {
|
||||
return res.end();
|
||||
}
|
||||
res.type(response.headers.get('content-type') || 'application/json');
|
||||
return res.send(await response.text());
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
const announcement = typeof common.fetchActiveAnnouncement === 'function'
|
||||
? await common.fetchActiveAnnouncement(pool, req.params.slug)
|
||||
@@ -335,13 +505,15 @@ function registerPlayerRoutes(app, options) {
|
||||
const connections = playerRuntime.snapshotConnections(req.params.slug);
|
||||
let screen = null;
|
||||
let screenLookupFailed = false;
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||
screen = screenRows[0] || null;
|
||||
} catch (error) {
|
||||
screenLookupFailed = isTransientDbError(error);
|
||||
if (!screenLookupFailed) {
|
||||
throw error;
|
||||
if (pool && typeof pool.query === 'function') {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||
screen = screenRows[0] || null;
|
||||
} catch (error) {
|
||||
screenLookupFailed = isTransientDbError(error);
|
||||
if (!screenLookupFailed) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
@@ -374,7 +546,7 @@ function registerPlayerRoutes(app, options) {
|
||||
const isRedirectCommand = command === 'redirect';
|
||||
let screen = null;
|
||||
let screenLookupFailed = false;
|
||||
if (!isRedirectCommand) {
|
||||
if (!isRedirectCommand && pool && typeof pool.query === 'function') {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
|
||||
screen = screenRows[0] || null;
|
||||
|
||||
Reference in New Issue
Block a user