Release 2.22.0: normalize playlist schedules and unify player sync
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m21s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
lzstealth committed 2026-10-27 22:54:38 +00:00
1 parent 7748129307
commit b71958cb38
39 files changed
+1174 -314

No files matched your search

+24 -12
View File
@@ -28,31 +28,43 @@ async function fetchAdminData(pool) {
ORDER BY s.id DESC
`);
const [playlistSlides] = await pool.query(`
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.disable_audio, ps.group_key, ps.group_name, ps.group_pick_count, ps.group_shuffle, ps.group_mode, ps.group_repeat_count, ps.group_schedule_rules_json, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.disable_audio, ps.group_id, g.group_key, g.name AS group_name, g.pick_count AS group_pick_count, g.shuffle AS group_shuffle, g.mode AS group_mode, g.repeat_count AS group_repeat_count, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_playlist_slides ps FORCE INDEX (idx_playlist_slides_playlist_position)
STRAIGHT_JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_playlist_groups g ON g.id = ps.group_id
LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC
`);
const [playlistScheduleRules] = await pool.query(`
SELECT id, playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_at, modified_at, created_by, modified_by
FROM c_playlist_slide_schedule_rules
ORDER BY playlist_slide_id ASC, position ASC, id ASC
SELECT id, playlist_slide_id, playlist_group_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_at, modified_at, created_by, modified_by
FROM c_playlist_schedule_rules
ORDER BY playlist_slide_id ASC, playlist_group_id ASC, position ASC, id ASC
`);
const rulesBySlideId = new Map();
const rulesByPlaylistSlideId = new Map();
const rulesByGroupId = new Map();
playlistScheduleRules.forEach(function (rule) {
const slideId = Number(rule.playlist_slide_id);
if (!rulesBySlideId.has(slideId)) {
rulesBySlideId.set(slideId, []);
if (rule.playlist_slide_id !== null && rule.playlist_slide_id !== undefined) {
const playlistSlideId = Number(rule.playlist_slide_id);
if (!rulesByPlaylistSlideId.has(playlistSlideId)) {
rulesByPlaylistSlideId.set(playlistSlideId, []);
}
rulesByPlaylistSlideId.get(playlistSlideId).push(rule);
}
if (rule.playlist_group_id !== null && rule.playlist_group_id !== undefined) {
const groupId = Number(rule.playlist_group_id);
if (!rulesByGroupId.has(groupId)) {
rulesByGroupId.set(groupId, []);
}
rulesByGroupId.get(groupId).push(rule);
}
rulesBySlideId.get(slideId).push(rule);
});
const playlistSlidesWithRules = playlistSlides.map(function (slide) {
return Object.assign({}, slide, {
scheduleRules: rulesBySlideId.get(Number(slide.id)) || []
scheduleRules: rulesByPlaylistSlideId.get(Number(slide.id)) || [],
groupScheduleRules: rulesByGroupId.get(Number(slide.group_id)) || []
});
});
+29 -11
View File
@@ -127,6 +127,25 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS c_playlist_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_id INT NOT NULL,
group_key VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
pick_count INT NOT NULL DEFAULT 1,
shuffle TINYINT(1) NOT NULL DEFAULT 0,
mode VARCHAR(16) NOT NULL DEFAULT 'select',
repeat_count INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_groups_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE,
UNIQUE KEY uq_playlist_groups_playlist_key (playlist_id, group_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS c_playlist_slides (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -136,27 +155,23 @@ async function ensureSchema(pool, options) {
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
disable_audio TINYINT(1) NOT NULL DEFAULT 1,
group_key VARCHAR(64) NULL,
group_name VARCHAR(128) NULL,
group_pick_count INT NULL,
group_shuffle TINYINT(1) NOT NULL DEFAULT 0,
group_mode VARCHAR(16) NOT NULL DEFAULT 'select',
group_repeat_count INT NOT NULL DEFAULT 1,
group_schedule_rules_json JSON NULL,
group_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES c_slides(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_slides_group FOREIGN KEY (group_id) REFERENCES c_playlist_groups(id) ON DELETE SET NULL,
INDEX idx_playlist_slides_playlist_position (playlist_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules (
CREATE TABLE IF NOT EXISTS c_playlist_schedule_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_slide_id INT NOT NULL,
playlist_slide_id INT NULL,
playlist_group_id INT NULL,
position INT NOT NULL DEFAULT 0,
start_datetime DATETIME NULL,
end_datetime DATETIME NULL,
@@ -167,8 +182,11 @@ async function ensureSchema(pool, options) {
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position)
CONSTRAINT fk_playlist_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_schedule_rules_group FOREIGN KEY (playlist_group_id) REFERENCES c_playlist_groups(id) ON DELETE CASCADE,
CONSTRAINT chk_playlist_schedule_rules_owner CHECK ((playlist_slide_id IS NOT NULL AND playlist_group_id IS NULL) OR (playlist_slide_id IS NULL AND playlist_group_id IS NOT NULL)),
INDEX idx_playlist_schedule_rules_slide_position (playlist_slide_id, position),
INDEX idx_playlist_schedule_rules_group_position (playlist_group_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
+216
View File
@@ -1026,6 +1026,222 @@ const VERSIONED_MIGRATIONS = [
]
);
}
},
{
version: '2.22.0',
label: 'v2.22.0 normalized playlist groups and schedule rules',
run: async function (pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS c_playlist_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_id INT NOT NULL,
group_key VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
pick_count INT NOT NULL DEFAULT 1,
shuffle TINYINT(1) NOT NULL DEFAULT 0,
mode VARCHAR(16) NOT NULL DEFAULT 'select',
repeat_count INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_groups_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE,
UNIQUE KEY uq_playlist_groups_playlist_key (playlist_id, group_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await ensureColumn(pool, 'c_playlist_slides', 'group_id', 'INT NULL', 'disable_audio');
await pool.query(`
CREATE TABLE IF NOT EXISTS c_playlist_schedule_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_slide_id INT NULL,
playlist_group_id INT NULL,
position INT NOT NULL DEFAULT 0,
start_datetime DATETIME NULL,
end_datetime DATETIME NULL,
start_time TIME NULL,
end_time TIME NULL,
schedule_days_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_schedule_rules_group FOREIGN KEY (playlist_group_id) REFERENCES c_playlist_groups(id) ON DELETE CASCADE,
CONSTRAINT chk_playlist_schedule_rules_owner CHECK ((playlist_slide_id IS NOT NULL AND playlist_group_id IS NULL) OR (playlist_slide_id IS NULL AND playlist_group_id IS NOT NULL)),
INDEX idx_playlist_schedule_rules_slide_position (playlist_slide_id, position),
INDEX idx_playlist_schedule_rules_group_position (playlist_group_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await ensureForeignKey(pool, 'c_playlist_slides', 'fk_playlist_slides_group', 'group_id', 'c_playlist_groups', 'id', 'SET NULL');
const legacyGroupColumns = [
'group_key', 'group_name', 'group_pick_count', 'group_shuffle',
'group_mode', 'group_repeat_count', 'group_schedule_rules_json'
];
const legacyGroupColumnPresence = await Promise.all(legacyGroupColumns.map(function (columnName) {
return columnExists(pool, 'c_playlist_slides', columnName);
}));
if (legacyGroupColumnPresence.some(Boolean) && !legacyGroupColumnPresence.every(Boolean)) {
throw new Error('Cannot migrate playlist groups because the legacy group columns are incomplete.');
}
const legacyGroupsByKey = new Map();
if (legacyGroupColumnPresence.every(Boolean)) {
const [legacyGroupRows] = await pool.query(`
SELECT id, playlist_id, group_key, group_name, group_pick_count, group_shuffle,
group_mode, group_repeat_count, group_schedule_rules_json, created_at, created_by, modified_at, modified_by
FROM c_playlist_slides
WHERE group_key IS NOT NULL
ORDER BY playlist_id ASC, group_key ASC, id ASC
`);
function parseLegacyGroupRules(value, groupLabel) {
const parsed = typeof value === 'string' ? parseJsonSafe(value) : value;
if (parsed == null) return [];
if (!Array.isArray(parsed)) {
throw new Error('Cannot migrate invalid schedule rules for playlist group ' + groupLabel + '.');
}
return parsed.map(function (rule, index) {
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
throw new Error('Cannot migrate invalid schedule rule ' + (index + 1) + ' for playlist group ' + groupLabel + '.');
}
const startDatetime = String(rule.start_datetime || '').trim() || null;
const endDatetime = String(rule.end_datetime || '').trim() || null;
const startTime = String(rule.start_time || '').trim() || null;
const endTime = String(rule.end_time || '').trim() || null;
let days = rule.days;
if (typeof days === 'string') days = parseJsonSafe(days);
if (days == null && rule.schedule_days_json != null) {
days = typeof rule.schedule_days_json === 'string' ? parseJsonSafe(rule.schedule_days_json) : rule.schedule_days_json;
}
if (days == null) days = [];
if (!Array.isArray(days)) {
throw new Error('Cannot migrate invalid schedule days for playlist group ' + groupLabel + '.');
}
days = Array.from(new Set(days.map(Number))).sort(function (left, right) { return left - right; });
if (days.some(function (day) { return !Number.isInteger(day) || day < 0 || day > 6; })) {
throw new Error('Cannot migrate invalid schedule days for playlist group ' + groupLabel + '.');
}
if ((startDatetime && Number.isNaN(new Date(startDatetime).getTime()))
|| (endDatetime && Number.isNaN(new Date(endDatetime).getTime()))
|| (startDatetime && endDatetime && new Date(endDatetime) <= new Date(startDatetime))) {
throw new Error('Cannot migrate invalid datetime bounds for playlist group ' + groupLabel + '.');
}
if ((startTime || endTime) && (!startTime || !endTime
|| !/^\d{2}:\d{2}(?::\d{2})?$/.test(startTime)
|| !/^\d{2}:\d{2}(?::\d{2})?$/.test(endTime)
|| endTime <= startTime)) {
throw new Error('Cannot migrate invalid time bounds for playlist group ' + groupLabel + '.');
}
if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
throw new Error('Cannot migrate an empty schedule rule for playlist group ' + groupLabel + '.');
}
return {
start_datetime: startDatetime,
end_datetime: endDatetime,
start_time: startTime,
end_time: endTime,
days: days
};
});
}
(legacyGroupRows || []).forEach(function (row) {
const groupKey = String(row.group_key || '').trim();
const groupLabel = String(row.playlist_id) + '/' + groupKey;
if (!groupKey) {
throw new Error('Cannot migrate a playlist group with an empty key (' + groupLabel + ').');
}
const settings = {
name: String(row.group_name || '').trim(),
pickCount: Number(row.group_pick_count) || 1,
shuffle: Number(row.group_shuffle) ? 1 : 0,
mode: row.group_mode === 'repeat' ? 'repeat' : 'select',
repeatCount: Math.min(100, Math.max(1, Number(row.group_repeat_count) || 1)),
rules: parseLegacyGroupRules(row.group_schedule_rules_json, groupLabel)
};
if (!settings.name) {
throw new Error('Cannot migrate playlist group ' + groupLabel + ' without a name.');
}
const mapKey = String(row.playlist_id) + '\\0' + groupKey;
const signature = JSON.stringify(settings);
const existing = legacyGroupsByKey.get(mapKey);
if (existing && existing.signature !== signature) {
throw new Error('Cannot migrate inconsistent settings across members of playlist group ' + groupLabel + '.');
}
if (existing) {
existing.memberIds.push(Number(row.id));
} else {
legacyGroupsByKey.set(mapKey, {
playlistId: Number(row.playlist_id),
groupKey: groupKey,
settings: settings,
signature: signature,
memberIds: [Number(row.id)],
createdAt: row.created_at,
createdBy: row.created_by,
modifiedAt: row.modified_at,
modifiedBy: row.modified_by
});
}
});
for (const group of legacyGroupsByKey.values()) {
const [insertResult] = await pool.query(
`INSERT INTO c_playlist_groups (playlist_id, group_key, name, pick_count, shuffle, mode, repeat_count, created_at, created_by, modified_at, modified_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
[group.playlistId, group.groupKey, group.settings.name, group.settings.pickCount, group.settings.shuffle, group.settings.mode, group.settings.repeatCount, group.createdAt, group.createdBy, group.modifiedAt, group.modifiedBy]
);
let groupId = Number(insertResult && insertResult.insertId);
if (!groupId) {
const [existingRows] = await pool.query('SELECT id FROM c_playlist_groups WHERE playlist_id = ? AND group_key = ?', [group.playlistId, group.groupKey]);
groupId = Number(existingRows && existingRows[0] && existingRows[0].id);
}
if (!groupId) {
throw new Error('Could not resolve migrated playlist group ' + group.playlistId + '/' + group.groupKey + '.');
}
await pool.query('UPDATE c_playlist_groups SET name = ?, pick_count = ?, shuffle = ?, mode = ?, repeat_count = ? WHERE id = ?', [group.settings.name, group.settings.pickCount, group.settings.shuffle, group.settings.mode, group.settings.repeatCount, groupId]);
await pool.query('DELETE FROM c_playlist_schedule_rules WHERE playlist_group_id = ?', [groupId]);
for (let index = 0; index < group.settings.rules.length; index += 1) {
const rule = group.settings.rules[index];
await pool.query(
'INSERT INTO c_playlist_schedule_rules (playlist_group_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[groupId, index, rule.start_datetime, rule.end_datetime, rule.start_time, rule.end_time, rule.days.length ? JSON.stringify(rule.days) : null, group.modifiedBy || group.createdBy || null, group.modifiedBy || group.createdBy || null]
);
}
for (const memberId of group.memberIds) {
await pool.query('UPDATE c_playlist_slides SET group_id = ? WHERE id = ?', [groupId, memberId]);
}
}
}
if (await tableExists(pool, 'c_playlist_slide_schedule_rules')) {
const [sourceRows] = await pool.query('SELECT COUNT(*) AS rule_count FROM c_playlist_slide_schedule_rules');
const sourceCount = Number(sourceRows && sourceRows[0] && sourceRows[0].rule_count) || 0;
await pool.query('DELETE FROM c_playlist_schedule_rules WHERE playlist_slide_id IS NOT NULL');
await pool.query(`
INSERT INTO c_playlist_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_at, created_by, modified_at, modified_by)
SELECT playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_at, created_by, modified_at, modified_by
FROM c_playlist_slide_schedule_rules
`);
const [targetRows] = await pool.query('SELECT COUNT(*) AS rule_count FROM c_playlist_schedule_rules WHERE playlist_slide_id IS NOT NULL');
const targetCount = Number(targetRows && targetRows[0] && targetRows[0].rule_count) || 0;
if (sourceCount !== targetCount) {
throw new Error('Playlist slide schedule migration count mismatch: expected ' + sourceCount + ', copied ' + targetCount + '.');
}
await pool.query('DROP TABLE c_playlist_slide_schedule_rules');
}
const legacyGroupColumnsToDrop = legacyGroupColumns.filter(function (columnName, index) {
return legacyGroupColumnPresence[index];
});
if (legacyGroupColumnsToDrop.length) {
await pool.query('ALTER TABLE c_playlist_slides ' + legacyGroupColumnsToDrop.map(function (columnName) {
return 'DROP COLUMN ' + columnName;
}).join(', '));
}
}
}
];
+21
View File
@@ -447,6 +447,27 @@ async function start() {
});
});
app.get('/api/media/remote-images/:filename', requireRequestAuth, async function (req, res, next) {
try {
const filename = String(req.params.filename || '').trim();
if (!/^[a-f0-9]{64}\.(?:png|jpe?g|gif|webp|svg)$/i.test(filename)) {
return res.status(400).json({ error: 'Filename is invalid.' });
}
const filePath = path.join(config.mediaDir, 'player-cache', 'remote-images', filename);
const file = await fs.promises.readFile(filePath).catch(function (error) {
if (error && error.code === 'ENOENT') return null;
throw error;
});
if (!file) return res.status(404).json({ error: 'Cached image not found.' });
if (file.length > 10 * 1024 * 1024) return res.status(413).json({ error: 'Cached image is too large.' });
res.type(path.extname(filename));
res.set('Cache-Control', 'private, max-age=3600');
return res.send(file);
} catch (error) {
next(error);
}
});
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
try {
const relativePath = resolveMediaPath(req.params.filename);
+23 -75
View File
@@ -171,7 +171,7 @@ async function start() {
async function triggerWebMediaSync() {
const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL;
if (!isRemotePlayer || !syncBaseUrl) {
if (!syncBaseUrl || !PLAYER_DEVICE_ID) {
return false;
}
@@ -203,43 +203,24 @@ async function start() {
}
}
async function triggerWebFontSync() {
const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL;
if (!isRemotePlayer || !syncBaseUrl) {
return false;
}
const requestBody = {
playerIdentifier: PLAYER_DEVICE_ID,
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL
};
try {
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-font',
body: requestBody
});
const response = await fetch(`${syncBaseUrl}/api/internal/sync/player-font`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json',
'Content-Type': 'application/json'
}, authHeaders),
body: JSON.stringify(requestBody)
});
return Boolean(response && response.ok);
} catch (_error) {
console.warn('[player] Startup font sync failed');
return false;
}
}
let webMediaSyncCompleted = false;
let webFontSyncCompleted = false;
let webFontSyncTriggered = false;
let webMediaSyncTriggered = false;
function triggerMediaSyncIfNeeded() {
if (webMediaSyncTriggered || webMediaSyncCompleted) {
return;
}
webMediaSyncTriggered = true;
triggerWebMediaSync().then(function (success) {
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
if (!success) {
webMediaSyncTriggered = false;
}
}).catch(function () {
webMediaSyncTriggered = false;
});
}
function shouldTriggerReconnectSync() {
if (!lastDisconnectAt) {
@@ -414,7 +395,6 @@ async function start() {
pathname: '/ws/players',
timestamp: timestamp
});
let webMediaSyncTriggered = false;
socket = new WebSocket(thinClientUrl, {
headers: Object.assign({
'x-pulse-request-timestamp': timestamp
@@ -441,39 +421,6 @@ async function start() {
refreshThinClientRegistration = sendHeartbeat;
function triggerMediaSyncIfNeeded() {
if (webMediaSyncTriggered || webMediaSyncCompleted) {
return;
}
webMediaSyncTriggered = true;
triggerWebMediaSync().then(function (success) {
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
if (!success) {
webMediaSyncTriggered = false;
}
}).catch(function () {
webMediaSyncTriggered = false;
});
}
function triggerFontSyncIfNeeded() {
if (webFontSyncTriggered || webFontSyncCompleted) {
return;
}
webFontSyncTriggered = true;
triggerWebFontSync().then(function (success) {
webFontSyncCompleted = Boolean(success) || webFontSyncCompleted;
if (!success) {
webFontSyncTriggered = false;
}
}).catch(function () {
webFontSyncTriggered = false;
webFontSyncCompleted = false;
});
}
function sendSnapshot(slug) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
@@ -531,7 +478,6 @@ async function start() {
if (parsedMessage && String(parsedMessage.type || '').trim() === 'heartbeat-ack') {
if (shouldTriggerReconnectSync()) {
triggerMediaSyncIfNeeded();
triggerFontSyncIfNeeded();
}
return;
}
@@ -552,8 +498,7 @@ async function start() {
socket.on('close', function () {
lastDisconnectAt = Date.now();
webFontSyncTriggered = false;
webFontSyncCompleted = false;
webMediaSyncTriggered = false;
webMediaSyncCompleted = false;
clearTimers();
thinClientSocket = null;
@@ -632,13 +577,16 @@ async function start() {
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) {
console.error(error);
});
triggerMediaSyncIfNeeded();
}
setInterval(function () {
if (isRemotePlayer) {
return;
}
syncDatabaseState().catch(function (error) {
syncDatabaseState().then(function () {
triggerMediaSyncIfNeeded();
}).catch(function (error) {
console.error(error);
});
}, DB_SYNC_INTERVAL_MS);
+30 -21
View File
@@ -204,31 +204,42 @@ function createPlayerPlaylistService(options) {
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(`
SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at,
SELECT sl.id, ps.id AS playlist_slide_id, ps.group_id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at,
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.disable_audio,
ps.group_key, ps.group_name, ps.group_pick_count, ps.group_shuffle, ps.group_mode, ps.group_repeat_count, ps.group_schedule_rules_json,
g.group_key, g.name AS group_name, g.pick_count AS group_pick_count, g.shuffle AS group_shuffle, g.mode AS group_mode, g.repeat_count AS group_repeat_count,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
FROM c_playlist_slides ps FORCE INDEX (idx_playlist_slides_playlist_position)
STRAIGHT_JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_playlist_groups g ON g.id = ps.group_id
LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ?
ORDER BY ps.position ASC, ps.id ASC
`, [screen.playlist_id]);
const [scheduleRuleRows] = await pool.query(`
SELECT r.id, r.playlist_slide_id, r.position, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json, r.created_at, r.modified_at, r.created_by, r.modified_by
FROM c_playlist_slide_schedule_rules r
JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
WHERE ps.playlist_id = ?
ORDER BY r.playlist_slide_id ASC, r.position ASC, r.id ASC
`, [screen.playlist_id]);
const rulesBySlideId = {};
SELECT r.id, r.playlist_slide_id, r.playlist_group_id, r.position, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json, r.created_at, r.modified_at, r.created_by, r.modified_by
FROM c_playlist_schedule_rules r
WHERE r.playlist_slide_id IN (SELECT id FROM c_playlist_slides WHERE playlist_id = ?)
OR r.playlist_group_id IN (SELECT id FROM c_playlist_groups WHERE playlist_id = ?)
ORDER BY r.playlist_slide_id ASC, r.playlist_group_id ASC, r.position ASC, r.id ASC
`, [screen.playlist_id, screen.playlist_id]);
const rulesByPlaylistSlideId = {};
const rulesByGroupId = {};
scheduleRuleRows.forEach(function (rule) {
const slideId = Number(rule.playlist_slide_id);
if (!rulesBySlideId[slideId]) {
rulesBySlideId[slideId] = [];
if (rule.playlist_slide_id !== null && rule.playlist_slide_id !== undefined) {
const playlistSlideId = Number(rule.playlist_slide_id);
if (!rulesByPlaylistSlideId[playlistSlideId]) {
rulesByPlaylistSlideId[playlistSlideId] = [];
}
rulesByPlaylistSlideId[playlistSlideId].push(rule);
}
if (rule.playlist_group_id !== null && rule.playlist_group_id !== undefined) {
const groupId = Number(rule.playlist_group_id);
if (!rulesByGroupId[groupId]) {
rulesByGroupId[groupId] = [];
}
rulesByGroupId[groupId].push(rule);
}
rulesBySlideId[slideId].push(rule);
});
const templateIds = slideRows
@@ -319,9 +330,6 @@ function createPlayerPlaylistService(options) {
}
}));
const groupScheduleRules = slide.group_schedule_rules_json
? common.parseJsonSafe(slide.group_schedule_rules_json) || []
: [];
if (template && template.background_image_path && !template.background_image_variants) {
const variants = await getImageVariants(template.background_image_path);
if (variants.length) {
@@ -356,14 +364,14 @@ function createPlayerPlaylistService(options) {
duration_seconds: videoDuration || storedDuration,
use_video_duration: Boolean(slide.use_video_duration),
disable_audio: disableAudio,
scheduleRules: rulesBySlideId[Number(slide.id)] || [],
scheduleRules: rulesByPlaylistSlideId[Number(slide.playlist_slide_id)] || [],
groupKey: slide.group_key || null,
groupName: slide.group_name || null,
groupPickCount: Number(slide.group_pick_count) || null,
groupShuffle: Boolean(Number(slide.group_shuffle)),
groupMode: slide.group_mode === 'repeat' ? 'repeat' : 'select',
groupRepeatCount: Math.min(100, Math.max(1, Number(slide.group_repeat_count) || 1)),
groupScheduleRules: Array.isArray(groupScheduleRules) ? groupScheduleRules : [],
groupScheduleRules: rulesByGroupId[Number(slide.group_id)] || [],
template_id: slide.template_id,
template: template,
content: content
@@ -457,18 +465,19 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, slide.duration_seconds);
updatePlaylistRevisionHash(hash, slide.use_video_duration);
updatePlaylistRevisionHash(hash, slide.disable_audio);
updatePlaylistRevisionHash(hash, slide.group_id);
updatePlaylistRevisionHash(hash, slide.group_key);
updatePlaylistRevisionHash(hash, slide.group_name);
updatePlaylistRevisionHash(hash, slide.group_pick_count);
updatePlaylistRevisionHash(hash, slide.group_shuffle);
updatePlaylistRevisionHash(hash, slide.group_mode);
updatePlaylistRevisionHash(hash, slide.group_repeat_count);
updatePlaylistRevisionHash(hash, slide.group_schedule_rules_json);
});
(Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) {
updatePlaylistRevisionHash(hash, rule.id);
updatePlaylistRevisionHash(hash, rule.playlist_slide_id);
updatePlaylistRevisionHash(hash, rule.playlist_group_id);
updatePlaylistRevisionHash(hash, rule.position);
updatePlaylistRevisionHash(hash, rule.start_datetime);
updatePlaylistRevisionHash(hash, rule.end_datetime);
+7 -11
View File
@@ -107,13 +107,8 @@ function normalizeScheduleRule(rule) {
const endDatetime = String(input.end_datetime || input.endDateTime || '').trim();
const startTime = String(input.start_time || input.startTime || '').trim();
const endTime = String(input.end_time || input.endTime || '').trim();
const days = parseScheduleDays(input.days);
const days = parseScheduleDays(input.days != null ? input.days : input.schedule_days_json);
if (startDatetime || endDatetime) {
if (!startDatetime || !endDatetime) {
return null;
}
}
if (startTime || endTime) {
if (!startTime || !endTime) {
return null;
@@ -159,13 +154,14 @@ function matchesScheduleRule(rule, now) {
return false;
}
if (normalized.start_datetime && normalized.end_datetime) {
const start = new Date(normalized.start_datetime);
const end = new Date(normalized.end_datetime);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
if (normalized.start_datetime || normalized.end_datetime) {
const start = normalized.start_datetime ? new Date(normalized.start_datetime) : null;
const end = normalized.end_datetime ? new Date(normalized.end_datetime) : null;
if ((start && Number.isNaN(start.getTime())) || (end && Number.isNaN(end.getTime()))
|| (start && end && end <= start)) {
return false;
}
if (now < start || now > end) {
if ((start && now < start) || (end && now > end)) {
return false;
}
}
+17 -5
View File
@@ -44,7 +44,16 @@ async function cacheResponse(cacheName, request, response, cacheKeyRequest) {
}
}
async function networkFirst(request, cacheName, cacheKeyRequest) {
async function matchCache(cacheName, request) {
try {
const cache = await caches.open(cacheName);
return await cache.match(request);
} catch (_error) {
return null;
}
}
async function networkFirst(request, cacheName, cacheKeyRequest, fallbackOnHttpError) {
try {
const response = await fetch(request);
if (response && response.ok) {
@@ -52,18 +61,21 @@ async function networkFirst(request, cacheName, cacheKeyRequest) {
return response;
}
if (response && response.status === 304) {
const cached = await caches.match(cacheKeyRequest || request);
const cached = await matchCache(cacheName, cacheKeyRequest || request);
if (cached) {
return cached;
}
}
const cached = await caches.match(cacheKeyRequest || request);
if (response && fallbackOnHttpError === false) {
return response;
}
const cached = await matchCache(cacheName, cacheKeyRequest || request);
if (cached) {
return cached;
}
return response;
} catch (_error) {
const cached = await caches.match(cacheKeyRequest || request);
const cached = await matchCache(cacheName, cacheKeyRequest || request);
if (cached) {
return cached;
}
@@ -184,6 +196,6 @@ self.addEventListener('fetch', function (event) {
}
if (url.pathname.startsWith('/api/screens/') && url.pathname.endsWith('/playlist')) {
event.respondWith(networkFirst(request, PLAYLIST_CACHE, normalizeRequest(request)));
event.respondWith(networkFirst(request, PLAYLIST_CACHE, normalizeRequest(request), false));
}
});
+40 -1
View File
@@ -268,6 +268,41 @@ function registerPlayerRoutes(app, options) {
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
}
async function syncRemoteImageCache(req, payload) {
if (!bridgeBaseUrl || !payload || !mediaDir) return;
const filenames = new Set();
for (const source of (payload.apiSources || []).concat(payload.rssFeeds || [])) {
for (const imagePath of Object.values(source && source.imageCache || {})) {
const match = String(imagePath || '').match(/^\/media\/player-cache\/remote-images\/([a-f0-9]{64}\.(?:png|jpe?g|gif|webp|svg))$/i);
if (match) filenames.add(match[1]);
}
}
await Promise.all(Array.from(filenames).map(async function (filename) {
const targetPath = path.join(mediaDir, 'player-cache', 'remote-images', filename);
try {
await fs.promises.access(targetPath, fs.constants.F_OK);
return;
} catch (_error) {
}
try {
const response = await fetchBridge(req, '/api/media/remote-images/' + encodeURIComponent(filename), { method: 'GET' });
if (!response || !response.ok) return;
const bytes = Buffer.from(await response.arrayBuffer());
if (!bytes.length || bytes.length > 10 * 1024 * 1024) return;
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const temporaryPath = targetPath + '.partial-' + crypto.randomUUID();
try {
await fs.promises.writeFile(temporaryPath, bytes);
await fs.promises.rename(temporaryPath, targetPath);
} finally {
await fs.promises.unlink(temporaryPath).catch(function () {});
}
} catch (error) {
if (!isBridgeFetchError(error)) console.warn('Unable to sync cached remote image:', filename, error);
}
}));
}
app.use('/assets', express.static(assetDir));
app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons')));
app.use('/assets/vendor/chart.js', express.static(path.join(__dirname, '..', 'web', 'public', 'vendor', 'chart.js')));
@@ -489,6 +524,7 @@ function registerPlayerRoutes(app, options) {
if (response.status === 304) {
const cachedSnapshot = await readPlaylistSnapshot(req.params.slug);
if (cachedSnapshot) {
await syncRemoteImageCache(req, cachedSnapshot);
return res.end();
}
@@ -501,12 +537,15 @@ function registerPlayerRoutes(app, options) {
return res.status(502).json({ error: 'Thin client playlist cache unavailable.' });
}
await writePlaylistSnapshot(req.params.slug, refreshedData);
await syncRemoteImageCache(req, refreshedData);
return res.json(refreshedData);
}
res.type(response.headers.get('content-type') || 'application/json');
const responseText = await response.text();
try {
await writePlaylistSnapshot(req.params.slug, JSON.parse(responseText));
const playlistData = JSON.parse(responseText);
await writePlaylistSnapshot(req.params.slug, playlistData);
await syncRemoteImageCache(req, playlistData);
} catch (_error) {
}
return res.send(responseText);
+92 -54
View File
@@ -5,6 +5,29 @@ const { createDashboardStateService } = require('#src/web/lib/dashboard-state');
const { createUploadSyncService } = require('#src/web/lib/media/index');
const { createRequestAuthHeaders } = require('#src/request-auth');
function mergePlayerSnapshotConnections(sourceSnapshots) {
const connections = [];
const seenConnections = new Set();
(Array.isArray(sourceSnapshots) ? sourceSnapshots : []).forEach(function (sourceConnections) {
(Array.isArray(sourceConnections) ? sourceConnections : []).forEach(function (connection) {
const identity = [
String(connection && connection.id || '').trim(),
String(connection && connection.clientId || '').trim(),
String(connection && connection.deviceId || '').trim(),
String(connection && connection.playerPublicBaseUrl || '').trim()
].join('|');
if (identity !== '|||' && seenConnections.has(identity)) {
return;
}
if (identity !== '|||') {
seenConnections.add(identity);
}
connections.push(connection);
});
});
return connections;
}
function createWebBootstrap(options) {
const pool = options && options.pool;
const common = options && options.common;
@@ -24,23 +47,22 @@ function createWebBootstrap(options) {
const dashboardClients = new Set();
const playerSnapshotCache = options && options.playerSnapshotCache ? options.playerSnapshotCache : new Map();
const playerSnapshotSockets = options && options.playerSnapshotSockets ? options.playerSnapshotSockets : new Map();
const playerSnapshotSourcesBySlug = new Map();
let dashboardRefreshInFlight = null;
let broadcastDashboardState = null;
function getPlayerSnapshotSocketUrl(slug) {
const resolvedPlayerInternalBaseUrl = configuredBridgeInternalUrl || configuredPlayerInternalUrl;
if (!resolvedPlayerInternalBaseUrl) {
function getPlayerSnapshotSocketUrl(baseUrl, slug) {
if (!baseUrl) {
throw new Error('Unable to resolve the player internal base URL.');
}
const url = new URL(resolvedPlayerInternalBaseUrl.replace(/^http/, 'ws'));
const url = new URL(baseUrl.replace(/^http/, 'ws'));
url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
url.search = '';
return url.toString();
}
function clearPlayerSnapshotSocket(slug) {
const key = String(slug || '').trim();
playerSnapshotSockets.delete(key);
function clearPlayerSnapshotSocket(subscriptionKey) {
playerSnapshotSockets.delete(subscriptionKey);
}
function ensurePlayerSnapshotSubscription(slug) {
@@ -49,61 +71,75 @@ function createWebBootstrap(options) {
}
const key = String(slug || '').trim();
if (!key || playerSnapshotSockets.has(key)) {
if (!key) {
return;
}
playerSnapshotSockets.set(key, null);
const authHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
});
const sourceUrls = Array.from(new Set([configuredPlayerInternalUrl, configuredBridgeInternalUrl].filter(Boolean)));
sourceUrls.forEach(function (baseUrl) {
const subscriptionKey = key + '\0' + baseUrl;
if (playerSnapshotSockets.has(subscriptionKey)) {
return;
}
Promise.resolve(getPlayerSnapshotSocketUrl(key)).then(function (socketUrl) {
const socket = new WebSocket(socketUrl, {
headers: authHeaders
playerSnapshotSockets.set(subscriptionKey, null);
const authHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
});
playerSnapshotSockets.set(key, socket);
socket.onmessage = function (event) {
try {
const payload = JSON.parse(String(event.data || '{}'));
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
return;
}
playerSnapshotCache.set(key, {
slug: key,
count: Array.isArray(payload.connections) ? payload.connections.length : 0,
connections: Array.isArray(payload.connections) ? payload.connections : []
});
uploadSyncService.scheduleAssignmentSync(uploadDir);
if (broadcastDashboardState) {
broadcastDashboardState().catch(function (error) {
console.error(error);
Promise.resolve().then(function () {
const socket = new WebSocket(getPlayerSnapshotSocketUrl(baseUrl, key), {
headers: authHeaders
});
playerSnapshotSockets.set(subscriptionKey, socket);
socket.onmessage = function (event) {
try {
const payload = JSON.parse(String(event.data || '{}'));
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
return;
}
if (!playerSnapshotSourcesBySlug.has(key)) {
playerSnapshotSourcesBySlug.set(key, new Map());
}
const sourceSnapshots = playerSnapshotSourcesBySlug.get(key);
sourceSnapshots.set(baseUrl, Array.isArray(payload.connections) ? payload.connections : []);
const connections = mergePlayerSnapshotConnections(Array.from(sourceSnapshots.values()));
playerSnapshotCache.set(key, {
slug: key,
count: connections.length,
connections: connections
});
uploadSyncService.scheduleAssignmentSync(uploadDir);
if (broadcastDashboardState) {
broadcastDashboardState().catch(function (error) {
console.error(error);
});
}
} catch (_error) {
// Ignore malformed player snapshot payloads.
}
} catch (_error) {
// Ignore malformed player snapshot payloads.
}
};
};
socket.onclose = function () {
clearPlayerSnapshotSocket(key);
setTimeout(function () {
ensurePlayerSnapshotSubscription(key);
}, 2000);
};
socket.onclose = function () {
clearPlayerSnapshotSocket(subscriptionKey);
setTimeout(function () {
ensurePlayerSnapshotSubscription(key);
}, 2000);
};
socket.onerror = function () {
try {
socket.close();
} catch (_error) {
// ignore close errors
}
};
}).catch(function (error) {
clearPlayerSnapshotSocket(key);
console.error(error);
socket.onerror = function () {
try {
socket.close();
} catch (_error) {
// ignore close errors
}
};
}).catch(function (error) {
clearPlayerSnapshotSocket(subscriptionKey);
console.error(error);
});
});
}
@@ -111,7 +147,9 @@ function createWebBootstrap(options) {
pool: pool,
common: common,
thinClientBaseUrl: configuredBridgeInternalUrl,
playerServiceName: configuredBridgeInternalUrl ? 'Bridge' : 'Player',
playerServiceName: configuredBridgeInternalUrl && configuredPlayerInternalUrl
? 'Player services'
: configuredBridgeInternalUrl ? 'Bridge' : 'Player',
playerSnapshotCache: playerSnapshotCache,
playerSnapshotSockets: playerSnapshotSockets,
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
@@ -270,4 +308,4 @@ function createWebBootstrap(options) {
};
}
module.exports = { createWebBootstrap };
module.exports = { createWebBootstrap, mergePlayerSnapshotConnections };
+8
View File
@@ -59,6 +59,14 @@ function registerStartupTasks(options) {
if (typeof exported === 'function') {
await exported(options);
}
const mediaSyncTaskFile = path.join(backgroundTaskDirectory, 'media-sync.js');
const mediaSyncTaskModule = require(mediaSyncTaskFile);
const registerMediaSyncTask = getTaskExport(mediaSyncTaskModule);
if (typeof registerMediaSyncTask === 'function') {
await registerMediaSyncTask(options);
}
}
return {
@@ -29,10 +29,11 @@ function registerFontSyncTask(options) {
continue;
}
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
const removed = await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
if (payload.requireSuccess && !removed) throw new Error('Unable to remove ' + uploadPath + ' from player ' + playerIdentifier + '.');
} else {
const copied = await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
if (payload.requireSuccess && copied === false) throw new Error('Unable to synchronize ' + uploadPath + ' to player ' + playerIdentifier + '.');
if (payload.requireSuccess && !copied) throw new Error('Unable to synchronize ' + uploadPath + ' to player ' + playerIdentifier + '.');
}
}
});
@@ -71,7 +71,8 @@ function registerInitialFontSyncTask(options) {
operations: operations,
playerIdentifier: metadata.playerIdentifier,
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
playerInternalBaseUrl: metadata.playerInternalBaseUrl
playerInternalBaseUrl: metadata.playerInternalBaseUrl,
requireSuccess: true
},
persist: true
});
+3 -1
View File
@@ -6,7 +6,9 @@ function createWebConfig() {
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
const assetDir = path.join(__dirname, '..', 'public');
const playerInternalUrl = (process.env.PLAYER_INTERNAL_URL || 'http://player:8081').replace(/\/$/, '');
const bridgeInternalUrl = (process.env.BRIDGE_INTERNAL_URL || 'http://player-bridge:8090').replace(/\/$/, '');
const bridgeInternalUrl = String(process.env.BRIDGE_INTERNAL_URL === undefined
? 'http://player-bridge:8090'
: process.env.BRIDGE_INTERNAL_URL).replace(/\/$/, '');
const webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, '');
const sessionCookieName = 'digital_signage_session';
const sessionMaxAgeMs = 14 * 24 * 60 * 60 * 1000;
+4 -6
View File
@@ -75,12 +75,6 @@ function normalizeScheduleRule(rule) {
input.days || input.day_list || input.schedule_days || input.schedule_days_json || input.scheduleDays
);
if (startDatetime || endDatetime) {
if (!startDatetime || !endDatetime) {
return null;
}
}
if (startTime || endTime) {
if (!startTime || !endTime) {
return null;
@@ -182,6 +176,10 @@ function formatScheduleRuleSummary(rule) {
const leftParts = [];
if (normalized.start_datetime && normalized.end_datetime) {
leftParts.push('Dates ' + formatDateTime(normalized.start_datetime) + ' to ' + formatDateTime(normalized.end_datetime));
} else if (normalized.start_datetime) {
leftParts.push('From ' + formatDateTime(normalized.start_datetime));
} else if (normalized.end_datetime) {
leftParts.push('Until ' + formatDateTime(normalized.end_datetime));
} else {
leftParts.push('Any date');
}
@@ -71,6 +71,13 @@
} catch (_error) {
days = String(input.days).split(',');
}
} else if (input.schedule_days_json !== undefined && input.schedule_days_json !== null) {
try {
var parsedScheduleDays = Array.isArray(input.schedule_days_json) ? input.schedule_days_json : JSON.parse(String(input.schedule_days_json));
days = Array.isArray(parsedScheduleDays) ? parsedScheduleDays : [];
} catch (_error) {
days = [];
}
}
days = Array.from(new Set(days.map(function (day) {
@@ -81,12 +88,6 @@
return left - right;
});
if (startDatetime || endDatetime) {
if (!startDatetime || !endDatetime) {
return null;
}
}
if (startTime || endTime) {
if (!startTime || !endTime) {
return null;
@@ -151,6 +152,10 @@
if (normalized.start_datetime && normalized.end_datetime) {
left.push('Dates ' + normalized.start_datetime.replace('T', ' ').slice(0, 16) + ' to ' + normalized.end_datetime.replace('T', ' ').slice(0, 16));
} else if (normalized.start_datetime) {
left.push('From ' + normalized.start_datetime.replace('T', ' ').slice(0, 16));
} else if (normalized.end_datetime) {
left.push('Until ' + normalized.end_datetime.replace('T', ' ').slice(0, 16));
} else {
left.push('Any date');
}
@@ -525,11 +530,6 @@
var endTime = String(fields.endTime && fields.endTime.value || '').trim();
clearRuleValidity(card);
if ((startDatetime || endDatetime) && (!startDatetime || !endDatetime)) {
setRuleError(!startDatetime ? fields.startDatetime : fields.endDatetime, 'Start and end datetimes must both be set for this rule.');
return false;
}
if ((startTime || endTime) && (!startTime || !endTime)) {
setRuleError(!startTime ? fields.startTime : fields.endTime, 'Start and end times must both be set for this rule.');
return false;
@@ -597,16 +597,6 @@
var startTime = String(fields.startTime && fields.startTime.value || '').trim();
var endTime = String(fields.endTime && fields.endTime.value || '').trim();
if (startDatetime || endDatetime) {
if (!startDatetime || !endDatetime) {
return {
type: 'datetime-pair',
target: !startDatetime ? fields.startDatetime : fields.endDatetime,
message: 'Start and end datetimes must both be set for this rule.'
};
}
}
if (startTime || endTime) {
if (!startTime || !endTime) {
return {
@@ -1437,11 +1427,6 @@
clearRuleValidity(card);
if ((startDatetime || endDatetime) && (!startDatetime || !endDatetime)) {
setRuleError(!startDatetime ? fields.startDatetime : fields.endDatetime, 'Start and end datetimes must both be set for this rule.');
isValid = false;
}
if ((startTime || endTime) && (!startTime || !endTime)) {
setRuleError(!startTime ? fields.startTime : fields.endTime, 'Start and end times must both be set for this rule.');
isValid = false;
+39 -17
View File
@@ -3,7 +3,7 @@
// URL and heartbeat checks in each route, which makes the screen-target selection consistent.
const { createPageAuthToken } = require('#src/request-auth');
const { normalizeBaseUrl } = require('#src/data/player-registry');
const { isLocalLikeBaseUrl, normalizeBaseUrl } = require('#src/data/player-registry');
module.exports = function registerScreenCommandRoutes(app, deps) {
const pool = deps.pool;
@@ -121,6 +121,20 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || normalizedPublicBaseUrl;
}
async function resolveRegisteredLocalPlayerBaseUrl(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;
const internalBaseUrl = normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url);
return internalBaseUrl && isLocalLikeBaseUrl(internalBaseUrl) ? internalBaseUrl : '';
}
// Admin commands may target either a direct player connection or a bridged device. Keep the fan-out
// logic explicit so a screen can be controlled through the correct transport without mixing both
// path choices into the command payload itself.
@@ -653,22 +667,30 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
commandPayload.blackout = blackoutValue;
}
const liveConnections = await fetchScreenConnections(slug);
const liveConnection = connectionId
? liveConnections.find(function (connection) {
const candidateConnectionId = String(connection && connection.id || '').trim();
const candidateClientId = String(connection && connection.clientId || '').trim();
return candidateConnectionId === connectionId || candidateClientId === connectionId;
})
: null;
const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl);
const targetPlayerBaseUrl = livePlayerBaseUrl || playerBaseUrl;
const liveDeviceId = String(liveConnection && liveConnection.playerDeviceId || '').trim();
const result = liveDeviceId && typeof forwardPlayerCommandToDevice === 'function'
? await forwardPlayerCommandToDevice(liveDeviceId, Object.assign({ screenSlug: slug }, commandPayload, connectionId ? { connectionId: connectionId } : {}))
: targetPlayerBaseUrl
? await forwardPlayerCommandForPlayerBaseUrl(slug, targetPlayerBaseUrl, commandPayload, connectionId, null)
: await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null);
const registeredLocalPlayerBaseUrl = connectionId
? await resolveRegisteredLocalPlayerBaseUrl(playerBaseUrl)
: '';
let result;
if (registeredLocalPlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
result = await forwardPlayerCommandToBaseUrl(registeredLocalPlayerBaseUrl, slug, commandPayload, connectionId);
} else {
const liveConnections = await fetchScreenConnections(slug);
const liveConnection = connectionId
? liveConnections.find(function (connection) {
const candidateConnectionId = String(connection && connection.id || '').trim();
const candidateClientId = String(connection && connection.clientId || '').trim();
return candidateConnectionId === connectionId || candidateClientId === connectionId;
})
: null;
const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl);
const targetPlayerBaseUrl = livePlayerBaseUrl || playerBaseUrl;
const liveDeviceId = String(liveConnection && liveConnection.playerDeviceId || '').trim();
result = liveDeviceId && typeof forwardPlayerCommandToDevice === 'function'
? await forwardPlayerCommandToDevice(liveDeviceId, Object.assign({ screenSlug: slug }, commandPayload, connectionId ? { connectionId: connectionId } : {}))
: targetPlayerBaseUrl
? await forwardPlayerCommandForPlayerBaseUrl(slug, targetPlayerBaseUrl, commandPayload, connectionId, null)
: await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null);
}
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
+82 -26
View File
@@ -21,28 +21,43 @@ module.exports = function registerPlaylistRoutes(app, deps) {
async function fetchPlaylistAuditItems(connection, playlistId) {
const [items] = await connection.query(
`SELECT id, slide_id, duration_seconds, use_video_duration, disable_audio,
group_key, group_name, group_pick_count, group_shuffle, group_mode,
group_repeat_count, group_schedule_rules_json
FROM c_playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC`,
`SELECT ps.id, ps.slide_id, ps.group_id, ps.duration_seconds, ps.use_video_duration, ps.disable_audio,
g.group_key, g.name AS group_name, g.pick_count AS group_pick_count, g.shuffle AS group_shuffle,
g.mode AS group_mode, g.repeat_count AS group_repeat_count
FROM c_playlist_slides ps
LEFT JOIN c_playlist_groups g ON g.id = ps.group_id
WHERE ps.playlist_id = ? ORDER BY ps.position ASC, ps.id ASC`,
[playlistId]
);
const [rules] = await connection.query(
`SELECT r.playlist_slide_id, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json
FROM c_playlist_slide_schedule_rules r
INNER JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
WHERE ps.playlist_id = ? ORDER BY r.position ASC, r.id ASC`,
[playlistId]
`SELECT r.playlist_slide_id, r.playlist_group_id, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json
FROM c_playlist_schedule_rules r
LEFT JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
LEFT JOIN c_playlist_groups pg ON pg.id = r.playlist_group_id
WHERE ps.playlist_id = ? OR pg.playlist_id = ? ORDER BY r.position ASC, r.id ASC`,
[playlistId, playlistId]
);
const rulesByItem = new Map();
rules.forEach(function ({ playlist_slide_id: itemId, ...rule }) {
if (!rulesByItem.has(Number(itemId))) rulesByItem.set(Number(itemId), []);
const rulesByGroup = new Map();
rules.forEach(function (rule) {
const { playlist_slide_id: playlistSlideId, playlist_group_id: playlistGroupId, ...ruleData } = rule;
if (typeof rule.schedule_days_json === 'string') rule.schedule_days_json = JSON.parse(rule.schedule_days_json);
rulesByItem.get(Number(itemId)).push(rule);
if (playlistSlideId !== null) {
const itemId = Number(playlistSlideId);
if (!rulesByItem.has(itemId)) rulesByItem.set(itemId, []);
rulesByItem.get(itemId).push(ruleData);
}
if (playlistGroupId !== null) {
const groupId = Number(playlistGroupId);
if (!rulesByGroup.has(groupId)) rulesByGroup.set(groupId, []);
rulesByGroup.get(groupId).push(ruleData);
}
});
return items.map(function ({ id, ...item }) {
if (typeof item.group_schedule_rules_json === 'string') item.group_schedule_rules_json = JSON.parse(item.group_schedule_rules_json);
return Object.assign(item, { scheduleRules: rulesByItem.get(Number(id)) || [] });
return items.map(function ({ id, group_id: groupId, ...item }) {
return Object.assign({}, item, {
scheduleRules: rulesByItem.get(Number(id)) || [],
groupScheduleRules: rulesByGroup.get(Number(groupId)) || []
});
});
}
@@ -124,16 +139,27 @@ module.exports = function registerPlaylistRoutes(app, deps) {
}
async function replacePlaylistSlideScheduleRules(connection, playlistSlideId, scheduleRules, actorId) {
await connection.query('DELETE FROM c_playlist_slide_schedule_rules WHERE playlist_slide_id = ?', [playlistSlideId]);
await connection.query('DELETE FROM c_playlist_schedule_rules WHERE playlist_slide_id = ?', [playlistSlideId]);
for (let index = 0; index < scheduleRules.length; index += 1) {
const rule = scheduleRules[index];
await connection.query(
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO c_playlist_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[playlistSlideId, rule.position || index, rule.startDatetime, rule.endDatetime, rule.startTime, rule.endTime, rule.daysJson, actorId, actorId]
);
}
}
async function replacePlaylistGroupScheduleRules(connection, playlistGroupId, scheduleRules, actorId) {
await connection.query('DELETE FROM c_playlist_schedule_rules WHERE playlist_group_id = ?', [playlistGroupId]);
for (let index = 0; index < scheduleRules.length; index += 1) {
const rule = scheduleRules[index];
await connection.query(
'INSERT INTO c_playlist_schedule_rules (playlist_group_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[playlistGroupId, rule.position || index, rule.startDatetime, rule.endDatetime, rule.startTime, rule.endTime, rule.daysJson, actorId, actorId]
);
}
}
function createValidationError(message) {
const error = new Error(message);
error.statusCode = 400;
@@ -256,9 +282,15 @@ module.exports = function registerPlaylistRoutes(app, deps) {
const endDatetime = String(rule.end_datetime || '').trim() || null;
const startTime = String(rule.start_time || '').trim() || null;
const endTime = String(rule.end_time || '').trim() || null;
const days = Array.isArray(rule.days) ? Array.from(new Set(rule.days.map(Number))) : [];
if (Boolean(startDatetime) !== Boolean(endDatetime) || Boolean(startTime) !== Boolean(endTime)
|| (startDatetime && (Number.isNaN(new Date(startDatetime).getTime()) || Number.isNaN(new Date(endDatetime).getTime()) || new Date(endDatetime) < new Date(startDatetime)))
let days = Array.isArray(rule.days) ? rule.days : [];
if (!days.length && rule.schedule_days_json != null) {
days = typeof rule.schedule_days_json === 'string' ? JSON.parse(rule.schedule_days_json) : rule.schedule_days_json;
}
days = Array.isArray(days) ? Array.from(new Set(days.map(Number))) : [];
if (Boolean(startTime) !== Boolean(endTime)
|| (startDatetime && Number.isNaN(new Date(startDatetime).getTime()))
|| (endDatetime && Number.isNaN(new Date(endDatetime).getTime()))
|| (startDatetime && endDatetime && new Date(endDatetime) <= new Date(startDatetime))
|| (startTime && (!/^\d{2}:\d{2}(?::\d{2})?$/.test(startTime) || !/^\d{2}:\d{2}(?::\d{2})?$/.test(endTime) || endTime < startTime))
|| days.some(function (day) { return !Number.isInteger(day) || day < 0 || day > 6; })) {
throw new Error('Group schedule rule is invalid.');
@@ -286,7 +318,9 @@ module.exports = function registerPlaylistRoutes(app, deps) {
}
const playlistScheduleRules = scheduleRulesByRowKey.get(rowKey) || [];
for (const rule of playlistScheduleRules) {
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
if ((rule.start_datetime && Number.isNaN(new Date(rule.start_datetime).getTime()))
|| (rule.end_datetime && Number.isNaN(new Date(rule.end_datetime).getTime()))
|| (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) <= new Date(rule.start_datetime))) {
throw createValidationError('End datetime must be after start datetime.');
}
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
@@ -325,7 +359,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
if (item.groupPickCount > groupSizes.get(item.groupKey)) {
throw createValidationError('Group pick count cannot exceed the number of slides in the group.');
}
const settings = { name: item.groupName, pickCount: item.groupPickCount, shuffle: item.groupShuffle, mode: item.groupMode, repeatCount: item.groupRepeatCount, rules: serializedRules };
const settings = { name: item.groupName, pickCount: item.groupPickCount, shuffle: item.groupShuffle, mode: item.groupMode, repeatCount: item.groupRepeatCount, rules: serializedRules, scheduleRules: item.groupScheduleRules };
const previousSettings = groupSettings.get(item.groupKey);
if (previousSettings && (previousSettings.name.toLowerCase() !== settings.name.toLowerCase()
|| previousSettings.pickCount !== settings.pickCount || previousSettings.shuffle !== settings.shuffle
@@ -373,18 +407,38 @@ module.exports = function registerPlaylistRoutes(app, deps) {
}
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
await connection.query('DELETE FROM c_playlist_groups WHERE playlist_id = ?', [playlist.id]);
const groupIdsByKey = new Map();
for (const [groupKey, settings] of groupSettings) {
const [groupResult] = await connection.query(
'INSERT INTO c_playlist_groups (playlist_id, group_key, name, pick_count, shuffle, mode, repeat_count, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[playlist.id, groupKey, settings.name, settings.pickCount, settings.shuffle, settings.mode, settings.repeatCount, actorId, actorId]
);
const groupId = Number(groupResult && groupResult.insertId);
if (!groupId) {
throw new Error('Could not create playlist group ' + groupKey + '.');
}
groupIdsByKey.set(groupKey, groupId);
for (let ruleIndex = 0; ruleIndex < settings.scheduleRules.length; ruleIndex += 1) {
const rule = settings.scheduleRules[ruleIndex];
await connection.query(
'INSERT INTO c_playlist_schedule_rules (playlist_group_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[groupId, ruleIndex, rule.start_datetime || null, rule.end_datetime || null, rule.start_time || null, rule.end_time || null, rule.days && rule.days.length ? JSON.stringify(rule.days) : null, actorId, actorId]
);
}
}
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
const [insertResult] = await connection.query(
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, group_key, group_name, group_pick_count, group_schedule_rules_json, group_shuffle, group_mode, group_repeat_count, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, item.groupKey, item.groupName, item.groupPickCount, item.groupKey ? JSON.stringify(item.groupScheduleRules) : null, item.groupShuffle, item.groupMode, item.groupRepeatCount, actorId, actorId]
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, group_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, item.groupKey ? groupIdsByKey.get(item.groupKey) : null, actorId, actorId]
);
const insertedPlaylistSlideId = Number(insertResult.insertId);
const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : [];
for (let ruleIndex = 0; ruleIndex < itemRules.length; ruleIndex += 1) {
const rule = itemRules[ruleIndex];
await connection.query(
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO c_playlist_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[insertedPlaylistSlideId, rule.position || ruleIndex, rule.startDatetime || null, rule.endDatetime || null, rule.startTime || null, rule.endTime || null, rule.daysJson || null, actorId, actorId]
);
}
@@ -757,7 +811,9 @@ module.exports = function registerPlaylistRoutes(app, deps) {
return res.status(400).send('Schedule rules are invalid.');
}
for (const rule of scheduleRules) {
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
if ((rule.start_datetime && Number.isNaN(new Date(rule.start_datetime).getTime()))
|| (rule.end_datetime && Number.isNaN(new Date(rule.end_datetime).getTime()))
|| (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) <= new Date(rule.start_datetime))) {
return res.status(400).send('End datetime must be after start datetime.');
}
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
+2 -1
View File
@@ -82,7 +82,8 @@ module.exports = function registerInternalSyncRoutes(app, deps) {
operations: operations,
playerIdentifier: playerIdentifier,
playerPublicBaseUrl: playerPublicBaseUrl,
playerInternalBaseUrl: playerInternalBaseUrl
playerInternalBaseUrl: playerInternalBaseUrl,
requireSuccess: true
}
});
res.status(202).json({ ok: true, queued: true, task: task });
@@ -209,16 +209,9 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
groupShuffle: Boolean(Number(item.group_shuffle)),
groupMode: item.group_mode === 'repeat' ? 'repeat' : 'select',
groupRepeatCount: Math.min(100, Math.max(1, Number(item.group_repeat_count) || 1)),
groupScheduleRules: Array.isArray(item.group_schedule_rules_json)
? item.group_schedule_rules_json
: (function () {
try {
const parsed = JSON.parse(item.group_schedule_rules_json || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
}()),
groupScheduleRules: (Array.isArray(item.groupScheduleRules) ? item.groupScheduleRules : []).map((rule) => Object.assign({}, rule, {
days: normalizeDayValues(rule.days || rule.schedule_days_json)
})),
scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView),
scheduleSummary: scheduleSummary(item)
}));