Release 2.22.1: preserve playlist row identities
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m18s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s

This commit is contained in:
lzstealth committed 2026-10-27 23:46:47 +00:00
1 parent c40bc6844a
commit 9b87ad4721
10 files changed
+337 -75

No files matched your search

+7
View File
@@ -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
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+147 -23
View File
@@ -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) : [];
@@ -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)));
@@ -30,7 +30,7 @@
<input type="hidden" name="group_shuffle[]" value="{{#if slide.groupShuffle}}1{{else}}0{{/if}}" form="playlist-edit-form" data-playlist-group-shuffle-value />
<input type="hidden" name="group_mode[]" value="{{#if slide.groupMode}}{{slide.groupMode}}{{else}}select{{/if}}" form="playlist-edit-form" data-playlist-group-mode-value />
<input type="hidden" name="group_repeat_count[]" value="{{#if slide.groupRepeatCount}}{{slide.groupRepeatCount}}{{else}}1{{/if}}" form="playlist-edit-form" data-playlist-group-repeat-count-value />
<input type="hidden" name="group_schedule_rules_json[]" value="{{#if slide.groupScheduleRules}}{{json slide.groupScheduleRules}}{{else}}[]{{/if}}" form="playlist-edit-form" data-playlist-group-schedule-rules />
<input type="hidden" name="group_schedule_rules_json[]" value="{{slide.groupScheduleRulesJson}}" form="playlist-edit-form" data-playlist-group-schedule-rules />
</td>
<td data-label="Rules">
<div class="playlist-schedule-summary"><span class="chip" data-schedule-summary-chip>{{slide.scheduleSummary}}</span></div>
+26
View File
@@ -41,6 +41,32 @@ test('new playlist rows receive preview URLs, titles and canvas dimensions', ()
assert.equal(attrs['aria-label'], 'Preview slide: Portrait');
});
test('playlist group rule JSON is escaped in the hidden form field and remains intact', () => {
const { renderView } = require('../src/web/view');
const model = buildPlaylistFormViewModel({ id: 3, name: 'Group rules' }, {
playlistSlides: [{
id: 12,
playlist_id: 3,
slide_id: 22,
position: 0,
group_id: 5,
group_key: 'events',
group_name: 'Events',
group_pick_count: 1,
groupScheduleRules: [{ id: 71, playlist_group_id: 5, position: 0, start_time: '09:00:00', end_time: '17:00:00', schedule_days_json: '[1,3]' }],
scheduleRules: []
}],
slides: [],
canvasSizes: []
}, '', null, { isEdit: true });
const html = renderView('playlists/form', model);
const hiddenRuleField = html.match(/<input[^>]+data-playlist-group-schedule-rules[^>]+>/);
assert.ok(hiddenRuleField);
assert.match(hiddenRuleField[0], /value="\[\{&quot;id&quot;:71,/);
assert.doesNotMatch(hiddenRuleField[0], /value="\[\{"id/);
});
test('playlist schedule summaries show up to two short rules on separate lines and count longer summaries', () => {
const fs = require('node:fs');
const path = require('node:path');
+123 -22
View File
@@ -54,14 +54,9 @@ test('playlist create and update redirect to the canonical edit path', async ()
calls.push('update-playlist');
return [{ affectedRows: 1 }];
}
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
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 ps.id, ps.slide_id, ps.group_id')) return [[]];
if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [[]];
if (sql.startsWith('SELECT r.id, r.playlist_slide_id')) return [[]];
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) {
return [[{ id: 11, canvas_size_id: 7 }]];
}
@@ -171,12 +166,8 @@ test('playlist create and update redirect to the canonical edit path', async ()
[71, 1, null, '2026-10-31T18:00', null, null, null, 12, 12]
]);
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',
@@ -220,24 +211,134 @@ test('playlist create and update redirect to the canonical edit path', async ()
}
});
test('playlist save updates retained slide, group, and schedule rows in place', async () => {
const writes = [];
let items = [
{ id: 101, slide_id: 11, group_id: 201, duration_seconds: 10, position: 0, use_video_duration: 0, disable_audio: 1 },
{ id: 102, slide_id: 12, group_id: null, duration_seconds: 15, position: 1, use_video_duration: 0, disable_audio: 1 }
];
let groups = [{ id: 201, group_key: 'news' }];
let rules = [
{ id: 301, playlist_slide_id: null, playlist_group_id: 201, position: 0, start_datetime: null, end_datetime: null, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' },
{ id: 302, playlist_slide_id: 101, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, start_time: '09:00:00', end_time: '18:00:00', schedule_days_json: null },
{ id: 303, playlist_slide_id: 102, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, start_time: '10:00:00', end_time: '19:00:00', schedule_days_json: null }
];
const connection = {
async beginTransaction() {}, async commit() {}, async rollback() {}, release() {},
async query(sql, params) {
if (sql.startsWith('SELECT ps.id, ps.slide_id, ps.group_id FROM c_playlist_slides')) return [structuredClone(items)];
if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [structuredClone(groups)];
if (sql.startsWith('SELECT r.id, r.playlist_slide_id')) return [structuredClone(rules)];
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }, { id: 13, canvas_size_id: 7 }]];
if (sql.startsWith('UPDATE c_playlists SET')) return [{ affectedRows: 1 }];
if (sql.startsWith('UPDATE c_playlist_groups SET')) {
writes.push({ kind: 'update-group', id: params[6], name: params[0] });
return [{ affectedRows: 1 }];
}
if (sql.startsWith('UPDATE c_playlist_slides SET')) {
writes.push({ kind: 'update-slide', id: params[7], duration: params[2] });
items = items.map(item => item.id === params[7] ? Object.assign({}, item, {
slide_id: params[0], position: params[1], duration_seconds: params[2],
use_video_duration: params[3], disable_audio: params[4], group_id: params[5]
}) : item);
return [{ affectedRows: 1 }];
}
if (sql.startsWith('UPDATE c_playlist_schedule_rules SET')) {
writes.push({ kind: 'update-rule', id: params[7] });
rules = rules.map(rule => rule.id === params[7] ? Object.assign({}, rule, {
position: params[0], start_datetime: params[1], end_datetime: params[2],
start_time: params[3], end_time: params[4], schedule_days_json: params[5]
}) : rule);
return [{ affectedRows: 1 }];
}
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
writes.push({ kind: 'insert-slide', slideId: params[1] });
items.push({ id: 103, slide_id: params[1], group_id: params[6], duration_seconds: params[3], position: params[2] });
return [{ insertId: 103 }];
}
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
writes.push({ kind: 'delete-slide', id: params[0] });
items = items.filter(item => item.id !== params[0]);
rules = rules.filter(rule => rule.playlist_slide_id !== params[0]);
}
return [[]];
}
};
const { app, handlers } = createAppAndHandlers();
registerPlaylistRoutes(app, {
pool: { async getConnection() { return connection; } },
common: {
async fetchDuplicateName() { return null; },
async fetchPlaylistById() { return { id: 4, name: 'Loop', canvas_id: 7 }; }
},
readArrayField(body, keys) {
for (const key of keys) {
if (Object.hasOwn(body, key)) return Array.isArray(body[key]) ? body[key] : [body[key]];
}
return [];
},
fetchScreensByPlaylistId: async () => [], getAuditUserId: () => 12,
redirectAfterSave(_req, res, url) { res.redirect(url); }, notifyPlayerScreens: async () => {}, broadcastDashboardState: async () => {},
requirePermission: () => () => {}
});
const response = createResponse();
await handlers['/playlists/:id'].at(-1)({
params: { id: '4' },
body: {
name: 'Loop',
slide_id: ['11', '13'], row_key: ['101', 'new-slide'],
duration_seconds: ['20', '12'], use_video_duration: ['0', '0'], disable_audio: ['1', '1'],
group_key: ['news', ''], group_name: ['News updated', ''], group_pick_count: ['1', ''], group_shuffle: ['0', ''],
group_mode: ['repeat', ''], group_repeat_count: ['3', ''],
group_schedule_rules_json: [JSON.stringify([{ start_time: '08:30', end_time: '17:30', days: [1, 2] }]), ''],
schedule_rule_row_key: ['101'], schedule_rule_start_time: ['09:30'],
schedule_rule_end_time: ['18:30'], schedule_rule_days_csv: ['']
}
}, response, error => { throw error; });
assert.equal(response.redirectedTo, '/playlists/4/edit', response.statusCode + ': ' + String(response.body || ''));
assert.deepEqual(writes, [
{ kind: 'update-group', id: 201, name: 'News updated' },
{ kind: 'update-rule', id: 301 },
{ kind: 'update-slide', id: 101, duration: 20 },
{ kind: 'update-rule', id: 302 },
{ kind: 'insert-slide', slideId: 13 },
{ kind: 'delete-slide', id: 102 }
]);
assert.deepEqual(items.map(item => item.id), [101, 103]);
assert.deepEqual(rules.map(rule => rule.id), [301, 302]);
});
test('playlist audit compares persisted items and schedules without recreated row IDs', async () => {
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]' }];
let items = [{ id: 101, slide_id: 11, group_id: null, duration_seconds: 10, position: 0, use_video_duration: 0, disable_audio: 1 }];
let rules = [{ id: 301, playlist_slide_id: 101, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, 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 beginTransaction() {}, async commit() {}, async rollback() {}, release() {},
async query(sql, params) {
if (sql.startsWith('SELECT ps.id, ps.slide_id, ps.group_id FROM c_playlist_slides')) return [structuredClone(items)];
if (sql.includes('FROM c_playlist_slides ps') && sql.includes('WHERE ps.playlist_id')) return [structuredClone(items)];
if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [[]];
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], group_id: params[6], duration_seconds: params[3] }];
return [{ insertId: 202 }];
if (sql.startsWith('UPDATE c_playlist_slides SET')) {
items = items.map(item => item.id === params[7] ? Object.assign({}, item, {
slide_id: params[0], position: params[1], duration_seconds: params[2],
use_video_duration: params[3], disable_audio: params[4], group_id: params[5]
}) : item);
return [{ affectedRows: 1 }];
}
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] });
if (sql.startsWith('UPDATE c_playlist_schedule_rules SET')) {
rules = rules.map(rule => rule.id === params[7] ? Object.assign({}, rule, {
position: params[0], start_datetime: params[1], end_datetime: params[2],
start_time: params[3] + ':00', end_time: params[4] + ':00', schedule_days_json: params[5]
}) : rule);
return [{ affectedRows: 1 }];
}
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
items = items.filter(item => item.id !== params[0]);
rules = rules.filter(rule => rule.playlist_slide_id !== params[0]);
}
return [[]];
}