diff --git a/CHANGELOG.md b/CHANGELOG.md index f73b01e..d9ecff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## 2.22.0 - 2026-10-27 + +### Added + +- Playlist groups now have normalized records, and slide/group schedules share one rules table. Date windows may have an open start or end boundary. +- Local startup and remote reconnect use the same initial player asset sync for assigned media, managed fonts, and weather icons; the shared uploader chooses direct or bridge delivery and retries unavailable transfers. + +### Fixed + +- Playlist offline fallback now uses only the current service-worker cache generation, and cached playlists no longer mask HTTP errors. + ## 2.21.6 - 2026-10-27 ### Added diff --git a/build/package.player.json b/build/package.player.json index 2c735c2..8e0d7ae 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.21.6", + "version": "2.22.0", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index bddc403..60387f3 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.21.6", + "version": "2.22.0", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 129bf46..6391ca9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.21.6", + "version": "2.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.21.6", + "version": "2.22.0", "dependencies": { "@better-auth/passkey": "^1.7.13", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 052e1fb..54982b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.21.6", + "version": "2.22.0", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/admin.js b/src/data/admin.js index bcf0869..e18e4ab 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -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)) || [] }); }); diff --git a/src/db/index.js b/src/db/index.js index a20ed33..8a77e29 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -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 `); diff --git a/src/db/migrations.js b/src/db/migrations.js index 96141dd..ffeec67 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -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(', ')); + } + } } ]; diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js index 3b4f41a..315d4b5 100644 --- a/src/player-bridge/index.js +++ b/src/player-bridge/index.js @@ -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); diff --git a/src/player.js b/src/player.js index 05b35e1..905701b 100644 --- a/src/player.js +++ b/src/player.js @@ -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); diff --git a/src/player/playlist.js b/src/player/playlist.js index 52ca84f..b559305 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -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); diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js index 1c1402d..2ce99fa 100644 --- a/src/player/public/js/player-page-rendering.js +++ b/src/player/public/js/player-page-rendering.js @@ -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; } } diff --git a/src/player/public/sw.js b/src/player/public/sw.js index 1d79a2e..0fbd8da 100644 --- a/src/player/public/sw.js +++ b/src/player/public/sw.js @@ -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)); } }); \ No newline at end of file diff --git a/src/player/routes.js b/src/player/routes.js index a16b6da..5269d69 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -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); diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js index 3dd2f13..a926dd7 100644 --- a/src/web/bootstrap.js +++ b/src/web/bootstrap.js @@ -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 }; \ No newline at end of file +module.exports = { createWebBootstrap, mergePlayerSnapshotConnections }; \ No newline at end of file diff --git a/src/web/lib/background-tasks/index.js b/src/web/lib/background-tasks/index.js index b12b654..c39e7b9 100644 --- a/src/web/lib/background-tasks/index.js +++ b/src/web/lib/background-tasks/index.js @@ -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 { diff --git a/src/web/lib/background-tasks/tasks-adhoc/font-sync.js b/src/web/lib/background-tasks/tasks-adhoc/font-sync.js index 2e86f73..48b825e 100644 --- a/src/web/lib/background-tasks/tasks-adhoc/font-sync.js +++ b/src/web/lib/background-tasks/tasks-adhoc/font-sync.js @@ -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 + '.'); } } }); diff --git a/src/web/lib/background-tasks/tasks-startup/font-sync.js b/src/web/lib/background-tasks/tasks-startup/font-sync.js index 78d3ed3..496ef27 100644 --- a/src/web/lib/background-tasks/tasks-startup/font-sync.js +++ b/src/web/lib/background-tasks/tasks-startup/font-sync.js @@ -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 }); diff --git a/src/web/lib/config.js b/src/web/lib/config.js index edff9a9..5c154cd 100644 --- a/src/web/lib/config.js +++ b/src/web/lib/config.js @@ -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; diff --git a/src/web/lib/playlist-schedule.js b/src/web/lib/playlist-schedule.js index 27925c0..ceac54f 100644 --- a/src/web/lib/playlist-schedule.js +++ b/src/web/lib/playlist-schedule.js @@ -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'); } diff --git a/src/web/public/js/playlists/playlist-schedule.js b/src/web/public/js/playlists/playlist-schedule.js index 9340d49..c719cbb 100644 --- a/src/web/public/js/playlists/playlist-schedule.js +++ b/src/web/public/js/playlists/playlist-schedule.js @@ -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; diff --git a/src/web/routes/admin/client-commands.js b/src/web/routes/admin/client-commands.js index dcbde46..f780e40 100644 --- a/src/web/routes/admin/client-commands.js +++ b/src/web/routes/admin/client-commands.js @@ -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(); diff --git a/src/web/routes/admin/playlists.js b/src/web/routes/admin/playlists.js index 16b423b..51f7d2c 100644 --- a/src/web/routes/admin/playlists.js +++ b/src/web/routes/admin/playlists.js @@ -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) { diff --git a/src/web/routes/internal/sync.js b/src/web/routes/internal/sync.js index 749f4f5..8a8635f 100644 --- a/src/web/routes/internal/sync.js +++ b/src/web/routes/internal/sync.js @@ -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 }); diff --git a/src/web/routes/signage/playlists/form-view-model.js b/src/web/routes/signage/playlists/form-view-model.js index c9cba5c..7d2abe4 100644 --- a/src/web/routes/signage/playlists/form-view-model.js +++ b/src/web/routes/signage/playlists/form-view-model.js @@ -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) })); diff --git a/test/client-commands-route.test.js b/test/client-commands-route.test.js index c63f9ca..ebb5ac5 100644 --- a/test/client-commands-route.test.js +++ b/test/client-commands-route.test.js @@ -248,3 +248,54 @@ test('client command route uses the bridge device route without a public player payload: { screenSlug: 'lobby', command: 'pause', connectionId: 'connection-1' } }]); }); + +test('client command route sends registered local players directly without querying bridge connections', async () => { + const { app, handlers } = createAppHarness(); + const calls = []; + registerScreenCommandRoutes(app, { + pool: { + async query(sql) { + if (String(sql).includes('SELECT id, name, slug FROM d_screens WHERE slug = ?')) { + return [[{ id: 3, name: 'Test', slug: 'test' }]]; + } + return [[]]; + } + }, + common: { + async fetchPlayerRegistrations() { + return [{ identifier: 'player-local', public_base_url: 'http://localhost:8081', internal_base_url: 'http://player:8081' }]; + } + }, + forwardPlayerCommand() { throw new Error('default player route should not be used'); }, + forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) { + calls.push({ baseUrl, slug, payload, connectionId }); + return { ok: true, sent: 1 }; + }, + forwardPlayerCommandToDevice() { throw new Error('bridge device route should not be used'); }, + getScreenConnections() { throw new Error('bridge connection lookup should not be used'); }, + isClientNameAvailable() { return true; }, + withClientNameReservation() {}, + broadcastDashboardState() {}, + requirePermission() { return function (_req, _res, next) { next(); }; } + }); + + const response = { + statusCode: 200, + body: null, + status(code) { this.statusCode = code; return this; }, + json(payload) { this.body = payload; return this; } + }; + await handlers['/clients/:slug/commands'][1]({ + params: { slug: 'test' }, + body: { command: 'pause', connectionId: 'client-local', playerBaseUrl: 'http://localhost:8081' }, + query: {} + }, response, () => {}); + + assert.equal(response.statusCode, 200); + assert.deepEqual(calls, [{ + baseUrl: 'http://player:8081', + slug: 'test', + payload: { command: 'pause', connectionId: 'client-local', playerBaseUrl: 'http://localhost:8081' }, + connectionId: 'client-local' + }]); +}); diff --git a/test/data-admin.test.js b/test/data-admin.test.js index ef9d484..af85527 100644 --- a/test/data-admin.test.js +++ b/test/data-admin.test.js @@ -1,7 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { fetchSlidesPage } = require('../src/data/admin'); +const { fetchAdminData, fetchSlidesPage } = require('../src/data/admin'); test('slide search does not include slide content_json', async () => { const queries = []; @@ -22,4 +22,30 @@ test('slide search does not include slide content_json', async () => { assert.doesNotMatch(queries[1].sql, /CAST\(s\.content_json AS CHAR\)/); assert.match(queries[0].sql, /CAST\(s\.title AS CHAR\)/); assert.match(queries[0].sql, /CAST\(st\.name AS CHAR\)/); +}); + +test('admin playlist data attaches normalized slide and group rules by owner ID', async () => { + const pool = { + async query(sql) { + if (sql.includes('FROM c_playlist_slides ps')) { + return [[ + { id: 12, playlist_id: 3, slide_id: 22, group_id: 5, group_key: 'events', group_name: 'Events', group_pick_count: 1, group_shuffle: 0, group_mode: 'select', group_repeat_count: 1 }, + { id: 13, playlist_id: 3, slide_id: 23, group_id: 5, group_key: 'events', group_name: 'Events', group_pick_count: 1, group_shuffle: 0, group_mode: 'select', group_repeat_count: 1 } + ]]; + } + if (sql.includes('FROM c_playlist_schedule_rules')) { + return [[ + { id: 70, playlist_slide_id: 12, playlist_group_id: null, start_datetime: '2026-10-01 09:00:00', end_datetime: null, schedule_days_json: null }, + { id: 71, playlist_slide_id: null, playlist_group_id: 5, start_datetime: null, end_datetime: '2026-10-31 18:00:00', schedule_days_json: '[3]' } + ]]; + } + return [[]]; + } + }; + + const data = await fetchAdminData(pool); + assert.equal(data.playlistSlides[0].scheduleRules[0].id, 70); + assert.equal(data.playlistSlides[0].groupScheduleRules[0].id, 71); + assert.equal(data.playlistSlides[1].scheduleRules.length, 0); + assert.equal(data.playlistSlides[1].groupScheduleRules[0].id, 71); }); \ No newline at end of file diff --git a/test/initial-font-sync-task.test.js b/test/initial-font-sync-task.test.js index 6801f06..c7b1da8 100644 --- a/test/initial-font-sync-task.test.js +++ b/test/initial-font-sync-task.test.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict'); require('../src/common'); const { registerInitialFontSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/font-sync'); +const { registerFontSyncTask } = require('../src/web/lib/background-tasks/tasks-adhoc/font-sync'); test('initial font sync queues a separate task for each live player', async () => { const queuedTasks = []; @@ -59,4 +60,38 @@ test('initial font sync queues a separate task for each live player', async () = assert.ok(queuedTasks.every(function (task) { return task.key === 'initial-font-sync' && task.title === 'Initial font sync' && task.taskType === 'font-sync'; })); -}); \ No newline at end of file +}); + +test('font sync task fails when a required player upload is unsuccessful', async () => { + let handler = null; + registerFontSyncTask({ + backgroundTaskQueue: { + setTaskHandler(taskType, taskHandler) { + assert.equal(taskType, 'font-sync'); + handler = taskHandler; + } + }, + uploadSyncService: { + async pushUploadFileToPlayer() { return false; }, + async removeUploadFileFromPlayer() { return false; } + } + }); + + await assert.rejects(handler({ + payload: { + uploadDir: 'media', + playerIdentifier: 'player-local', + requireSuccess: true, + operations: [{ type: 'put', uploadPath: '/media/assets/fonts/test.ttf' }] + } + }), /Unable to synchronize \/media\/assets\/fonts\/test\.ttf to player player-local\./); + + await assert.rejects(handler({ + payload: { + uploadDir: 'media', + playerIdentifier: 'player-local', + requireSuccess: true, + operations: [{ type: 'delete', uploadPath: '/media/assets/fonts/old.ttf' }] + } + }), /Unable to remove \/media\/assets\/fonts\/old\.ttf from player player-local\./); +}); diff --git a/test/initial-media-sync-task.test.js b/test/initial-media-sync-task.test.js index 820fbf0..c072c1c 100644 --- a/test/initial-media-sync-task.test.js +++ b/test/initial-media-sync-task.test.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict'); require('../src/common'); const { registerInitialMediaSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/media-sync'); +const { registerStartupTasks } = require('../src/web/lib/background-tasks'); test('initial media sync queues a separate task for each live player', async () => { const queuedTasks = []; @@ -59,4 +60,45 @@ test('initial media sync queues a separate task for each live player', async () assert.ok(queuedTasks.every(function (task) { return task.key === 'initial-media-sync' && task.title === 'Initial media sync' && task.taskType === 'media-sync'; })); +}); + +test('startup registration queues the shared initial asset sync for a live direct player', async () => { + const queuedTasks = []; + const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString(); + + const startupTasks = registerStartupTasks({ + pool: { + async query() { + return [[{ + identifier: 'player-local', + public_base_url: 'http://localhost:8081', + internal_base_url: 'http://player:8081', + last_seen_at: liveLastSeenAt + }]]; + } + }, + common: { + async fetchApiSourcesData() { return { apiSources: [] }; }, + async fetchRssFeedsData() { return { rssFeeds: [] }; }, + async fetchWeatherLocationsData() { return { weatherLocations: [] }; } + }, + backgroundTaskQueue: { + async enqueueTask(definition) { + queuedTasks.push(definition); + return definition; + } + }, + mediaDir: 'e:/Projects Git/pulse-signage/media', + uploadSyncService: { + async getPlayerTaskMetadata() { return {}; } + } + }); + + await startupTasks.initialize(); + + assert.equal(queuedTasks.length, 1); + assert.equal(queuedTasks[0].taskType, 'media-sync'); + assert.equal(queuedTasks[0].key, 'initial-media-sync'); + assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-local'); + assert.equal(queuedTasks[0].payload.mode, 'initial'); }); \ No newline at end of file diff --git a/test/internal-sync-route.test.js b/test/internal-sync-route.test.js index 98e98c6..35e46ea 100644 --- a/test/internal-sync-route.test.js +++ b/test/internal-sync-route.test.js @@ -148,6 +148,7 @@ test('player font sync requests are queued as background tasks', async () => { assert.equal(queuedTasks[0].taskType, 'font-sync'); assert.equal(queuedTasks[0].payload.mode, 'initial'); assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote'); + assert.equal(queuedTasks[0].payload.requireSuccess, true); assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote'); } finally { await new Promise(function (resolve) { diff --git a/test/player-playlist.test.js b/test/player-playlist.test.js index 964c19e..cc2e4f6 100644 --- a/test/player-playlist.test.js +++ b/test/player-playlist.test.js @@ -55,6 +55,8 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn if (sql.includes('FROM c_playlist_slides ps')) { return [[{ id: 101, + playlist_slide_id: 301, + group_id: 55, title: 'Intro', template_id: 33, content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"backupVideo":{"type":"video","duration_seconds":20.1111},"textRegion":{"type":"text","value":"Hello"},"brandTagline":{"type":"text","value":"Old slide value"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}', @@ -69,7 +71,6 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn group_shuffle: 1, group_mode: 'repeat', group_repeat_count: 2, - group_schedule_rules_json: '[{"start_time":"08:00","end_time":"12:00","days":[1,2,3]}]', template_name: 'Template 33', canvas_size_name: 'HD', canvas_size_width: 1920, @@ -78,6 +79,7 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn canvas_height: 1080 }, { id: 102, + playlist_slide_id: 302, title: 'Live Feed', template_id: 33, content_json: '{"rtmpRegion":{"type":"rtmp","value":"rtmp://example/live"}}', @@ -94,10 +96,12 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn canvas_height: 1080 }]]; } - if (sql.includes('FROM c_playlist_slide_schedule_rules')) { + if (sql.includes('FROM c_playlist_schedule_rules')) { + assert.deepEqual(params, [22, 22]); return [[ - { id: 500, playlist_slide_id: 101, position: 1, start_datetime: null, end_datetime: null, start_time: '08:00', end_time: '12:00', schedule_days_json: '[1,2,3]' }, - { id: 501, playlist_slide_id: 101, position: 2, start_datetime: null, end_datetime: null, start_time: '13:00', end_time: '17:00', schedule_days_json: '[4,5]' } + { id: 500, playlist_slide_id: 301, playlist_group_id: null, position: 1, start_datetime: null, end_datetime: null, start_time: '08:00', end_time: '12:00', schedule_days_json: '[1,2,3]' }, + { id: 501, playlist_slide_id: 301, playlist_group_id: null, position: 2, start_datetime: null, end_datetime: null, start_time: '13:00', end_time: '17:00', schedule_days_json: '[4,5]' }, + { id: 502, playlist_slide_id: null, playlist_group_id: 55, position: 0, start_datetime: '2026-01-01T00:00:00.000Z', end_datetime: null, start_time: null, end_time: null, schedule_days_json: null } ]]; } if (sql.includes('FROM c_templates st')) { @@ -154,7 +158,9 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn assert.equal(payload.slides[1].groupMode, 'select'); assert.equal(payload.slides[1].groupRepeatCount, 1); assert.equal(payload.slides[1].groupShuffle, false); - assert.deepEqual(payload.slides[0].groupScheduleRules, [{ start_time: '08:00', end_time: '12:00', days: [1, 2, 3] }]); + assert.equal(payload.slides[0].groupScheduleRules.length, 1); + assert.equal(payload.slides[0].groupScheduleRules[0].start_datetime, '2026-01-01T00:00:00.000Z'); + assert.equal(payload.slides[0].groupScheduleRules[0].playlist_group_id, 55); assert.equal(payload.slides[0].template.regions[0].label, 'Text Region'); assert.equal(payload.rssFeeds[0].items[0].normalized, true); assert.deepEqual(payload.apiSources[0].responseJson, { ok: true }); diff --git a/test/player-preload-helpers.test.js b/test/player-preload-helpers.test.js index 69507dd..c6eb26a 100644 --- a/test/player-preload-helpers.test.js +++ b/test/player-preload-helpers.test.js @@ -116,6 +116,20 @@ test('playlist groups rotate in order, fill every pass, and optionally shuffle', assert.equal(sandbox.isSlideActive(gatedSlide, now), true); assert.equal(sandbox.isSlideActive(gatedSlide, new Date(2026, 9, 14, 12, 30)), false); assert.equal(sandbox.isSlideActive(gatedSlide, new Date(2026, 9, 15, 10, 30)), false); + + const databaseRuleSlide = { + scheduleRules: [{ start_time: '10:00', end_time: '11:00', schedule_days_json: '[3]' }] + }; + assert.equal(sandbox.isSlideActive(databaseRuleSlide, now), true); + assert.equal(sandbox.isSlideActive(databaseRuleSlide, new Date(2026, 9, 15, 10, 30)), false); + + const startBoundSlide = { scheduleRules: [{ start_datetime: '2026-10-14T11:00' }] }; + assert.equal(sandbox.isSlideActive(startBoundSlide, now), false); + assert.equal(sandbox.isSlideActive(startBoundSlide, new Date(2026, 9, 14, 11, 0)), true); + + const endBoundSlide = { scheduleRules: [{ end_datetime: '2026-10-14T10:00' }] }; + assert.equal(sandbox.isSlideActive(endBoundSlide, now), false); + assert.equal(sandbox.isSlideActive(endBoundSlide, new Date(2026, 9, 14, 10, 0)), true); }); test('repeat groups play all eligible members in order and ignore Pick and shuffle', () => { diff --git a/test/player-routes.test.js b/test/player-routes.test.js index ae1c7e2..262ab28 100644 --- a/test/player-routes.test.js +++ b/test/player-routes.test.js @@ -1,5 +1,8 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); require('../src/common'); @@ -63,6 +66,10 @@ function createResponse() { headers[name] = value; return this; }, + type(value) { + headers['Content-Type'] = value; + return this; + }, status(code) { this.statusCode = code; return this; @@ -378,6 +385,60 @@ test('playlist api route returns 404, etag, and 304 responses', async () => { assert.equal(notModifiedRes.ended, true); }); +test('remote playlist sync downloads cached API and RSS images from the bridge', async () => { + const { app, handlers } = createAppAndHandlers(); + const mediaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-player-remote-images-')); + const filename = 'a'.repeat(64) + '.png'; + const imageBytes = Buffer.from('cached image bytes'); + const playlistData = { + screen: { id: 7, slug: 'news' }, + revision: 'revision-1', + apiSources: [{ imageCache: { 'https://api.example/image.png': '/media/player-cache/remote-images/' + filename } }], + rssFeeds: [] + }; + const originalFetch = global.fetch; + const fetchCalls = []; + global.fetch = async function (url, init) { + fetchCalls.push({ url: String(url), init: init }); + if (String(url).includes('/api/screens/news/playlist')) { + return { + status: 200, + ok: true, + headers: { get(name) { return name.toLowerCase() === 'content-type' ? 'application/json' : null; } }, + async text() { return JSON.stringify(playlistData); } + }; + } + if (String(url).endsWith('/api/media/remote-images/' + filename)) { + return { ok: true, async arrayBuffer() { return imageBytes; } }; + } + throw new Error('Unexpected fetch: ' + String(url)); + }; + + try { + registerPlayerRoutes(app, { + app, + pool: null, + common: { renderPlayerPage() { return ''; } }, + ...createPlayerRouteOptions({ + bridgeBaseUrl: 'http://bridge.test', + mediaDir + }) + }); + + const response = createResponse(); + await handlers['/api/screens/:slug/playlist']({ params: { slug: 'news' }, headers: {}, query: {} }, response); + + assert.equal(response.statusCode, 200); + assert.equal(fetchCalls.length, 2); + assert.match(fetchCalls[1].url, /\/api\/media\/remote-images\//); + assert.equal(fetchCalls[1].init.method, 'GET'); + assert.deepEqual(fs.readFileSync(path.join(mediaDir, 'player-cache', 'remote-images', filename)), imageBytes); + } finally { + global.fetch = originalFetch; + fs.rmSync(mediaDir, { recursive: true, force: true }); + } +}); + test('announcement route returns 503 for transient failures and 304 on matching etag', async () => { const { app, handlers } = createAppAndHandlers(); const announcement = { diff --git a/test/player-service-worker.test.js b/test/player-service-worker.test.js index dfa95a0..cb13cf5 100644 --- a/test/player-service-worker.test.js +++ b/test/player-service-worker.test.js @@ -62,10 +62,11 @@ test('player service worker retention supports opaque versions before active cac ]); }); -function createFetchWorker({ failure, cachedResponse = null, fetchError = false } = {}) { +function createFetchWorker({ failure, cachedResponse = null, cacheMatchResponse = null, fetchError = false, networkStatus = 200 } = {}) { const networkResponse = new Response('fresh network body', { - status: 200, headers: { ETag: '"fresh"', 'Content-Type': 'text/plain' } + status: networkStatus, headers: { ETag: '"fresh"', 'Content-Type': 'text/plain' } }); + const namedCacheResponse = cacheMatchResponse === null ? cachedResponse : cacheMatchResponse; const calls = { fetch: 0, writes: [] }; const listeners = new Map(); const sandbox = { @@ -87,6 +88,7 @@ function createFetchWorker({ failure, cachedResponse = null, fetchError = false async open() { if (failure === 'open') throw new Error('Cache storage unavailable'); return { + async match() { return namedCacheResponse; }, async put(key, response) { if (failure === 'put') throw new Error('QuotaExceededError'); calls.writes.push({ key, response }); @@ -158,4 +160,39 @@ test('player script assets refresh from network and retain offline cache fallbac respondWith(promise) { offlineResponsePromise = promise; } }); assert.equal(await offlineResponsePromise, cachedResponse); +}); + +test('playlist HTTP errors are not hidden by a stale cached playlist', async () => { + const cachedResponse = new Response('stale playlist body', { status: 200 }); + const worker = createFetchWorker({ cachedResponse, networkStatus: 403 }); + const request = new Request('http://localhost/api/screens/test/playlist?ts=2'); + const response = await worker.sandbox.networkFirst( + request, + 'playlists', + { url: 'http://localhost/api/screens/test/playlist' }, + false + ); + + assert.equal(response.status, 403); + assert.equal(await response.text(), 'fresh network body'); +}); + +test('playlist offline fallback does not read an older cache generation', async () => { + const staleResponse = new Response('stale QR playlist', { status: 200 }); + const currentResponse = new Response('current Video playlist', { status: 200 }); + const worker = createFetchWorker({ + cachedResponse: staleResponse, + cacheMatchResponse: currentResponse, + fetchError: true + }); + const request = new Request('http://localhost/api/screens/test/playlist?ts=3'); + const response = await worker.sandbox.networkFirst( + request, + 'playlists-current', + { url: 'http://localhost/api/screens/test/playlist' }, + false + ); + + assert.equal(response, currentResponse); + assert.equal(await response.text(), 'current Video playlist'); }); \ No newline at end of file diff --git a/test/playlist-form-view-model.test.js b/test/playlist-form-view-model.test.js index b1ddd24..a3ce974 100644 --- a/test/playlist-form-view-model.test.js +++ b/test/playlist-form-view-model.test.js @@ -62,6 +62,8 @@ test('playlist schedule summaries show up to two short rules on separate lines a [[{ days: [1] }, { days: [2] }, { days: [3] }, { days: [4] }], '4 rules'], [[{ start_datetime: '2026-10-25T09:00', end_datetime: '2026-10-26T17:00', days: [1] }], 'Rule 1: Dates 2026-10-25 09:00 to 2026-10-26 17:00: Mon'], + [[{ start_datetime: '2026-10-25T09:00' }], 'Rule 1: From 2026-10-25 09:00'], + [[{ end_datetime: '2026-10-26T17:00' }], 'Rule 1: Until 2026-10-26 17:00'], [[{ start_datetime: new Date(2026, 0, 1, 0, 0), end_datetime: new Date(2026, 9, 30, 20, 0) }], 'Rule 1: Dates 2026-01-01 00:00 to 2026-10-30 20:00'], [[{ start_datetime: '2026-10-25T09:00', end_datetime: '2026-10-26T17:00', @@ -91,6 +93,36 @@ test('playlist schedule summaries show up to two short rules on separate lines a assert.match(css, /\.playlist-schedule-summary \.chip\s*\{[^}]*display:\s*inline-block;[^}]*white-space:\s*pre-line;[^}]*overflow-wrap:\s*anywhere;/); }); +test('live schedule validator accepts one datetime bound and still rejects reversed ranges', () => { + const fs = require('node:fs'); + const path = require('node:path'); + const vm = require('node:vm'); + const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/playlists/playlist-schedule.js'), 'utf8'); + const start = source.lastIndexOf(' function validateScheduleRuleCard(card)'); + const end = source.indexOf(' function updateLiveRuleValidity(card)', start); + const fields = { + days: [], + startDatetime: { value: '2026-10-25T09:00' }, + endDatetime: { value: '' }, + startTime: { value: '' }, + endTime: { value: '' } + }; + const context = { + getRuleFields: () => fields, + getRuleDayLabels: () => [], + getDateRangeDayIndices: () => [0, 1, 2, 3, 4, 5, 6], + clearRuleValidity() {}, + setRuleError() {}, + setDayInputError() {}, + hasScheduleRuleOverlap: () => true + }; + vm.runInNewContext(source.slice(start, end) + '\nglobalThis.validateRule = validateScheduleRuleCard;', context); + + assert.equal(context.validateRule({}), true); + fields.endDatetime.value = '2026-10-24T09:00'; + assert.equal(context.validateRule({}), false); +}); + test('playlist rows show mute controls for RTMP slides', () => { const model = buildPlaylistFormViewModel( { id: 22, name: 'Playlist 22' }, diff --git a/test/playlists-routes.test.js b/test/playlists-routes.test.js index 4529d1d..3c42e1c 100644 --- a/test/playlists-routes.test.js +++ b/test/playlists-routes.test.js @@ -38,6 +38,8 @@ function createResponse() { test('playlist create and update redirect to the canonical edit path', async () => { const calls = []; let groupInsertParams = null; + let slideInsertParams = null; + const groupRuleInsertParams = []; let canvasSizeEnabled = 1; const connection = { async beginTransaction() {}, @@ -56,14 +58,28 @@ test('playlist create and update redirect to the canonical edit path', async () calls.push('delete-slides'); return [{ affectedRows: 1 }]; } + if (sql.startsWith('DELETE FROM c_playlist_groups')) { + calls.push('delete-groups'); + return [{ affectedRows: 1 }]; + } if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) { return [[{ id: 11, canvas_size_id: 7 }]]; } - if (sql.startsWith('INSERT INTO c_playlist_slides')) { + if (sql.startsWith('INSERT INTO c_playlist_groups')) { groupInsertParams = params; + calls.push('insert-group'); + return [{ insertId: 71 }]; + } + if (sql.startsWith('INSERT INTO c_playlist_slides')) { + slideInsertParams = params; calls.push('insert-slide'); return [{ insertId: 101 }]; } + if (sql.startsWith('INSERT INTO c_playlist_schedule_rules')) { + groupRuleInsertParams.push(params); + calls.push('insert-group-rule'); + return [{ insertId: 81 }]; + } return [[]]; } }; @@ -139,26 +155,31 @@ test('playlist create and update redirect to the canonical edit path', async () group_shuffle: ['1'], group_mode: ['repeat'], group_repeat_count: ['2'], - group_schedule_rules_json: [JSON.stringify([{ start_time: '08:00', end_time: '17:00', days: [1, 2, 3] }])] + group_schedule_rules_json: [JSON.stringify([ + { start_datetime: '2026-10-01T09:00', days: [1, 2, 3] }, + { end_datetime: '2026-10-31T18:00' } + ])] } - }, updateRes, () => {}); + }, updateRes, error => { throw error; }); assert.equal(createRes.redirectedTo, '/playlists/4/edit'); assert.equal(updateRes.redirectedTo, '/playlists/4/edit', updateRes.statusCode + ': ' + String(updateRes.body || '')); - assert.deepEqual(groupInsertParams.slice(6, 10), [ - 'news', - 'News', - 1, - JSON.stringify([{ start_time: '08:00', end_time: '17:00', days: [1, 2, 3] }]) + assert.deepEqual(groupInsertParams, [4, 'news', 'News', 1, 1, 'repeat', 2, 12, 12]); + assert.equal(slideInsertParams[6], 71); + assert.deepEqual(groupRuleInsertParams, [ + [71, 0, '2026-10-01T09:00', null, null, null, '[1,2,3]', 12, 12], + [71, 1, null, '2026-10-31T18:00', null, null, null, 12, 12] ]); - assert.equal(groupInsertParams[10], 1); - assert.equal(groupInsertParams[11], 'repeat'); - assert.equal(groupInsertParams[12], 2); assert.deepEqual(calls, [ 'delete-slides', + 'delete-groups', { kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' }, 'update-playlist', 'delete-slides', + 'delete-groups', + 'insert-group', + 'insert-group-rule', + 'insert-group-rule', 'insert-slide', { kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists/4' } ]); @@ -200,21 +221,22 @@ test('playlist create and update redirect to the canonical edit path', async () }); test('playlist audit compares persisted items and schedules without recreated row IDs', async () => { - let items = [{ id: 101, slide_id: 11, duration_seconds: 10 }]; + let items = [{ id: 101, slide_id: 11, group_id: null, duration_seconds: 10 }]; let rules = [{ playlist_slide_id: 101, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }]; const events = []; const connection = { async commit() {}, async rollback() {}, release() {}, async query(sql, params) { - if (sql.includes('FROM c_playlist_slides WHERE playlist_id') && sql.startsWith('SELECT id, slide_id')) return [structuredClone(items)]; - if (sql.includes('FROM c_playlist_slide_schedule_rules r')) return [structuredClone(rules)]; + if (sql.includes('FROM c_playlist_slides ps') && sql.includes('WHERE ps.playlist_id')) return [structuredClone(items)]; + if (sql.includes('FROM c_playlist_schedule_rules r')) return [structuredClone(rules)]; if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }]]; if (sql.startsWith('DELETE FROM c_playlist_slides')) { items = []; rules = []; } + if (sql.startsWith('DELETE FROM c_playlist_groups')) return [{ affectedRows: 0 }]; if (sql.startsWith('INSERT INTO c_playlist_slides')) { - items = [{ id: 202, slide_id: params[1], duration_seconds: params[3] }]; + items = [{ id: 202, slide_id: params[1], group_id: params[6], duration_seconds: params[3] }]; return [{ insertId: 202 }]; } - if (sql.startsWith('INSERT INTO c_playlist_slide_schedule_rules')) { + if (sql.startsWith('INSERT INTO c_playlist_schedule_rules')) { rules.push({ playlist_slide_id: params[0], start_time: params[4] + ':00', end_time: params[5] + ':00', schedule_days_json: params[6] }); } return [[]]; diff --git a/test/schema-update-log.test.js b/test/schema-update-log.test.js index 4c90b1b..fbf0699 100644 --- a/test/schema-update-log.test.js +++ b/test/schema-update-log.test.js @@ -93,7 +93,7 @@ test('pending migrations are empty when the schema already matches the app versi test('2.20.1 release follows the consolidated 2.20.0 migration', async () => { const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.19.1' }); - assert.deepEqual(pending.map(migration => migration.version), ['2.20.0', '2.20.1']); + assert.deepEqual(pending.filter(migration => migration.version.startsWith('2.20.')).map(migration => migration.version), ['2.20.0', '2.20.1']); }); test('2.20.1 removes retired security setting rows', async () => { @@ -627,4 +627,120 @@ test('2.19.0 indexed-reference migration backfills slide and template defaults', assert.ok(queries.every(([sql]) => !sql.includes('idx_content_reference_owner'))); assert.ok(insertedReferences.some(row => row[0] === 'slide' && row[1] === 2 && row[3] === 'media' && row[4] === '/media/uploads/hero.png')); assert.ok(insertedReferences.some(row => row[0] === 'template' && row[1] === 4 && row[2] === 'headline' && row[3] === 'font-family' && row[4] === 'acme display')); +}); + +function createPlaylistScheduleMigrationPool(groupRows, sourceRuleCount) { + const queries = []; + const legacyGroupColumns = new Set([ + 'group_key', 'group_name', 'group_pick_count', 'group_shuffle', + 'group_mode', 'group_repeat_count', 'group_schedule_rules_json' + ]); + let hasGroupId = false; + let hasLegacyScheduleTable = true; + let migratedSlideRuleCount = 0; + const groupInsertParams = []; + const groupRuleInsertParams = []; + const memberGroupUpdates = []; + + return { + queries, + groupInsertParams, + groupRuleInsertParams, + memberGroupUpdates, + async query(sql, params) { + const text = String(sql); + queries.push([text, params]); + if (text.includes('FROM information_schema.COLUMNS')) { + const columnName = params[1]; + const exists = columnName === 'group_id' ? hasGroupId : legacyGroupColumns.has(columnName); + return [[{ column_count: exists ? 1 : 0 }]]; + } + if (text.includes('FROM information_schema.TABLES')) { + return [[{ table_count: params[0] === 'c_playlist_slide_schedule_rules' && hasLegacyScheduleTable ? 1 : 0 }]]; + } + if (text.includes('FROM information_schema.KEY_COLUMN_USAGE')) { + return [[{ fk_count: 0 }]]; + } + if (text.startsWith('ALTER TABLE c_playlist_slides ADD COLUMN group_id')) { + hasGroupId = true; + } + if (text.startsWith('ALTER TABLE c_playlist_slides DROP COLUMN')) { + text.split('DROP COLUMN ').slice(1).forEach(part => legacyGroupColumns.delete(part.split(/[ ,]/)[0])); + } + if (text.includes('SELECT id, playlist_id, group_key, group_name')) { + return [structuredClone(groupRows)]; + } + if (text.startsWith('INSERT INTO c_playlist_groups')) { + groupInsertParams.push(params); + return [{ insertId: 77 }]; + } + if (text.startsWith('INSERT INTO c_playlist_schedule_rules') && text.includes('VALUES')) { + groupRuleInsertParams.push(params); + } + if (text.startsWith('UPDATE c_playlist_slides SET group_id')) { + memberGroupUpdates.push(params); + } + if (text.startsWith('SELECT COUNT(*) AS rule_count FROM c_playlist_slide_schedule_rules')) { + return [[{ rule_count: sourceRuleCount }]]; + } + if (text.startsWith('DELETE FROM c_playlist_schedule_rules WHERE playlist_slide_id')) { + migratedSlideRuleCount = 0; + } + if (text.includes('INSERT INTO c_playlist_schedule_rules') && text.includes('SELECT playlist_slide_id')) { + migratedSlideRuleCount = sourceRuleCount; + } + if (text.startsWith('SELECT COUNT(*) AS rule_count FROM c_playlist_schedule_rules WHERE playlist_slide_id')) { + return [[{ rule_count: migratedSlideRuleCount }]]; + } + if (text === 'DROP TABLE c_playlist_slide_schedule_rules') { + hasLegacyScheduleTable = false; + } + return [[]]; + } + }; +} + +function buildLegacyPlaylistGroupRows(groupRulesJson, secondPickCount) { + return [ + { id: 501, playlist_id: 8, group_key: 'events', group_name: 'Events', group_pick_count: 1, group_shuffle: 1, group_mode: 'select', group_repeat_count: 1, group_schedule_rules_json: groupRulesJson, created_at: '2026-10-01 10:00:00', created_by: 4, modified_at: '2026-10-02 10:00:00', modified_by: 5 }, + { id: 502, playlist_id: 8, group_key: 'events', group_name: 'Events', group_pick_count: secondPickCount || 1, group_shuffle: 1, group_mode: 'select', group_repeat_count: 1, group_schedule_rules_json: groupRulesJson, created_at: '2026-10-01 10:00:00', created_by: 4, modified_at: '2026-10-02 10:00:00', modified_by: 5 } + ]; +} + +test('2.22.0 playlist schedule migration consolidates shared groups and copies both rule owners', async () => { + const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.21.6' }); + const migration = pending.find(item => item.version === '2.22.0'); + assert.ok(migration); + + const groupRules = JSON.stringify([{ start_datetime: '2026-10-01T10:00', days: [2] }]); + const pool = createPlaylistScheduleMigrationPool(buildLegacyPlaylistGroupRows(groupRules), 2); + await migration.run(pool); + + assert.equal(pool.groupInsertParams.length, 1); + assert.deepEqual(pool.groupInsertParams[0].slice(0, 7), [8, 'events', 'Events', 1, 1, 'select', 1]); + assert.equal(pool.memberGroupUpdates.length, 2); + assert.deepEqual(pool.memberGroupUpdates, [[77, 501], [77, 502]]); + assert.deepEqual(pool.groupRuleInsertParams, [[77, 0, '2026-10-01T10:00', null, null, null, '[2]', 5, 5]]); + assert.ok(pool.queries.some(([sql]) => sql.includes('INSERT INTO c_playlist_schedule_rules') && sql.includes('SELECT playlist_slide_id'))); + assert.ok(pool.queries.some(([sql]) => sql === 'DROP TABLE c_playlist_slide_schedule_rules')); + const legacyGroupColumnDrops = pool.queries.filter(([sql]) => sql.startsWith('ALTER TABLE c_playlist_slides DROP COLUMN')); + assert.equal(legacyGroupColumnDrops.length, 1); + assert.match(legacyGroupColumnDrops[0][0], /DROP COLUMN group_key, DROP COLUMN group_name, DROP COLUMN group_pick_count, DROP COLUMN group_shuffle, DROP COLUMN group_mode, DROP COLUMN group_repeat_count, DROP COLUMN group_schedule_rules_json/); + + const schema = require('node:fs').readFileSync(require.resolve('../src/db/index.js'), 'utf8'); + assert.match(schema, /CREATE TABLE IF NOT EXISTS c_playlist_groups/); + assert.match(schema, /CREATE TABLE IF NOT EXISTS c_playlist_schedule_rules/); + assert.doesNotMatch(schema, /group_schedule_rules_json/); +}); + +test('2.22.0 playlist schedule migration aborts before dropping inconsistent legacy group data', async () => { + const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.21.6' }); + const migration = pending.find(item => item.version === '2.22.0'); + assert.ok(migration); + + const pool = createPlaylistScheduleMigrationPool(buildLegacyPlaylistGroupRows('[]', 2), 0); + await assert.rejects(migration.run(pool), /inconsistent settings across members/); + assert.equal(pool.groupInsertParams.length, 0); + assert.equal(pool.queries.some(([sql]) => sql === 'DROP TABLE c_playlist_slide_schedule_rules'), false); + assert.equal(pool.queries.some(([sql]) => sql.startsWith('ALTER TABLE c_playlist_slides DROP COLUMN')), false); }); \ No newline at end of file diff --git a/test/timetable-migrations.test.js b/test/timetable-migrations.test.js index 50a6237..aba1eb4 100644 --- a/test/timetable-migrations.test.js +++ b/test/timetable-migrations.test.js @@ -227,7 +227,7 @@ test('2.19.1 migration rebuilds only managed font references for all content in ]); const pending = await getPendingMigrations(pool, { currentVersion: '2.19.0' }); - assert.deepEqual(pending.map(migration => migration.version), ['2.19.1', '2.20.0', '2.20.1']); + assert.deepEqual(pending.map(migration => migration.version), ['2.19.1', '2.20.0', '2.20.1', '2.22.0']); await pending[0].run(pool); const deletions = pool.queries.filter(([sql]) => sql.startsWith('DELETE')); @@ -243,7 +243,7 @@ test('2.19.1 migration rebuilds only managed font references for all content in assert.ok(pool.queries.some(([sql, params]) => sql.startsWith('SELECT id, template_id, region_key') && params[0] === 900)); assert.ok(!pool.queries.some(([sql]) => /^(UPDATE|ALTER|TRUNCATE)/.test(sql))); assert.deepEqual(pool.queries.slice(-1), [['COMMIT']]); - assert.deepEqual((await getPendingMigrations(pool, { currentVersion: '2.19.1' })).map(migration => migration.version), ['2.20.0', '2.20.1']); + assert.deepEqual((await getPendingMigrations(pool, { currentVersion: '2.19.1' })).map(migration => migration.version), ['2.20.0', '2.20.1', '2.22.0']); }); test('2.19.1 font repair rolls back without releasing the caller-owned connection on failure', async () => { diff --git a/test/web-bootstrap.test.js b/test/web-bootstrap.test.js index 2ae5e39..5482a11 100644 --- a/test/web-bootstrap.test.js +++ b/test/web-bootstrap.test.js @@ -3,7 +3,37 @@ const assert = require('node:assert/strict'); require('../src/common'); -const { createWebBootstrap } = require('../src/web/bootstrap'); +const { createWebBootstrap, mergePlayerSnapshotConnections } = require('../src/web/bootstrap'); +const { createWebConfig } = require('../src/web/lib/config'); + +test('dashboard combines player and bridge snapshots without duplicating clients', () => { + const localClient = { id: 'connection-1', clientId: 'client-1', deviceId: 'device-1', playerPublicBaseUrl: 'http://player.local' }; + const bridgeClient = { id: 'connection-2', clientId: 'client-2', deviceId: 'device-2', playerPublicBaseUrl: 'https://player.example' }; + const connections = mergePlayerSnapshotConnections([ + [localClient, bridgeClient], + [localClient] + ]); + + assert.deepEqual(connections, [localClient, bridgeClient]); +}); + +test('web config uses the player directly when no bridge URL is configured', () => { + const previousBridgeUrl = process.env.BRIDGE_INTERNAL_URL; + delete process.env.BRIDGE_INTERNAL_URL; + try { + assert.equal(createWebConfig().bridgeInternalUrl, 'http://player-bridge:8090'); + process.env.BRIDGE_INTERNAL_URL = ''; + const config = createWebConfig(); + assert.equal(config.bridgeInternalUrl, ''); + assert.equal(config.playerInternalUrl, 'http://player:8081'); + } finally { + if (previousBridgeUrl === undefined) { + delete process.env.BRIDGE_INTERNAL_URL; + } else { + process.env.BRIDGE_INTERNAL_URL = previousBridgeUrl; + } + } +}); test('dashboard websocket broadcasts keep the last successful snapshot when refreshes fail', async () => { let adminDataCalls = 0;