Add multi-player and remote bridge support

This commit is contained in:
2026-08-07 02:58:18 +01:00
parent a4f8a807ff
commit 74318eb34e
58 changed files with 3785 additions and 432 deletions
-4
View File
@@ -208,11 +208,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
const targetPlayerRecord = typeof common.fetchScreenPlayerRecord === 'function'
? await common.fetchScreenPlayerRecord(pool, targetScreenSlug)
: null;
const targetBaseUrl = String(
targetPlayerRecord && targetPlayerRecord.public_base_url ||
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') ||
''
).trim().replace(/\/$/, '');
+5 -10
View File
@@ -111,7 +111,7 @@ module.exports = function registerManageRoutes(app, deps) {
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
const actorId = getAuditUserId(req);
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, player_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, slug, playlistId, '1', actorId, actorId]);
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
closeUrl: '/screens',
newUrl: '/screens/new',
@@ -144,19 +144,14 @@ module.exports = function registerManageRoutes(app, deps) {
const previousPlaylistId = screen.playlist_id;
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
const previousSlug = String(screen.slug || '').trim();
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, '1', getAuditUserId(req), screen.id]);
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
if (previousPlaylistId !== playlistId && previousSlug) {
await notifyPlayerScreens([previousSlug], 'refresh');
}
if (previousSlug && previousSlug !== slug) {
const playerRecord = typeof common.fetchScreenPlayerRecord === 'function'
? await common.fetchScreenPlayerRecord(pool, previousSlug)
: null;
const playerBaseUrl = playerRecord && playerRecord.public_base_url
? String(playerRecord.public_base_url).replace(/\/$/, '')
: typeof common.fetchPlayerPublicBaseUrl === 'function'
? await common.fetchPlayerPublicBaseUrl(pool)
: '';
const playerBaseUrl = typeof common.fetchPlayerPublicBaseUrl === 'function'
? await common.fetchPlayerPublicBaseUrl(pool)
: '';
await forwardPlayerCommand(previousSlug, {
command: 'redirect',
url: playerBaseUrl ? `${playerBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}`
+33
View File
@@ -0,0 +1,33 @@
// Internal sync trigger routes for player-driven queue flushes.
const { verifyRequestAuth } = require('#src/request-auth');
function requireRequestAuth(req, res, next) {
if (!verifyRequestAuth(req)) {
return res.status(401).json({ error: 'Request authentication required.' });
}
next();
}
module.exports = function registerInternalSyncRoutes(app, deps) {
const uploadSyncService = deps && deps.uploadSyncService;
const mediaDir = String(deps && deps.mediaDir || '').trim();
if (!uploadSyncService || typeof uploadSyncService.flushPendingPlayerUploadSyncs !== 'function' || typeof uploadSyncService.runMediaSyncTask !== 'function' || !mediaDir) {
throw new Error('registerInternalSyncRoutes requires the sync dependencies.');
}
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
try {
await uploadSyncService.runMediaSyncTask({
mode: 'initial',
uploadDir: mediaDir
});
await uploadSyncService.flushPendingPlayerUploadSyncs();
res.json({ ok: true });
} catch (error) {
next(error);
}
});
};
+17
View File
@@ -15,6 +15,8 @@ const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes');
const registerTimetableRoutes = require('./data-sources/timetables/routes');
const registerSettingsRoutes = require('./settings/background-tasks');
const registerFontRoutes = require('./settings/fonts');
const registerInternalSyncRoutes = require('./internal/sync');
const registerScreensRoutes = require('./signage/screens/routes');
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
function registerRoutes(app, deps) {
@@ -22,6 +24,21 @@ function registerRoutes(app, deps) {
registerAuthAndAccountRoutes(app, deps);
registerSignageRoutes(app, deps);
registerSettingsAndContentRoutes(app, deps);
registerInternalSyncRoutes(app, {
uploadSyncService: deps.uploadSyncService,
mediaDir: deps.mediaDir
});
registerScreensRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
mediaDir: deps.mediaDir,
buildDashboardState: deps.buildDashboardState,
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
requirePermission: deps.requirePermission,
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
getScreenConnections: deps.playerActionService.getScreenConnections,
});
}
function registerAuthAndAccountRoutes(app, deps) {
@@ -21,8 +21,10 @@ module.exports = function renderDashboardPage(data, message, currentUser) {
playlists: data.playlists || [],
screens: data.screens || [],
clients: data.clients || [],
kioskPlayers: data.kioskPlayers || [],
slides: data.slides || [],
connectedClientsCount: Number(data.connectedClientsCount || 0),
connectedPlayersCount: Number(data.connectedPlayersCount || data.connectedClientsCount || 0),
primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null,
scripts: ['js/dashboard/dashboard-page.js']
});
@@ -4,7 +4,8 @@ function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
const viewScreen = Object.assign({
name: '',
slug: '',
playlist_id: null
playlist_id: null,
player_urls: []
}, screen || {});
return {
+36 -20
View File
@@ -1,6 +1,7 @@
// Screen route registration and dashboard wiring.
const fs = require('fs');
const { screenPlayerUrl } = require('../../../routes/common');
module.exports = function registerScreensRoutes(app, deps) {
const pool = deps.pool;
@@ -19,6 +20,17 @@ module.exports = function registerScreensRoutes(app, deps) {
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
};
function normalizeTargetPlayerUrl(value) {
const normalized = String(value || '').trim().replace(/\/$/, '');
if (!normalized) {
return null;
}
if (!/^https?:\/\//i.test(normalized)) {
return null;
}
return normalized;
}
function requireQueryPermission(readPermissionKey, editPermissionKey) {
return function (req, res, next) {
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
@@ -58,7 +70,6 @@ module.exports = function registerScreensRoutes(app, deps) {
return Object.assign({}, screen, {
player_connection_count: playerConnectionCount,
player_url: dashboardScreen && dashboardScreen.player_url ? dashboardScreen.player_url : screen.player_url || null
});
});
}
@@ -71,6 +82,21 @@ module.exports = function registerScreensRoutes(app, deps) {
return common.fetchPlayerPublicBaseUrl(pool);
}
async function buildScreenPlayerUrls(screen) {
const playerRegistrations = typeof common.fetchPlayerRegistrations === 'function'
? await common.fetchPlayerRegistrations(pool)
: [];
return (Array.isArray(playerRegistrations) ? playerRegistrations : []).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, {
public_base_url: baseUrl || null,
player_url: playerUrl || null
});
});
}
function buildLauncherContent(templatePath, playerUrl) {
const template = fs.readFileSync(templatePath, 'utf8');
const normalizedPlayerUrl = String(playerUrl || '').trim();
@@ -100,8 +126,8 @@ module.exports = function registerScreensRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
screen.player_url = await buildPlayerUrl();
if (screen.player_url) {
screen.player_urls = await buildScreenPlayerUrls(screen);
if (screen.player_urls.length) {
screen.launcher_downloads = launcherDownloadPaths;
}
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
@@ -113,18 +139,9 @@ module.exports = function registerScreensRoutes(app, deps) {
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const dashboardState = await buildDashboardState(pool);
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
? await common.fetchScreenPlayerUrls(pool)
: {};
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderScreensPage({
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []).map(function (screen) {
const slug = String(screen && screen.slug || '').trim();
const registryUrl = slug && playerUrlsBySlug[slug] ? String(playerUrlsBySlug[slug]).trim() : '';
return Object.assign({}, screen, {
player_url: screen.player_url || registryUrl || null
});
}),
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []),
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
@@ -138,11 +155,8 @@ module.exports = function registerScreensRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
? await common.fetchScreenPlayerUrls(pool)
: {};
screen.player_url = String(playerUrlsBySlug[screen.slug] || '').trim() || null;
if (screen.player_url) {
screen.player_urls = await buildScreenPlayerUrls(screen);
if (screen.player_urls.length) {
screen.launcher_downloads = launcherDownloadPaths;
}
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
@@ -155,7 +169,8 @@ module.exports = function registerScreensRoutes(app, deps) {
app.get('/downloads/kiosk/pulse-signage-kiosk.bat', requirePermission('screens.update'), async function (_req, res, next) {
try {
const playerUrl = await buildPlayerUrl();
const playerUrl = normalizeTargetPlayerUrl(_req.query && _req.query.playerUrl)
|| await buildPlayerUrl();
if (!playerUrl) {
return res.status(404).send('Player URL is not available yet.');
}
@@ -173,7 +188,8 @@ module.exports = function registerScreensRoutes(app, deps) {
app.get('/downloads/kiosk/pulse-signage-kiosk.sh', requirePermission('screens.update'), async function (_req, res, next) {
try {
const playerUrl = await buildPlayerUrl();
const playerUrl = normalizeTargetPlayerUrl(_req.query && _req.query.playerUrl)
|| await buildPlayerUrl();
if (!playerUrl) {
return res.status(404).send('Player URL is not available yet.');
}
+43
View File
@@ -1,14 +1,32 @@
// Slide route registration and pagination wiring.
const { renderFragment } = require('../../../view');
const path = require('path');
const { verifyRequestAuth } = require('#src/request-auth');
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnail-preview');
function safeJsonForScript(value) {
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
}
module.exports = function registerSlidesRoutes(app, deps) {
const pages = deps.pages;
const common = deps.common;
const pool = deps.pool;
const mediaDir = String(deps.mediaDir || path.join(__dirname, '..', '..', '..', '..', 'media')).trim();
const webBaseUrl = String(deps.webBaseUrl || '').trim().replace(/\/$/, '');
const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission;
function requireRequestAuth(req, res, next) {
if (!verifyRequestAuth(req)) {
return res.status(401).json({ error: 'Request authentication required.' });
}
next();
}
const LIST_PAGE_SIZE = 25;
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
@@ -35,5 +53,30 @@ module.exports = function registerSlidesRoutes(app, deps) {
frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body'
}));
});
app.get('/api/internal/slide-thumbnails/:id/popup-preview', async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
}
const payload = buildThumbnailPreviewPayload(slide, {
baseUrl: webBaseUrl || (String(req.headers.host || '').trim() ? `${req.protocol}://${String(req.headers.host).trim()}` : ''),
fontStylesheetHref: getFontStylesheetHref(mediaDir)
});
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(renderFragment('slides/popup-preview', {
title: 'Slide preview',
framePopupCard: true,
hideFrameHeader: true,
frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body',
popupPreviewPayloadJson: safeJsonForScript(payload)
}));
} catch (error) {
next(error);
}
});
};