Release 2.22.0: normalize playlist schedules and unify player sync
This commit is contained in:
1 parent
7748129307
commit
b71958cb38
39 files changed
+1173
-313
No files matched your search
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## 2.21.6 - 2026-10-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.21.6",
|
"version": "2.22.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.21.6",
|
"version": "2.22.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.21.6",
|
"version": "2.22.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.21.6",
|
"version": "2.22.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.7.13",
|
"@better-auth/passkey": "^1.7.13",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.21.6",
|
"version": "2.22.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+24
-12
@@ -28,31 +28,43 @@ async function fetchAdminData(pool) {
|
|||||||
ORDER BY s.id DESC
|
ORDER BY s.id DESC
|
||||||
`);
|
`);
|
||||||
const [playlistSlides] = await pool.query(`
|
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
|
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
|
FROM c_playlist_slides ps FORCE INDEX (idx_playlist_slides_playlist_position)
|
||||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
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_templates st ON st.id = sl.template_id
|
||||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_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
|
ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC
|
||||||
`);
|
`);
|
||||||
const [playlistScheduleRules] = await pool.query(`
|
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
|
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_slide_schedule_rules
|
FROM c_playlist_schedule_rules
|
||||||
ORDER BY playlist_slide_id ASC, position ASC, id ASC
|
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) {
|
playlistScheduleRules.forEach(function (rule) {
|
||||||
const slideId = Number(rule.playlist_slide_id);
|
if (rule.playlist_slide_id !== null && rule.playlist_slide_id !== undefined) {
|
||||||
if (!rulesBySlideId.has(slideId)) {
|
const playlistSlideId = Number(rule.playlist_slide_id);
|
||||||
rulesBySlideId.set(slideId, []);
|
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) {
|
const playlistSlidesWithRules = playlistSlides.map(function (slide) {
|
||||||
return Object.assign({}, slide, {
|
return Object.assign({}, slide, {
|
||||||
scheduleRules: rulesBySlideId.get(Number(slide.id)) || []
|
scheduleRules: rulesByPlaylistSlideId.get(Number(slide.id)) || [],
|
||||||
|
groupScheduleRules: rulesByGroupId.get(Number(slide.group_id)) || []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+29
-11
@@ -127,6 +127,25 @@ async function ensureSchema(pool, options) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) 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(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS c_playlist_slides (
|
CREATE TABLE IF NOT EXISTS c_playlist_slides (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
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,
|
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
|
||||||
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
|
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
disable_audio TINYINT(1) NOT NULL DEFAULT 1,
|
disable_audio TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
group_key VARCHAR(64) NULL,
|
group_id INT 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,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL,
|
modified_by INT NULL,
|
||||||
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE,
|
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_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)
|
INDEX idx_playlist_slides_playlist_position (playlist_id, position)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await pool.query(`
|
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,
|
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,
|
position INT NOT NULL DEFAULT 0,
|
||||||
start_datetime DATETIME NULL,
|
start_datetime DATETIME NULL,
|
||||||
end_datetime DATETIME NULL,
|
end_datetime DATETIME NULL,
|
||||||
@@ -167,8 +182,11 @@ async function ensureSchema(pool, options) {
|
|||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL,
|
modified_by INT NULL,
|
||||||
CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
|
CONSTRAINT fk_playlist_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_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
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
@@ -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(', '));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const relativePath = resolveMediaPath(req.params.filename);
|
const relativePath = resolveMediaPath(req.params.filename);
|
||||||
|
|||||||
+23
-75
@@ -171,7 +171,7 @@ async function start() {
|
|||||||
|
|
||||||
async function triggerWebMediaSync() {
|
async function triggerWebMediaSync() {
|
||||||
const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL;
|
const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL;
|
||||||
if (!isRemotePlayer || !syncBaseUrl) {
|
if (!syncBaseUrl || !PLAYER_DEVICE_ID) {
|
||||||
return false;
|
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 webMediaSyncCompleted = false;
|
||||||
let webFontSyncCompleted = false;
|
let webMediaSyncTriggered = false;
|
||||||
let webFontSyncTriggered = 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() {
|
function shouldTriggerReconnectSync() {
|
||||||
if (!lastDisconnectAt) {
|
if (!lastDisconnectAt) {
|
||||||
@@ -414,7 +395,6 @@ async function start() {
|
|||||||
pathname: '/ws/players',
|
pathname: '/ws/players',
|
||||||
timestamp: timestamp
|
timestamp: timestamp
|
||||||
});
|
});
|
||||||
let webMediaSyncTriggered = false;
|
|
||||||
socket = new WebSocket(thinClientUrl, {
|
socket = new WebSocket(thinClientUrl, {
|
||||||
headers: Object.assign({
|
headers: Object.assign({
|
||||||
'x-pulse-request-timestamp': timestamp
|
'x-pulse-request-timestamp': timestamp
|
||||||
@@ -441,39 +421,6 @@ async function start() {
|
|||||||
|
|
||||||
refreshThinClientRegistration = sendHeartbeat;
|
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) {
|
function sendSnapshot(slug) {
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
@@ -531,7 +478,6 @@ async function start() {
|
|||||||
if (parsedMessage && String(parsedMessage.type || '').trim() === 'heartbeat-ack') {
|
if (parsedMessage && String(parsedMessage.type || '').trim() === 'heartbeat-ack') {
|
||||||
if (shouldTriggerReconnectSync()) {
|
if (shouldTriggerReconnectSync()) {
|
||||||
triggerMediaSyncIfNeeded();
|
triggerMediaSyncIfNeeded();
|
||||||
triggerFontSyncIfNeeded();
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -552,8 +498,7 @@ async function start() {
|
|||||||
|
|
||||||
socket.on('close', function () {
|
socket.on('close', function () {
|
||||||
lastDisconnectAt = Date.now();
|
lastDisconnectAt = Date.now();
|
||||||
webFontSyncTriggered = false;
|
webMediaSyncTriggered = false;
|
||||||
webFontSyncCompleted = false;
|
|
||||||
webMediaSyncCompleted = false;
|
webMediaSyncCompleted = false;
|
||||||
clearTimers();
|
clearTimers();
|
||||||
thinClientSocket = null;
|
thinClientSocket = null;
|
||||||
@@ -632,13 +577,16 @@ async function start() {
|
|||||||
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) {
|
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
|
triggerMediaSyncIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(function () {
|
setInterval(function () {
|
||||||
if (isRemotePlayer) {
|
if (isRemotePlayer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
syncDatabaseState().catch(function (error) {
|
syncDatabaseState().then(function () {
|
||||||
|
triggerMediaSyncIfNeeded();
|
||||||
|
}).catch(function (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
}, DB_SYNC_INTERVAL_MS);
|
}, DB_SYNC_INTERVAL_MS);
|
||||||
|
|||||||
+30
-21
@@ -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 [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 playlist = playlistRows[0] || null;
|
||||||
const [slideRows] = await pool.query(`
|
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.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
|
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
|
FROM c_playlist_slides ps FORCE INDEX (idx_playlist_slides_playlist_position)
|
||||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
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_templates st ON st.id = sl.template_id
|
||||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
WHERE ps.playlist_id = ?
|
WHERE ps.playlist_id = ?
|
||||||
ORDER BY ps.position ASC, ps.id ASC
|
ORDER BY ps.position ASC, ps.id ASC
|
||||||
`, [screen.playlist_id]);
|
`, [screen.playlist_id]);
|
||||||
const [scheduleRuleRows] = await pool.query(`
|
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
|
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_slide_schedule_rules r
|
FROM c_playlist_schedule_rules r
|
||||||
JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
|
WHERE r.playlist_slide_id IN (SELECT id FROM c_playlist_slides WHERE playlist_id = ?)
|
||||||
WHERE ps.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.position ASC, r.id ASC
|
ORDER BY r.playlist_slide_id ASC, r.playlist_group_id ASC, r.position ASC, r.id ASC
|
||||||
`, [screen.playlist_id]);
|
`, [screen.playlist_id, screen.playlist_id]);
|
||||||
const rulesBySlideId = {};
|
const rulesByPlaylistSlideId = {};
|
||||||
|
const rulesByGroupId = {};
|
||||||
scheduleRuleRows.forEach(function (rule) {
|
scheduleRuleRows.forEach(function (rule) {
|
||||||
const slideId = Number(rule.playlist_slide_id);
|
if (rule.playlist_slide_id !== null && rule.playlist_slide_id !== undefined) {
|
||||||
if (!rulesBySlideId[slideId]) {
|
const playlistSlideId = Number(rule.playlist_slide_id);
|
||||||
rulesBySlideId[slideId] = [];
|
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
|
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) {
|
if (template && template.background_image_path && !template.background_image_variants) {
|
||||||
const variants = await getImageVariants(template.background_image_path);
|
const variants = await getImageVariants(template.background_image_path);
|
||||||
if (variants.length) {
|
if (variants.length) {
|
||||||
@@ -356,14 +364,14 @@ function createPlayerPlaylistService(options) {
|
|||||||
duration_seconds: videoDuration || storedDuration,
|
duration_seconds: videoDuration || storedDuration,
|
||||||
use_video_duration: Boolean(slide.use_video_duration),
|
use_video_duration: Boolean(slide.use_video_duration),
|
||||||
disable_audio: disableAudio,
|
disable_audio: disableAudio,
|
||||||
scheduleRules: rulesBySlideId[Number(slide.id)] || [],
|
scheduleRules: rulesByPlaylistSlideId[Number(slide.playlist_slide_id)] || [],
|
||||||
groupKey: slide.group_key || null,
|
groupKey: slide.group_key || null,
|
||||||
groupName: slide.group_name || null,
|
groupName: slide.group_name || null,
|
||||||
groupPickCount: Number(slide.group_pick_count) || null,
|
groupPickCount: Number(slide.group_pick_count) || null,
|
||||||
groupShuffle: Boolean(Number(slide.group_shuffle)),
|
groupShuffle: Boolean(Number(slide.group_shuffle)),
|
||||||
groupMode: slide.group_mode === 'repeat' ? 'repeat' : 'select',
|
groupMode: slide.group_mode === 'repeat' ? 'repeat' : 'select',
|
||||||
groupRepeatCount: Math.min(100, Math.max(1, Number(slide.group_repeat_count) || 1)),
|
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_id: slide.template_id,
|
||||||
template: template,
|
template: template,
|
||||||
content: content
|
content: content
|
||||||
@@ -457,18 +465,19 @@ function createPlayerPlaylistService(options) {
|
|||||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||||
updatePlaylistRevisionHash(hash, slide.use_video_duration);
|
updatePlaylistRevisionHash(hash, slide.use_video_duration);
|
||||||
updatePlaylistRevisionHash(hash, slide.disable_audio);
|
updatePlaylistRevisionHash(hash, slide.disable_audio);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.group_id);
|
||||||
updatePlaylistRevisionHash(hash, slide.group_key);
|
updatePlaylistRevisionHash(hash, slide.group_key);
|
||||||
updatePlaylistRevisionHash(hash, slide.group_name);
|
updatePlaylistRevisionHash(hash, slide.group_name);
|
||||||
updatePlaylistRevisionHash(hash, slide.group_pick_count);
|
updatePlaylistRevisionHash(hash, slide.group_pick_count);
|
||||||
updatePlaylistRevisionHash(hash, slide.group_shuffle);
|
updatePlaylistRevisionHash(hash, slide.group_shuffle);
|
||||||
updatePlaylistRevisionHash(hash, slide.group_mode);
|
updatePlaylistRevisionHash(hash, slide.group_mode);
|
||||||
updatePlaylistRevisionHash(hash, slide.group_repeat_count);
|
updatePlaylistRevisionHash(hash, slide.group_repeat_count);
|
||||||
updatePlaylistRevisionHash(hash, slide.group_schedule_rules_json);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
(Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) {
|
(Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) {
|
||||||
updatePlaylistRevisionHash(hash, rule.id);
|
updatePlaylistRevisionHash(hash, rule.id);
|
||||||
updatePlaylistRevisionHash(hash, rule.playlist_slide_id);
|
updatePlaylistRevisionHash(hash, rule.playlist_slide_id);
|
||||||
|
updatePlaylistRevisionHash(hash, rule.playlist_group_id);
|
||||||
updatePlaylistRevisionHash(hash, rule.position);
|
updatePlaylistRevisionHash(hash, rule.position);
|
||||||
updatePlaylistRevisionHash(hash, rule.start_datetime);
|
updatePlaylistRevisionHash(hash, rule.start_datetime);
|
||||||
updatePlaylistRevisionHash(hash, rule.end_datetime);
|
updatePlaylistRevisionHash(hash, rule.end_datetime);
|
||||||
|
|||||||
@@ -107,13 +107,8 @@ function normalizeScheduleRule(rule) {
|
|||||||
const endDatetime = String(input.end_datetime || input.endDateTime || '').trim();
|
const endDatetime = String(input.end_datetime || input.endDateTime || '').trim();
|
||||||
const startTime = String(input.start_time || input.startTime || '').trim();
|
const startTime = String(input.start_time || input.startTime || '').trim();
|
||||||
const endTime = String(input.end_time || input.endTime || '').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) {
|
||||||
if (!startTime || !endTime) {
|
if (!startTime || !endTime) {
|
||||||
return null;
|
return null;
|
||||||
@@ -159,13 +154,14 @@ function matchesScheduleRule(rule, now) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized.start_datetime && normalized.end_datetime) {
|
if (normalized.start_datetime || normalized.end_datetime) {
|
||||||
const start = new Date(normalized.start_datetime);
|
const start = normalized.start_datetime ? new Date(normalized.start_datetime) : null;
|
||||||
const end = new Date(normalized.end_datetime);
|
const end = normalized.end_datetime ? new Date(normalized.end_datetime) : null;
|
||||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
if ((start && Number.isNaN(start.getTime())) || (end && Number.isNaN(end.getTime()))
|
||||||
|
|| (start && end && end <= start)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (now < start || now > end) {
|
if ((start && now < start) || (end && now > end)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-5
@@ -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 {
|
try {
|
||||||
const response = await fetch(request);
|
const response = await fetch(request);
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
@@ -52,18 +61,21 @@ async function networkFirst(request, cacheName, cacheKeyRequest) {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
if (response && response.status === 304) {
|
if (response && response.status === 304) {
|
||||||
const cached = await caches.match(cacheKeyRequest || request);
|
const cached = await matchCache(cacheName, cacheKeyRequest || request);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return 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) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
const cached = await caches.match(cacheKeyRequest || request);
|
const cached = await matchCache(cacheName, cacheKeyRequest || request);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
@@ -184,6 +196,6 @@ self.addEventListener('fetch', function (event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith('/api/screens/') && url.pathname.endsWith('/playlist')) {
|
if (url.pathname.startsWith('/api/screens/') && url.pathname.endsWith('/playlist')) {
|
||||||
event.respondWith(networkFirst(request, PLAYLIST_CACHE, normalizeRequest(request)));
|
event.respondWith(networkFirst(request, PLAYLIST_CACHE, normalizeRequest(request), false));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
+40
-1
@@ -268,6 +268,41 @@ function registerPlayerRoutes(app, options) {
|
|||||||
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
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', express.static(assetDir));
|
||||||
app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons')));
|
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')));
|
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) {
|
if (response.status === 304) {
|
||||||
const cachedSnapshot = await readPlaylistSnapshot(req.params.slug);
|
const cachedSnapshot = await readPlaylistSnapshot(req.params.slug);
|
||||||
if (cachedSnapshot) {
|
if (cachedSnapshot) {
|
||||||
|
await syncRemoteImageCache(req, cachedSnapshot);
|
||||||
return res.end();
|
return res.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,12 +537,15 @@ function registerPlayerRoutes(app, options) {
|
|||||||
return res.status(502).json({ error: 'Thin client playlist cache unavailable.' });
|
return res.status(502).json({ error: 'Thin client playlist cache unavailable.' });
|
||||||
}
|
}
|
||||||
await writePlaylistSnapshot(req.params.slug, refreshedData);
|
await writePlaylistSnapshot(req.params.slug, refreshedData);
|
||||||
|
await syncRemoteImageCache(req, refreshedData);
|
||||||
return res.json(refreshedData);
|
return res.json(refreshedData);
|
||||||
}
|
}
|
||||||
res.type(response.headers.get('content-type') || 'application/json');
|
res.type(response.headers.get('content-type') || 'application/json');
|
||||||
const responseText = await response.text();
|
const responseText = await response.text();
|
||||||
try {
|
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) {
|
} catch (_error) {
|
||||||
}
|
}
|
||||||
return res.send(responseText);
|
return res.send(responseText);
|
||||||
|
|||||||
Vendored
+92
-54
@@ -5,6 +5,29 @@ const { createDashboardStateService } = require('#src/web/lib/dashboard-state');
|
|||||||
const { createUploadSyncService } = require('#src/web/lib/media/index');
|
const { createUploadSyncService } = require('#src/web/lib/media/index');
|
||||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
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) {
|
function createWebBootstrap(options) {
|
||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
@@ -24,23 +47,22 @@ function createWebBootstrap(options) {
|
|||||||
const dashboardClients = new Set();
|
const dashboardClients = new Set();
|
||||||
const playerSnapshotCache = options && options.playerSnapshotCache ? options.playerSnapshotCache : new Map();
|
const playerSnapshotCache = options && options.playerSnapshotCache ? options.playerSnapshotCache : new Map();
|
||||||
const playerSnapshotSockets = options && options.playerSnapshotSockets ? options.playerSnapshotSockets : new Map();
|
const playerSnapshotSockets = options && options.playerSnapshotSockets ? options.playerSnapshotSockets : new Map();
|
||||||
|
const playerSnapshotSourcesBySlug = new Map();
|
||||||
let dashboardRefreshInFlight = null;
|
let dashboardRefreshInFlight = null;
|
||||||
let broadcastDashboardState = null;
|
let broadcastDashboardState = null;
|
||||||
function getPlayerSnapshotSocketUrl(slug) {
|
function getPlayerSnapshotSocketUrl(baseUrl, slug) {
|
||||||
const resolvedPlayerInternalBaseUrl = configuredBridgeInternalUrl || configuredPlayerInternalUrl;
|
if (!baseUrl) {
|
||||||
if (!resolvedPlayerInternalBaseUrl) {
|
|
||||||
throw new Error('Unable to resolve the player internal base URL.');
|
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.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
|
||||||
url.search = '';
|
url.search = '';
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPlayerSnapshotSocket(slug) {
|
function clearPlayerSnapshotSocket(subscriptionKey) {
|
||||||
const key = String(slug || '').trim();
|
playerSnapshotSockets.delete(subscriptionKey);
|
||||||
playerSnapshotSockets.delete(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensurePlayerSnapshotSubscription(slug) {
|
function ensurePlayerSnapshotSubscription(slug) {
|
||||||
@@ -49,61 +71,75 @@ function createWebBootstrap(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const key = String(slug || '').trim();
|
const key = String(slug || '').trim();
|
||||||
if (!key || playerSnapshotSockets.has(key)) {
|
if (!key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
playerSnapshotSockets.set(key, null);
|
const sourceUrls = Array.from(new Set([configuredPlayerInternalUrl, configuredBridgeInternalUrl].filter(Boolean)));
|
||||||
const authHeaders = createRequestAuthHeaders({
|
sourceUrls.forEach(function (baseUrl) {
|
||||||
method: 'GET',
|
const subscriptionKey = key + '\0' + baseUrl;
|
||||||
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
if (playerSnapshotSockets.has(subscriptionKey)) {
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Promise.resolve(getPlayerSnapshotSocketUrl(key)).then(function (socketUrl) {
|
playerSnapshotSockets.set(subscriptionKey, null);
|
||||||
const socket = new WebSocket(socketUrl, {
|
const authHeaders = createRequestAuthHeaders({
|
||||||
headers: authHeaders
|
method: 'GET',
|
||||||
|
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
||||||
});
|
});
|
||||||
playerSnapshotSockets.set(key, socket);
|
|
||||||
|
|
||||||
socket.onmessage = function (event) {
|
Promise.resolve().then(function () {
|
||||||
try {
|
const socket = new WebSocket(getPlayerSnapshotSocketUrl(baseUrl, key), {
|
||||||
const payload = JSON.parse(String(event.data || '{}'));
|
headers: authHeaders
|
||||||
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
|
});
|
||||||
return;
|
playerSnapshotSockets.set(subscriptionKey, socket);
|
||||||
}
|
|
||||||
playerSnapshotCache.set(key, {
|
socket.onmessage = function (event) {
|
||||||
slug: key,
|
try {
|
||||||
count: Array.isArray(payload.connections) ? payload.connections.length : 0,
|
const payload = JSON.parse(String(event.data || '{}'));
|
||||||
connections: Array.isArray(payload.connections) ? payload.connections : []
|
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
|
||||||
});
|
return;
|
||||||
uploadSyncService.scheduleAssignmentSync(uploadDir);
|
}
|
||||||
if (broadcastDashboardState) {
|
if (!playerSnapshotSourcesBySlug.has(key)) {
|
||||||
broadcastDashboardState().catch(function (error) {
|
playerSnapshotSourcesBySlug.set(key, new Map());
|
||||||
console.error(error);
|
}
|
||||||
|
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 () {
|
socket.onclose = function () {
|
||||||
clearPlayerSnapshotSocket(key);
|
clearPlayerSnapshotSocket(subscriptionKey);
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
ensurePlayerSnapshotSubscription(key);
|
ensurePlayerSnapshotSubscription(key);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onerror = function () {
|
socket.onerror = function () {
|
||||||
try {
|
try {
|
||||||
socket.close();
|
socket.close();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// ignore close errors
|
// ignore close errors
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
clearPlayerSnapshotSocket(key);
|
clearPlayerSnapshotSocket(subscriptionKey);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +147,9 @@ function createWebBootstrap(options) {
|
|||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
thinClientBaseUrl: configuredBridgeInternalUrl,
|
thinClientBaseUrl: configuredBridgeInternalUrl,
|
||||||
playerServiceName: configuredBridgeInternalUrl ? 'Bridge' : 'Player',
|
playerServiceName: configuredBridgeInternalUrl && configuredPlayerInternalUrl
|
||||||
|
? 'Player services'
|
||||||
|
: configuredBridgeInternalUrl ? 'Bridge' : 'Player',
|
||||||
playerSnapshotCache: playerSnapshotCache,
|
playerSnapshotCache: playerSnapshotCache,
|
||||||
playerSnapshotSockets: playerSnapshotSockets,
|
playerSnapshotSockets: playerSnapshotSockets,
|
||||||
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
||||||
@@ -270,4 +308,4 @@ function createWebBootstrap(options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createWebBootstrap };
|
module.exports = { createWebBootstrap, mergePlayerSnapshotConnections };
|
||||||
@@ -59,6 +59,14 @@ function registerStartupTasks(options) {
|
|||||||
if (typeof exported === 'function') {
|
if (typeof exported === 'function') {
|
||||||
await exported(options);
|
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 {
|
return {
|
||||||
|
|||||||
@@ -29,10 +29,11 @@ function registerFontSyncTask(options) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
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 {
|
} else {
|
||||||
const copied = await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
|
const copied = await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
|
||||||
if (payload.requireSuccess && copied === false) throw new Error('Unable to synchronize ' + uploadPath + ' to player ' + playerIdentifier + '.');
|
if (payload.requireSuccess && !copied) throw new Error('Unable to synchronize ' + uploadPath + ' to player ' + playerIdentifier + '.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ function registerInitialFontSyncTask(options) {
|
|||||||
operations: operations,
|
operations: operations,
|
||||||
playerIdentifier: metadata.playerIdentifier,
|
playerIdentifier: metadata.playerIdentifier,
|
||||||
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
|
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
|
||||||
playerInternalBaseUrl: metadata.playerInternalBaseUrl
|
playerInternalBaseUrl: metadata.playerInternalBaseUrl,
|
||||||
|
requireSuccess: true
|
||||||
},
|
},
|
||||||
persist: true
|
persist: true
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ function createWebConfig() {
|
|||||||
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
||||||
const assetDir = path.join(__dirname, '..', 'public');
|
const assetDir = path.join(__dirname, '..', 'public');
|
||||||
const playerInternalUrl = (process.env.PLAYER_INTERNAL_URL || 'http://player:8081').replace(/\/$/, '');
|
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 webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, '');
|
||||||
const sessionCookieName = 'digital_signage_session';
|
const sessionCookieName = 'digital_signage_session';
|
||||||
const sessionMaxAgeMs = 14 * 24 * 60 * 60 * 1000;
|
const sessionMaxAgeMs = 14 * 24 * 60 * 60 * 1000;
|
||||||
|
|||||||
@@ -75,12 +75,6 @@ function normalizeScheduleRule(rule) {
|
|||||||
input.days || input.day_list || input.schedule_days || input.schedule_days_json || input.scheduleDays
|
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) {
|
||||||
if (!startTime || !endTime) {
|
if (!startTime || !endTime) {
|
||||||
return null;
|
return null;
|
||||||
@@ -182,6 +176,10 @@ function formatScheduleRuleSummary(rule) {
|
|||||||
const leftParts = [];
|
const leftParts = [];
|
||||||
if (normalized.start_datetime && normalized.end_datetime) {
|
if (normalized.start_datetime && normalized.end_datetime) {
|
||||||
leftParts.push('Dates ' + formatDateTime(normalized.start_datetime) + ' to ' + formatDateTime(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 {
|
} else {
|
||||||
leftParts.push('Any date');
|
leftParts.push('Any date');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,13 @@
|
|||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
days = String(input.days).split(',');
|
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) {
|
days = Array.from(new Set(days.map(function (day) {
|
||||||
@@ -81,12 +88,6 @@
|
|||||||
return left - right;
|
return left - right;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (startDatetime || endDatetime) {
|
|
||||||
if (!startDatetime || !endDatetime) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startTime || endTime) {
|
if (startTime || endTime) {
|
||||||
if (!startTime || !endTime) {
|
if (!startTime || !endTime) {
|
||||||
return null;
|
return null;
|
||||||
@@ -151,6 +152,10 @@
|
|||||||
|
|
||||||
if (normalized.start_datetime && normalized.end_datetime) {
|
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));
|
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 {
|
} else {
|
||||||
left.push('Any date');
|
left.push('Any date');
|
||||||
}
|
}
|
||||||
@@ -525,11 +530,6 @@
|
|||||||
var endTime = String(fields.endTime && fields.endTime.value || '').trim();
|
var endTime = String(fields.endTime && fields.endTime.value || '').trim();
|
||||||
clearRuleValidity(card);
|
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)) {
|
if ((startTime || endTime) && (!startTime || !endTime)) {
|
||||||
setRuleError(!startTime ? fields.startTime : fields.endTime, 'Start and end times must both be set for this rule.');
|
setRuleError(!startTime ? fields.startTime : fields.endTime, 'Start and end times must both be set for this rule.');
|
||||||
return false;
|
return false;
|
||||||
@@ -597,16 +597,6 @@
|
|||||||
var startTime = String(fields.startTime && fields.startTime.value || '').trim();
|
var startTime = String(fields.startTime && fields.startTime.value || '').trim();
|
||||||
var endTime = String(fields.endTime && fields.endTime.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) {
|
||||||
if (!startTime || !endTime) {
|
if (!startTime || !endTime) {
|
||||||
return {
|
return {
|
||||||
@@ -1437,11 +1427,6 @@
|
|||||||
|
|
||||||
clearRuleValidity(card);
|
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)) {
|
if ((startTime || endTime) && (!startTime || !endTime)) {
|
||||||
setRuleError(!startTime ? fields.startTime : fields.endTime, 'Start and end times must both be set for this rule.');
|
setRuleError(!startTime ? fields.startTime : fields.endTime, 'Start and end times must both be set for this rule.');
|
||||||
isValid = false;
|
isValid = false;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// URL and heartbeat checks in each route, which makes the screen-target selection consistent.
|
// URL and heartbeat checks in each route, which makes the screen-target selection consistent.
|
||||||
|
|
||||||
const { createPageAuthToken } = require('#src/request-auth');
|
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) {
|
module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
@@ -121,6 +121,20 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || normalizedPublicBaseUrl;
|
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
|
// 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
|
// logic explicit so a screen can be controlled through the correct transport without mixing both
|
||||||
// path choices into the command payload itself.
|
// path choices into the command payload itself.
|
||||||
@@ -653,22 +667,30 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
|||||||
commandPayload.blackout = blackoutValue;
|
commandPayload.blackout = blackoutValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const liveConnections = await fetchScreenConnections(slug);
|
const registeredLocalPlayerBaseUrl = connectionId
|
||||||
const liveConnection = connectionId
|
? await resolveRegisteredLocalPlayerBaseUrl(playerBaseUrl)
|
||||||
? liveConnections.find(function (connection) {
|
: '';
|
||||||
const candidateConnectionId = String(connection && connection.id || '').trim();
|
let result;
|
||||||
const candidateClientId = String(connection && connection.clientId || '').trim();
|
if (registeredLocalPlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
||||||
return candidateConnectionId === connectionId || candidateClientId === connectionId;
|
result = await forwardPlayerCommandToBaseUrl(registeredLocalPlayerBaseUrl, slug, commandPayload, connectionId);
|
||||||
})
|
} else {
|
||||||
: null;
|
const liveConnections = await fetchScreenConnections(slug);
|
||||||
const livePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(liveConnection && liveConnection.playerPublicBaseUrl);
|
const liveConnection = connectionId
|
||||||
const targetPlayerBaseUrl = livePlayerBaseUrl || playerBaseUrl;
|
? liveConnections.find(function (connection) {
|
||||||
const liveDeviceId = String(liveConnection && liveConnection.playerDeviceId || '').trim();
|
const candidateConnectionId = String(connection && connection.id || '').trim();
|
||||||
const result = liveDeviceId && typeof forwardPlayerCommandToDevice === 'function'
|
const candidateClientId = String(connection && connection.clientId || '').trim();
|
||||||
? await forwardPlayerCommandToDevice(liveDeviceId, Object.assign({ screenSlug: slug }, commandPayload, connectionId ? { connectionId: connectionId } : {}))
|
return candidateConnectionId === connectionId || candidateClientId === connectionId;
|
||||||
: targetPlayerBaseUrl
|
})
|
||||||
? await forwardPlayerCommandForPlayerBaseUrl(slug, targetPlayerBaseUrl, commandPayload, connectionId, null)
|
: null;
|
||||||
: await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, 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') {
|
if (typeof broadcastDashboardState === 'function') {
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
|
|||||||
@@ -21,28 +21,43 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
|
|
||||||
async function fetchPlaylistAuditItems(connection, playlistId) {
|
async function fetchPlaylistAuditItems(connection, playlistId) {
|
||||||
const [items] = await connection.query(
|
const [items] = await connection.query(
|
||||||
`SELECT id, slide_id, duration_seconds, use_video_duration, disable_audio,
|
`SELECT ps.id, ps.slide_id, ps.group_id, ps.duration_seconds, ps.use_video_duration, ps.disable_audio,
|
||||||
group_key, group_name, group_pick_count, group_shuffle, group_mode,
|
g.group_key, g.name AS group_name, g.pick_count AS group_pick_count, g.shuffle AS group_shuffle,
|
||||||
group_repeat_count, group_schedule_rules_json
|
g.mode AS group_mode, g.repeat_count AS group_repeat_count
|
||||||
FROM c_playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC`,
|
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]
|
[playlistId]
|
||||||
);
|
);
|
||||||
const [rules] = await connection.query(
|
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
|
`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_slide_schedule_rules r
|
FROM c_playlist_schedule_rules r
|
||||||
INNER JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
|
LEFT JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
|
||||||
WHERE ps.playlist_id = ? ORDER BY r.position ASC, r.id ASC`,
|
LEFT JOIN c_playlist_groups pg ON pg.id = r.playlist_group_id
|
||||||
[playlistId]
|
WHERE ps.playlist_id = ? OR pg.playlist_id = ? ORDER BY r.position ASC, r.id ASC`,
|
||||||
|
[playlistId, playlistId]
|
||||||
);
|
);
|
||||||
const rulesByItem = new Map();
|
const rulesByItem = new Map();
|
||||||
rules.forEach(function ({ playlist_slide_id: itemId, ...rule }) {
|
const rulesByGroup = new Map();
|
||||||
if (!rulesByItem.has(Number(itemId))) rulesByItem.set(Number(itemId), []);
|
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);
|
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 }) {
|
return items.map(function ({ id, group_id: groupId, ...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, {
|
||||||
return Object.assign(item, { scheduleRules: rulesByItem.get(Number(id)) || [] });
|
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) {
|
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) {
|
for (let index = 0; index < scheduleRules.length; index += 1) {
|
||||||
const rule = scheduleRules[index];
|
const rule = scheduleRules[index];
|
||||||
await connection.query(
|
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]
|
[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) {
|
function createValidationError(message) {
|
||||||
const error = new Error(message);
|
const error = new Error(message);
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
@@ -256,9 +282,15 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
const endDatetime = String(rule.end_datetime || '').trim() || null;
|
const endDatetime = String(rule.end_datetime || '').trim() || null;
|
||||||
const startTime = String(rule.start_time || '').trim() || null;
|
const startTime = String(rule.start_time || '').trim() || null;
|
||||||
const endTime = String(rule.end_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))) : [];
|
let days = Array.isArray(rule.days) ? rule.days : [];
|
||||||
if (Boolean(startDatetime) !== Boolean(endDatetime) || Boolean(startTime) !== Boolean(endTime)
|
if (!days.length && rule.schedule_days_json != null) {
|
||||||
|| (startDatetime && (Number.isNaN(new Date(startDatetime).getTime()) || Number.isNaN(new Date(endDatetime).getTime()) || new Date(endDatetime) < new Date(startDatetime)))
|
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))
|
|| (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; })) {
|
|| days.some(function (day) { return !Number.isInteger(day) || day < 0 || day > 6; })) {
|
||||||
throw new Error('Group schedule rule is invalid.');
|
throw new Error('Group schedule rule is invalid.');
|
||||||
@@ -286,7 +318,9 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
const playlistScheduleRules = scheduleRulesByRowKey.get(rowKey) || [];
|
const playlistScheduleRules = scheduleRulesByRowKey.get(rowKey) || [];
|
||||||
for (const rule of playlistScheduleRules) {
|
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.');
|
throw createValidationError('End datetime must be after start datetime.');
|
||||||
}
|
}
|
||||||
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
|
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)) {
|
if (item.groupPickCount > groupSizes.get(item.groupKey)) {
|
||||||
throw createValidationError('Group pick count cannot exceed the number of slides in the group.');
|
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);
|
const previousSettings = groupSettings.get(item.groupKey);
|
||||||
if (previousSettings && (previousSettings.name.toLowerCase() !== settings.name.toLowerCase()
|
if (previousSettings && (previousSettings.name.toLowerCase() !== settings.name.toLowerCase()
|
||||||
|| previousSettings.pickCount !== settings.pickCount || previousSettings.shuffle !== settings.shuffle
|
|| 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_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) {
|
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||||
const item = normalizedSlides[i];
|
const item = normalizedSlides[i];
|
||||||
const [insertResult] = await connection.query(
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'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, item.groupName, item.groupPickCount, item.groupKey ? JSON.stringify(item.groupScheduleRules) : null, item.groupShuffle, item.groupMode, item.groupRepeatCount, actorId, actorId]
|
[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 insertedPlaylistSlideId = Number(insertResult.insertId);
|
||||||
const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : [];
|
const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : [];
|
||||||
for (let ruleIndex = 0; ruleIndex < itemRules.length; ruleIndex += 1) {
|
for (let ruleIndex = 0; ruleIndex < itemRules.length; ruleIndex += 1) {
|
||||||
const rule = itemRules[ruleIndex];
|
const rule = itemRules[ruleIndex];
|
||||||
await connection.query(
|
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]
|
[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.');
|
return res.status(400).send('Schedule rules are invalid.');
|
||||||
}
|
}
|
||||||
for (const rule of scheduleRules) {
|
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.');
|
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) {
|
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ module.exports = function registerInternalSyncRoutes(app, deps) {
|
|||||||
operations: operations,
|
operations: operations,
|
||||||
playerIdentifier: playerIdentifier,
|
playerIdentifier: playerIdentifier,
|
||||||
playerPublicBaseUrl: playerPublicBaseUrl,
|
playerPublicBaseUrl: playerPublicBaseUrl,
|
||||||
playerInternalBaseUrl: playerInternalBaseUrl
|
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||||
|
requireSuccess: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
res.status(202).json({ ok: true, queued: true, task: task });
|
res.status(202).json({ ok: true, queued: true, task: task });
|
||||||
|
|||||||
@@ -209,16 +209,9 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
|
|||||||
groupShuffle: Boolean(Number(item.group_shuffle)),
|
groupShuffle: Boolean(Number(item.group_shuffle)),
|
||||||
groupMode: item.group_mode === 'repeat' ? 'repeat' : 'select',
|
groupMode: item.group_mode === 'repeat' ? 'repeat' : 'select',
|
||||||
groupRepeatCount: Math.min(100, Math.max(1, Number(item.group_repeat_count) || 1)),
|
groupRepeatCount: Math.min(100, Math.max(1, Number(item.group_repeat_count) || 1)),
|
||||||
groupScheduleRules: Array.isArray(item.group_schedule_rules_json)
|
groupScheduleRules: (Array.isArray(item.groupScheduleRules) ? item.groupScheduleRules : []).map((rule) => Object.assign({}, rule, {
|
||||||
? item.group_schedule_rules_json
|
days: normalizeDayValues(rule.days || rule.schedule_days_json)
|
||||||
: (function () {
|
})),
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(item.group_schedule_rules_json || '[]');
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch (_error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}()),
|
|
||||||
scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView),
|
scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView),
|
||||||
scheduleSummary: scheduleSummary(item)
|
scheduleSummary: scheduleSummary(item)
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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' }
|
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'
|
||||||
|
}]);
|
||||||
|
});
|
||||||
+27
-1
@@ -1,7 +1,7 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
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 () => {
|
test('slide search does not include slide content_json', async () => {
|
||||||
const queries = [];
|
const queries = [];
|
||||||
@@ -23,3 +23,29 @@ test('slide search does not include slide content_json', async () => {
|
|||||||
assert.match(queries[0].sql, /CAST\(s\.title AS CHAR\)/);
|
assert.match(queries[0].sql, /CAST\(s\.title AS CHAR\)/);
|
||||||
assert.match(queries[0].sql, /CAST\(st\.name 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);
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ const assert = require('node:assert/strict');
|
|||||||
require('../src/common');
|
require('../src/common');
|
||||||
|
|
||||||
const { registerInitialFontSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/font-sync');
|
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 () => {
|
test('initial font sync queues a separate task for each live player', async () => {
|
||||||
const queuedTasks = [];
|
const queuedTasks = [];
|
||||||
@@ -60,3 +61,37 @@ test('initial font sync queues a separate task for each live player', async () =
|
|||||||
return task.key === 'initial-font-sync' && task.title === 'Initial font sync' && task.taskType === 'font-sync';
|
return task.key === 'initial-font-sync' && task.title === 'Initial font sync' && task.taskType === 'font-sync';
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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\./);
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ const assert = require('node:assert/strict');
|
|||||||
require('../src/common');
|
require('../src/common');
|
||||||
|
|
||||||
const { registerInitialMediaSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/media-sync');
|
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 () => {
|
test('initial media sync queues a separate task for each live player', async () => {
|
||||||
const queuedTasks = [];
|
const queuedTasks = [];
|
||||||
@@ -60,3 +61,44 @@ test('initial media sync queues a separate task for each live player', async ()
|
|||||||
return task.key === 'initial-media-sync' && task.title === 'Initial media sync' && task.taskType === 'media-sync';
|
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');
|
||||||
|
});
|
||||||
@@ -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].taskType, 'font-sync');
|
||||||
assert.equal(queuedTasks[0].payload.mode, 'initial');
|
assert.equal(queuedTasks[0].payload.mode, 'initial');
|
||||||
assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote');
|
assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote');
|
||||||
|
assert.equal(queuedTasks[0].payload.requireSuccess, true);
|
||||||
assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote');
|
assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote');
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise(function (resolve) {
|
await new Promise(function (resolve) {
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
|||||||
if (sql.includes('FROM c_playlist_slides ps')) {
|
if (sql.includes('FROM c_playlist_slides ps')) {
|
||||||
return [[{
|
return [[{
|
||||||
id: 101,
|
id: 101,
|
||||||
|
playlist_slide_id: 301,
|
||||||
|
group_id: 55,
|
||||||
title: 'Intro',
|
title: 'Intro',
|
||||||
template_id: 33,
|
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"}}',
|
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_shuffle: 1,
|
||||||
group_mode: 'repeat',
|
group_mode: 'repeat',
|
||||||
group_repeat_count: 2,
|
group_repeat_count: 2,
|
||||||
group_schedule_rules_json: '[{"start_time":"08:00","end_time":"12:00","days":[1,2,3]}]',
|
|
||||||
template_name: 'Template 33',
|
template_name: 'Template 33',
|
||||||
canvas_size_name: 'HD',
|
canvas_size_name: 'HD',
|
||||||
canvas_size_width: 1920,
|
canvas_size_width: 1920,
|
||||||
@@ -78,6 +79,7 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
|||||||
canvas_height: 1080
|
canvas_height: 1080
|
||||||
}, {
|
}, {
|
||||||
id: 102,
|
id: 102,
|
||||||
|
playlist_slide_id: 302,
|
||||||
title: 'Live Feed',
|
title: 'Live Feed',
|
||||||
template_id: 33,
|
template_id: 33,
|
||||||
content_json: '{"rtmpRegion":{"type":"rtmp","value":"rtmp://example/live"}}',
|
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
|
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 [[
|
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: 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: 101, position: 2, start_datetime: null, end_datetime: null, start_time: '13:00', end_time: '17:00', schedule_days_json: '[4,5]' }
|
{ 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')) {
|
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].groupMode, 'select');
|
||||||
assert.equal(payload.slides[1].groupRepeatCount, 1);
|
assert.equal(payload.slides[1].groupRepeatCount, 1);
|
||||||
assert.equal(payload.slides[1].groupShuffle, false);
|
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.slides[0].template.regions[0].label, 'Text Region');
|
||||||
assert.equal(payload.rssFeeds[0].items[0].normalized, true);
|
assert.equal(payload.rssFeeds[0].items[0].normalized, true);
|
||||||
assert.deepEqual(payload.apiSources[0].responseJson, { ok: true });
|
assert.deepEqual(payload.apiSources[0].responseJson, { ok: true });
|
||||||
|
|||||||
@@ -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, now), true);
|
||||||
assert.equal(sandbox.isSlideActive(gatedSlide, new Date(2026, 9, 14, 12, 30)), false);
|
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);
|
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', () => {
|
test('repeat groups play all eligible members in order and ignore Pick and shuffle', () => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
require('../src/common');
|
require('../src/common');
|
||||||
|
|
||||||
@@ -63,6 +66,10 @@ function createResponse() {
|
|||||||
headers[name] = value;
|
headers[name] = value;
|
||||||
return this;
|
return this;
|
||||||
},
|
},
|
||||||
|
type(value) {
|
||||||
|
headers['Content-Type'] = value;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
status(code) {
|
status(code) {
|
||||||
this.statusCode = code;
|
this.statusCode = code;
|
||||||
return this;
|
return this;
|
||||||
@@ -378,6 +385,60 @@ test('playlist api route returns 404, etag, and 304 responses', async () => {
|
|||||||
assert.equal(notModifiedRes.ended, true);
|
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 () => {
|
test('announcement route returns 503 for transient failures and 304 on matching etag', async () => {
|
||||||
const { app, handlers } = createAppAndHandlers();
|
const { app, handlers } = createAppAndHandlers();
|
||||||
const announcement = {
|
const announcement = {
|
||||||
|
|||||||
@@ -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', {
|
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 calls = { fetch: 0, writes: [] };
|
||||||
const listeners = new Map();
|
const listeners = new Map();
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
@@ -87,6 +88,7 @@ function createFetchWorker({ failure, cachedResponse = null, fetchError = false
|
|||||||
async open() {
|
async open() {
|
||||||
if (failure === 'open') throw new Error('Cache storage unavailable');
|
if (failure === 'open') throw new Error('Cache storage unavailable');
|
||||||
return {
|
return {
|
||||||
|
async match() { return namedCacheResponse; },
|
||||||
async put(key, response) {
|
async put(key, response) {
|
||||||
if (failure === 'put') throw new Error('QuotaExceededError');
|
if (failure === 'put') throw new Error('QuotaExceededError');
|
||||||
calls.writes.push({ key, response });
|
calls.writes.push({ key, response });
|
||||||
@@ -159,3 +161,38 @@ test('player script assets refresh from network and retain offline cache fallbac
|
|||||||
});
|
});
|
||||||
assert.equal(await offlineResponsePromise, cachedResponse);
|
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');
|
||||||
|
});
|
||||||
@@ -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'],
|
[[{ days: [1] }, { days: [2] }, { days: [3] }, { days: [4] }], '4 rules'],
|
||||||
[[{ start_datetime: '2026-10-25T09:00', end_datetime: '2026-10-26T17:00', days: [1] }],
|
[[{ 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'],
|
'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) }],
|
[[{ 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'],
|
'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',
|
[[{ 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;/);
|
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', () => {
|
test('playlist rows show mute controls for RTMP slides', () => {
|
||||||
const model = buildPlaylistFormViewModel(
|
const model = buildPlaylistFormViewModel(
|
||||||
{ id: 22, name: 'Playlist 22' },
|
{ id: 22, name: 'Playlist 22' },
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ function createResponse() {
|
|||||||
test('playlist create and update redirect to the canonical edit path', async () => {
|
test('playlist create and update redirect to the canonical edit path', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
let groupInsertParams = null;
|
let groupInsertParams = null;
|
||||||
|
let slideInsertParams = null;
|
||||||
|
const groupRuleInsertParams = [];
|
||||||
let canvasSizeEnabled = 1;
|
let canvasSizeEnabled = 1;
|
||||||
const connection = {
|
const connection = {
|
||||||
async beginTransaction() {},
|
async beginTransaction() {},
|
||||||
@@ -56,14 +58,28 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
|||||||
calls.push('delete-slides');
|
calls.push('delete-slides');
|
||||||
return [{ affectedRows: 1 }];
|
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')) {
|
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) {
|
||||||
return [[{ id: 11, canvas_size_id: 7 }]];
|
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;
|
groupInsertParams = params;
|
||||||
|
calls.push('insert-group');
|
||||||
|
return [{ insertId: 71 }];
|
||||||
|
}
|
||||||
|
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
|
||||||
|
slideInsertParams = params;
|
||||||
calls.push('insert-slide');
|
calls.push('insert-slide');
|
||||||
return [{ insertId: 101 }];
|
return [{ insertId: 101 }];
|
||||||
}
|
}
|
||||||
|
if (sql.startsWith('INSERT INTO c_playlist_schedule_rules')) {
|
||||||
|
groupRuleInsertParams.push(params);
|
||||||
|
calls.push('insert-group-rule');
|
||||||
|
return [{ insertId: 81 }];
|
||||||
|
}
|
||||||
return [[]];
|
return [[]];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -139,26 +155,31 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
|||||||
group_shuffle: ['1'],
|
group_shuffle: ['1'],
|
||||||
group_mode: ['repeat'],
|
group_mode: ['repeat'],
|
||||||
group_repeat_count: ['2'],
|
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(createRes.redirectedTo, '/playlists/4/edit');
|
||||||
assert.equal(updateRes.redirectedTo, '/playlists/4/edit', updateRes.statusCode + ': ' + String(updateRes.body || ''));
|
assert.equal(updateRes.redirectedTo, '/playlists/4/edit', updateRes.statusCode + ': ' + String(updateRes.body || ''));
|
||||||
assert.deepEqual(groupInsertParams.slice(6, 10), [
|
assert.deepEqual(groupInsertParams, [4, 'news', 'News', 1, 1, 'repeat', 2, 12, 12]);
|
||||||
'news',
|
assert.equal(slideInsertParams[6], 71);
|
||||||
'News',
|
assert.deepEqual(groupRuleInsertParams, [
|
||||||
1,
|
[71, 0, '2026-10-01T09:00', null, null, null, '[1,2,3]', 12, 12],
|
||||||
JSON.stringify([{ start_time: '08:00', end_time: '17:00', days: [1, 2, 3] }])
|
[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, [
|
assert.deepEqual(calls, [
|
||||||
'delete-slides',
|
'delete-slides',
|
||||||
|
'delete-groups',
|
||||||
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
|
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
|
||||||
'update-playlist',
|
'update-playlist',
|
||||||
'delete-slides',
|
'delete-slides',
|
||||||
|
'delete-groups',
|
||||||
|
'insert-group',
|
||||||
|
'insert-group-rule',
|
||||||
|
'insert-group-rule',
|
||||||
'insert-slide',
|
'insert-slide',
|
||||||
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists/4' }
|
{ 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 () => {
|
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]' }];
|
let rules = [{ playlist_slide_id: 101, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }];
|
||||||
const events = [];
|
const events = [];
|
||||||
const connection = {
|
const connection = {
|
||||||
async commit() {}, async rollback() {}, release() {},
|
async commit() {}, async rollback() {}, release() {},
|
||||||
async query(sql, params) {
|
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_slides ps') && sql.includes('WHERE ps.playlist_id')) return [structuredClone(items)];
|
||||||
if (sql.includes('FROM c_playlist_slide_schedule_rules r')) return [structuredClone(rules)];
|
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('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_slides')) { items = []; rules = []; }
|
||||||
|
if (sql.startsWith('DELETE FROM c_playlist_groups')) return [{ affectedRows: 0 }];
|
||||||
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
|
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 }];
|
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] });
|
rules.push({ playlist_slide_id: params[0], start_time: params[4] + ':00', end_time: params[5] + ':00', schedule_days_json: params[6] });
|
||||||
}
|
}
|
||||||
return [[]];
|
return [[]];
|
||||||
|
|||||||
@@ -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 () => {
|
test('2.20.1 release follows the consolidated 2.20.0 migration', async () => {
|
||||||
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.19.1' });
|
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 () => {
|
test('2.20.1 removes retired security setting rows', async () => {
|
||||||
@@ -628,3 +628,119 @@ test('2.19.0 indexed-reference migration backfills slide and template defaults',
|
|||||||
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] === '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'));
|
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);
|
||||||
|
});
|
||||||
@@ -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' });
|
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);
|
await pending[0].run(pool);
|
||||||
|
|
||||||
const deletions = pool.queries.filter(([sql]) => sql.startsWith('DELETE'));
|
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, params]) => sql.startsWith('SELECT id, template_id, region_key') && params[0] === 900));
|
||||||
assert.ok(!pool.queries.some(([sql]) => /^(UPDATE|ALTER|TRUNCATE)/.test(sql)));
|
assert.ok(!pool.queries.some(([sql]) => /^(UPDATE|ALTER|TRUNCATE)/.test(sql)));
|
||||||
assert.deepEqual(pool.queries.slice(-1), [['COMMIT']]);
|
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 () => {
|
test('2.19.1 font repair rolls back without releasing the caller-owned connection on failure', async () => {
|
||||||
|
|||||||
@@ -3,7 +3,37 @@ const assert = require('node:assert/strict');
|
|||||||
|
|
||||||
require('../src/common');
|
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 () => {
|
test('dashboard websocket broadcasts keep the last successful snapshot when refreshes fail', async () => {
|
||||||
let adminDataCalls = 0;
|
let adminDataCalls = 0;
|
||||||
|
|||||||
Reference in new issue
Block a user