diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fad777..15f2aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## 2.22.1 - 2026-10-27 + +### Fixed + +- Playlist saves now update existing slides, groups, and schedule rules in place, preserving their database IDs. +- Playlist group schedule rules remain visible in the editor after refreshing the page. + ## 2.22.0 - 2026-10-27 ### Added diff --git a/build/package.player.json b/build/package.player.json index 8e0d7ae..59bd357 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.22.0", + "version": "2.22.1", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 60387f3..0895abe 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.22.0", + "version": "2.22.1", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 6391ca9..d527cce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.22.0", + "version": "2.22.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.22.0", + "version": "2.22.1", "dependencies": { "@better-auth/passkey": "^1.7.13", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 54982b2..6ef995e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.22.0", + "version": "2.22.1", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/web/routes/admin/playlists.js b/src/web/routes/admin/playlists.js index 51f7d2c..0c1d55f 100644 --- a/src/web/routes/admin/playlists.js +++ b/src/web/routes/admin/playlists.js @@ -160,6 +160,42 @@ module.exports = function registerPlaylistRoutes(app, deps) { } } + async function saveScheduleRulesInPlace(connection, ownerColumn, ownerId, scheduleRules, existingRules, actorId) { + const rules = Array.isArray(scheduleRules) ? scheduleRules : []; + const existing = Array.isArray(existingRules) ? existingRules : []; + for (let index = 0; index < rules.length; index += 1) { + const rule = rules[index]; + const existingRule = existing[index]; + const position = Number.isInteger(Number(rule.position)) ? Number(rule.position) : index; + const startDatetime = rule.startDatetime || rule.start_datetime || null; + const endDatetime = rule.endDatetime || rule.end_datetime || null; + const startTime = rule.startTime || rule.start_time || null; + const endTime = rule.endTime || rule.end_time || null; + const daysJson = rule.daysJson !== undefined + ? rule.daysJson + : rule.days && rule.days.length + ? JSON.stringify(rule.days) + : rule.schedule_days_json || null; + if (existingRule) { + await connection.query( + `UPDATE c_playlist_schedule_rules SET position = ?, start_datetime = ?, end_datetime = ?, start_time = ?, end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND ${ownerColumn} = ?`, + [position, startDatetime, endDatetime, startTime, endTime, daysJson, actorId, existingRule.id, ownerId] + ); + } else { + await connection.query( + `INSERT INTO c_playlist_schedule_rules (${ownerColumn}, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ownerId, position, startDatetime, endDatetime, startTime, endTime, daysJson, actorId, actorId] + ); + } + } + for (const removedRule of existing.slice(rules.length)) { + await connection.query( + `DELETE FROM c_playlist_schedule_rules WHERE id = ? AND ${ownerColumn} = ?`, + [removedRule.id, ownerId] + ); + } + } + function createValidationError(message) { const error = new Error(message); error.statusCode = 400; @@ -402,45 +438,132 @@ module.exports = function registerPlaylistRoutes(app, deps) { ? Number(playlist.canvas_id) : requestedCanvasId || selectedCanvasId || null; + let existingPlaylistSlides = []; + let existingPlaylistGroups = []; + let existingScheduleRules = []; + if (shouldUpdatePlaylist) { + [existingPlaylistSlides] = await connection.query( + 'SELECT ps.id, ps.slide_id, ps.group_id FROM c_playlist_slides ps WHERE ps.playlist_id = ? ORDER BY ps.position ASC, ps.id ASC FOR UPDATE', + [playlist.id] + ); + [existingPlaylistGroups] = await connection.query( + 'SELECT id, group_key FROM c_playlist_groups WHERE playlist_id = ? FOR UPDATE', + [playlist.id] + ); + const ownerFilters = []; + const ownerParams = []; + if (existingPlaylistSlides.length) { + ownerFilters.push('r.playlist_slide_id IN (?)'); + ownerParams.push(existingPlaylistSlides.map(function (item) { return item.id; })); + } + if (existingPlaylistGroups.length) { + ownerFilters.push('r.playlist_group_id IN (?)'); + ownerParams.push(existingPlaylistGroups.map(function (group) { return group.id; })); + } + if (ownerFilters.length) { + [existingScheduleRules] = await connection.query( + `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 FROM c_playlist_schedule_rules r WHERE ${ownerFilters.join(' OR ')} ORDER BY r.playlist_slide_id ASC, r.playlist_group_id ASC, r.position ASC, r.id ASC FOR UPDATE`, + ownerParams + ); + } + } + + const existingSlidesById = new Map(existingPlaylistSlides.map(function (item) { return [Number(item.id), item]; })); + const existingSlidesBySlideId = new Map(); + existingPlaylistSlides.forEach(function (item) { + const slideId = Number(item.slide_id); + if (!existingSlidesBySlideId.has(slideId)) existingSlidesBySlideId.set(slideId, []); + existingSlidesBySlideId.get(slideId).push(item); + }); + const retainedSlideIds = new Set(); + if (shouldUpdatePlaylist) { + normalizedSlides.forEach(function (item) { + const rowId = /^\d+$/.test(item.rowKey) ? Number(item.rowKey) : null; + let existing = rowId ? existingSlidesById.get(rowId) : null; + if (rowId && (!existing || Number(existing.slide_id) !== item.slideId)) { + throw createValidationError('Playlist slide data is invalid.'); + } + if (!existing) { + existing = (existingSlidesBySlideId.get(item.slideId) || []).find(function (candidate) { + return !retainedSlideIds.has(Number(candidate.id)); + }); + } + if (existing) { + const existingId = Number(existing.id); + if (retainedSlideIds.has(existingId)) { + throw createValidationError('Playlist slide data is invalid.'); + } + item.existingPlaylistSlideId = existingId; + retainedSlideIds.add(existingId); + } + }); + } + if (shouldUpdatePlaylist) { await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, canvas_id = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, nextCanvasId, actorId, playlist.id]); } - 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(); + const existingGroupsByKey = new Map(existingPlaylistGroups.map(function (group) { return [String(group.group_key), group]; })); + const retainedGroupIds = new Set(); + const existingRulesByOwner = new Map(); + existingScheduleRules.forEach(function (rule) { + const owner = rule.playlist_group_id !== null && rule.playlist_group_id !== undefined + ? 'group:' + Number(rule.playlist_group_id) + : 'slide:' + Number(rule.playlist_slide_id); + if (!existingRulesByOwner.has(owner)) existingRulesByOwner.set(owner, []); + existingRulesByOwner.get(owner).push(rule); + }); 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); + const existingGroup = existingGroupsByKey.get(groupKey); + let groupId; + if (existingGroup) { + groupId = Number(existingGroup.id); + await connection.query( + 'UPDATE c_playlist_groups SET name = ?, pick_count = ?, shuffle = ?, mode = ?, repeat_count = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', + [settings.name, settings.pickCount, settings.shuffle, settings.mode, settings.repeatCount, actorId, groupId, playlist.id] + ); + retainedGroupIds.add(groupId); + } else { + 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] + ); + 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] - ); - } + await saveScheduleRulesInPlace(connection, 'playlist_group_id', groupId, settings.scheduleRules, existingRulesByOwner.get('group:' + groupId), 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_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]; + const groupId = item.groupKey ? groupIdsByKey.get(item.groupKey) : null; + let playlistSlideId = item.existingPlaylistSlideId; + if (playlistSlideId) { await connection.query( - '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] + 'UPDATE c_playlist_slides SET slide_id = ?, position = ?, duration_seconds = ?, use_video_duration = ?, disable_audio = ?, group_id = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', + [item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, groupId, actorId, playlistSlideId, playlist.id] ); + } else { + const [insertResult] = await connection.query( + '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, groupId, actorId, actorId] + ); + playlistSlideId = Number(insertResult.insertId); + } + await saveScheduleRulesInPlace(connection, 'playlist_slide_id', playlistSlideId, itemRules, existingRulesByOwner.get('slide:' + playlistSlideId), actorId); + } + for (const existingSlide of existingPlaylistSlides) { + if (!retainedSlideIds.has(Number(existingSlide.id))) { + await connection.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [existingSlide.id, playlist.id]); + } + } + for (const existingGroup of existingPlaylistGroups) { + if (!retainedGroupIds.has(Number(existingGroup.id))) { + await connection.query('DELETE FROM c_playlist_groups WHERE id = ? AND playlist_id = ?', [existingGroup.id, playlist.id]); } } @@ -537,6 +660,7 @@ module.exports = function registerPlaylistRoutes(app, deps) { } } + await connection.beginTransaction(); const previousItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : []; const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true }); const nextItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : []; diff --git a/src/web/routes/signage/playlists/form-view-model.js b/src/web/routes/signage/playlists/form-view-model.js index 7d2abe4..6373e72 100644 --- a/src/web/routes/signage/playlists/form-view-model.js +++ b/src/web/routes/signage/playlists/form-view-model.js @@ -190,31 +190,35 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option } return a.id - b.id; }) - .map((item, index, items) => Object.assign({}, item, { - row_key: String(item.row_key || item.id || ''), - displayOrder: index + 1, - isFirst: index === 0, - isLast: index === items.length - 1, - canvasSizeId: Number(item.canvas_size_id) || null, - showVideoDurationButton: hasVideoRegion(item.content_json), - showMuteButton: hasPlayableMediaRegion(item.content_json), - videoSourcePath: getVideoSourcePath(item), - videoDurationSeconds: getVideoDurationSeconds(item), - useVideoDuration: Boolean(item.use_video_duration), - disableAudio: getDisableAudioValue(item), - durationSeconds: getVideoDurationValue(item), - groupKey: item.group_key || '', - groupName: item.group_name || '', - groupPickCount: Number(item.group_pick_count) || 1, - 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.groupScheduleRules) ? item.groupScheduleRules : []).map((rule) => Object.assign({}, rule, { + .map((item, index, items) => { + const 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) - })); + })); + return Object.assign({}, item, { + row_key: String(item.row_key || item.id || ''), + displayOrder: index + 1, + isFirst: index === 0, + isLast: index === items.length - 1, + canvasSizeId: Number(item.canvas_size_id) || null, + showVideoDurationButton: hasVideoRegion(item.content_json), + showMuteButton: hasPlayableMediaRegion(item.content_json), + videoSourcePath: getVideoSourcePath(item), + videoDurationSeconds: getVideoDurationSeconds(item), + useVideoDuration: Boolean(item.use_video_duration), + disableAudio: getDisableAudioValue(item), + durationSeconds: getVideoDurationValue(item), + groupKey: item.group_key || '', + groupName: item.group_name || '', + groupPickCount: Number(item.group_pick_count) || 1, + 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: groupScheduleRules, + groupScheduleRulesJson: JSON.stringify(groupScheduleRules), + scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView), + scheduleSummary: scheduleSummary(item) + }); + }); const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id)); const storedCanvasId = Number(viewPlaylist.canvas_id) || null; const playlistSlideCanvasIds = Array.from(new Set(playlistSlides.map((item) => Number(item.canvasSizeId || item.canvas_size_id)).filter((value) => Number.isInteger(value) && value > 0))); diff --git a/src/web/views/signage/playlists/slide-row.hbs b/src/web/views/signage/playlists/slide-row.hbs index 5c85538..3f5163c 100644 --- a/src/web/views/signage/playlists/slide-row.hbs +++ b/src/web/views/signage/playlists/slide-row.hbs @@ -30,7 +30,7 @@ - +