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
+1 -2
View File
@@ -22,10 +22,9 @@ async function fetchAdminData(pool) {
ORDER BY s.id DESC
`);
const [screens] = await pool.query(`
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name, pl.public_base_url
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
FROM d_screens s
LEFT JOIN c_playlists p ON p.id = s.playlist_id
LEFT JOIN d_players pl ON pl.device_id = s.player_id
ORDER BY s.id DESC
`);
const [playlistSlides] = await pool.query(`
+4 -1
View File
@@ -7,7 +7,8 @@ const { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord } = require('./screens');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens');
const { fetchPlayerRegistrations } = require('./player-registry');
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
const { fetchSlideById, buildSlidePayload } = require('./slides');
@@ -63,6 +64,8 @@ module.exports = {
fetchScreenPlayerUrls,
fetchPlayerPublicBaseUrl,
fetchScreenPlayerRecord,
fetchPlayerRecordByIdentifier,
fetchPlayerRegistrations,
fetchTemplateById,
fetchSlideById,
fetchTemplatesData,
+140
View File
@@ -0,0 +1,140 @@
function normalizeDeviceId(value) {
return String(value || '')
.trim()
.replace(/[^a-zA-Z0-9_-]/g, '')
.slice(0, 128);
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function normalizeIdentifier(value) {
return String(value || '').trim().slice(0, 255);
}
async function columnExists(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?`,
[tableName, columnName]
);
return Number(rows && rows[0] && rows[0].column_count) > 0;
}
async function fetchPlayerRegistrations(pool) {
if (!pool) {
return [];
}
const [rows] = await pool.query(
`SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, modified_at
FROM d_players
ORDER BY modified_at DESC, identifier ASC`
);
return rows;
}
function getConfiguredPlayerIdentifier() {
return normalizeDeviceId(process.env.PLAYER_IDENTIFIER || process.env.PLAYER_DEVICE_ID || '');
}
async function resolvePlayerRegistration(pool, identifier) {
const normalizedIdentifier = normalizeDeviceId(identifier);
if (!pool || !normalizedIdentifier) {
return null;
}
const hasIdentifierColumn = await columnExists(pool, 'd_players', 'identifier');
const hasDeviceIdColumn = await columnExists(pool, 'd_players', 'device_id');
const identifierColumn = hasIdentifierColumn ? 'identifier' : (hasDeviceIdColumn ? 'device_id' : '');
if (!identifierColumn) {
return null;
}
const selectIdExpression = hasIdentifierColumn ? 'id' : 'NULL AS id';
const selectIdentifierExpression = hasIdentifierColumn ? 'identifier' : 'device_id AS identifier';
const orderByExpression = hasIdentifierColumn ? 'modified_at DESC, id DESC' : 'modified_at DESC';
const [rows] = await pool.query(
`SELECT ${selectIdExpression}, ${selectIdentifierExpression}, public_base_url, internal_base_url, last_seen_at
FROM d_players
WHERE ${identifierColumn} = ?
LIMIT 1`,
[normalizedIdentifier]
);
if (rows[0]) {
return rows[0];
}
const [fallbackRows] = await pool.query(
`SELECT ${selectIdExpression}, ${selectIdentifierExpression}, public_base_url, internal_base_url, last_seen_at
FROM d_players
ORDER BY ${orderByExpression}
LIMIT 1`
);
return fallbackRows[0] || null;
}
async function upsertPlayerRegistration(pool, options) {
const identifier = normalizeDeviceId(options && (options.identifier || options.deviceId));
const publicBaseUrl = normalizeBaseUrl(options && options.publicBaseUrl);
const internalBaseUrl = normalizeBaseUrl(options && options.internalBaseUrl);
if (!pool || !identifier) {
return null;
}
await pool.query(
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
public_base_url = VALUES(public_base_url),
internal_base_url = VALUES(internal_base_url),
last_seen_at = CURRENT_TIMESTAMP,
modified_at = CURRENT_TIMESTAMP`,
[identifier, publicBaseUrl || null, internalBaseUrl || null]
);
return resolvePlayerRegistration(pool, identifier);
}
async function recordPlayerHeartbeat(pool, options) {
const identifier = normalizeDeviceId(options && (options.identifier || options.deviceId));
const publicBaseUrl = normalizeBaseUrl(options && options.publicBaseUrl);
const internalBaseUrl = normalizeBaseUrl(options && options.internalBaseUrl);
if (!pool || !identifier) {
return null;
}
await pool.query(
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
public_base_url = COALESCE(VALUES(public_base_url), public_base_url),
internal_base_url = COALESCE(VALUES(internal_base_url), internal_base_url),
last_seen_at = CURRENT_TIMESTAMP,
modified_at = CURRENT_TIMESTAMP`,
[identifier, publicBaseUrl || null, internalBaseUrl || null]
);
return resolvePlayerRegistration(pool, identifier);
}
module.exports = {
normalizeDeviceId: normalizeDeviceId,
normalizeIdentifier: normalizeIdentifier,
getConfiguredPlayerIdentifier: getConfiguredPlayerIdentifier,
fetchPlayerRegistrations: fetchPlayerRegistrations,
resolvePlayerRegistration: resolvePlayerRegistration,
upsertPlayerRegistration: upsertPlayerRegistration,
recordPlayerHeartbeat: recordPlayerHeartbeat
};
+48 -19
View File
@@ -13,6 +13,27 @@ function normalizePlayerBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function getConfiguredPlayerIdentifier() {
return String(process.env.PLAYER_IDENTIFIER || process.env.PLAYER_DEVICE_ID || '').trim() || null;
}
async function fetchPlayerRecordByIdentifier(pool, identifier) {
const normalizedIdentifier = String(identifier || '').trim();
if (!normalizedIdentifier) {
return null;
}
const [rows] = await pool.query(
`SELECT id, identifier, public_base_url, internal_base_url, last_seen_at
FROM d_players
WHERE identifier = ?
LIMIT 1`,
[normalizedIdentifier]
);
return rows[0] || null;
}
function buildScreenPlayerUrl(screen, fallbackBaseUrl) {
const slug = String(screen && screen.slug || '').trim();
if (!slug) {
@@ -28,20 +49,21 @@ function buildScreenPlayerUrl(screen, fallbackBaseUrl) {
}
async function fetchScreenPlayerUrls(pool) {
const playerBaseUrl = await fetchPlayerPublicBaseUrl(pool);
if (!playerBaseUrl) {
return {};
}
const [rows] = await pool.query(`
SELECT s.slug, p.public_base_url
FROM d_screens s
LEFT JOIN d_players p ON p.device_id = s.player_id
WHERE p.public_base_url IS NOT NULL
AND TRIM(p.public_base_url) <> ''
ORDER BY p.modified_at DESC, s.slug ASC
SELECT slug
FROM d_screens
ORDER BY slug ASC
`);
const playerUrls = {};
rows.forEach(function (row) {
const slug = String(row && row.slug || '').trim();
const publicBaseUrl = normalizePlayerBaseUrl(row && row.public_base_url);
const playerUrl = buildScreenPlayerUrl({ slug: slug }, publicBaseUrl);
const playerUrl = buildScreenPlayerUrl({ slug: slug }, playerBaseUrl);
if (slug && playerUrl && !playerUrls[slug]) {
playerUrls[slug] = playerUrl;
}
@@ -51,27 +73,32 @@ async function fetchScreenPlayerUrls(pool) {
}
async function fetchPlayerPublicBaseUrl(pool) {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const [rows] = await pool.query(`
SELECT public_base_url
FROM d_players
WHERE device_id = '1'
WHERE identifier = ?
LIMIT 1
`);
`, [configuredPlayerIdentifier]);
return normalizePlayerBaseUrl(rows[0] && rows[0].public_base_url) || null;
}
async function fetchScreenPlayerRecord(pool, slug) {
const [rows] = await pool.query(`
SELECT p.device_id, p.public_base_url, p.internal_base_url, p.last_seen_at
FROM d_screens s
JOIN d_players p ON p.device_id = s.player_id
WHERE s.slug = ?
ORDER BY p.modified_at DESC, p.device_id ASC
LIMIT 1
`, [slug]);
if (slug) {
const [rows] = await pool.query(
`SELECT slug
FROM d_screens
WHERE slug = ?
LIMIT 1`,
[slug]
);
if (!rows.length) {
return null;
}
}
return rows[0] || null;
return fetchPlayerRecordByIdentifier(pool, getConfiguredPlayerIdentifier());
}
async function uniqueScreenSlug(pool, baseSlug, excludeId) {
@@ -112,6 +139,8 @@ async function fetchScreenEditData(pool) {
module.exports = {
slugify,
normalizePlayerBaseUrl,
getConfiguredPlayerIdentifier,
fetchPlayerRecordByIdentifier,
buildScreenPlayerUrl,
fetchScreenPlayerUrls,
fetchPlayerPublicBaseUrl,