+
+
+
+
+
+
+
+
@@ -75,6 +114,25 @@ export async function render(container) {
let currentWeekStart = new Date(weekStart);
let editingId = null;
+ // Wire up target radio buttons
+ const deviceRadio = document.getElementById('schedTargetDevice');
+ const groupRadio = document.getElementById('schedTargetGroup');
+ const deviceSelect = document.getElementById('schedDeviceSelect');
+ const groupSelect = document.getElementById('schedGroupSelect');
+ const noGroupsMsg = document.getElementById('schedNoGroups');
+ const zoneNote = document.getElementById('schedZoneNote');
+
+ function updateTargetVisibility() {
+ const isGroup = groupRadio.checked;
+ deviceSelect.style.display = isGroup ? 'none' : '';
+ groupSelect.style.display = isGroup ? '' : 'none';
+ if (noGroupsMsg) noGroupsMsg.style.display = (isGroup && groups.length === 0) ? '' : 'none';
+ zoneNote.style.display = isGroup ? '' : 'none';
+ }
+
+ deviceRadio.addEventListener('change', updateTargetVisibility);
+ groupRadio.addEventListener('change', updateTargetVisibility);
+
function updateWeekLabel() {
const end = new Date(currentWeekStart);
end.setDate(end.getDate() + 6);
@@ -125,12 +183,17 @@ export async function render(container) {
const cell = cal.querySelector(`[data-hour="${Math.floor(startHour)}"][data-day="${dayIdx}"]`);
if (!cell) return;
+ const isGroupSchedule = !!ev.group_id;
const block = document.createElement('div');
const topOffset = (startHour - Math.floor(startHour)) * 28;
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(20, duration * 28)}px;
- background:${ev.color || '#3B82F6'};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.85`;
- block.textContent = ev.title || ev.content_name || ev.widget_name || 'Scheduled';
- block.title = `${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}`;
+ background:${ev.color || '#3B82F6'};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.85;
+ ${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.6);' : ''}`;
+
+ const label = ev.title || ev.playlist_name || ev.content_name || ev.widget_name || 'Scheduled';
+ const prefix = isGroupSchedule ? `[${esc(ev.group_name || 'Group')}] ` : '';
+ block.textContent = prefix + label;
+ block.title = `${isGroupSchedule ? 'Group: ' + (ev.group_name || '') + '\n' : ''}${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}\nPriority: ${ev.priority}`;
block.onclick = () => editSchedule(ev);
cell.appendChild(block);
});
@@ -139,6 +202,8 @@ export async function render(container) {
function editSchedule(ev) {
editingId = ev.id;
document.getElementById('schedModalTitle').textContent = 'Edit Schedule';
+ document.getElementById('schedPlaylist').value = ev.playlist_id || '';
+ document.getElementById('schedLayout').value = ev.layout_id || '';
document.getElementById('schedContent').value = ev.content_id || '';
document.getElementById('schedTitle').value = ev.title || '';
const start = new Date(ev.start_time);
@@ -148,6 +213,17 @@ export async function render(container) {
document.getElementById('schedRepeat').value = ev.recurrence || '';
document.getElementById('schedPriority').value = ev.priority || 0;
document.getElementById('schedColor').value = ev.color || '#3B82F6';
+
+ // Set target type
+ if (ev.group_id) {
+ groupRadio.checked = true;
+ groupSelect.value = ev.group_id;
+ } else {
+ deviceRadio.checked = true;
+ deviceSelect.value = ev.device_id || document.getElementById('schedDevice').value;
+ }
+ updateTargetVisibility();
+
document.getElementById('scheduleModal').style.display = 'flex';
}
@@ -155,19 +231,35 @@ export async function render(container) {
editingId = null;
document.getElementById('schedModalTitle').textContent = 'Add Schedule';
document.getElementById('schedTitle').value = '';
+ document.getElementById('schedPlaylist').value = '';
+ document.getElementById('schedLayout').value = '';
+ document.getElementById('schedContent').value = '';
+ // Default to current device in the calendar selector
+ deviceRadio.checked = true;
+ deviceSelect.value = document.getElementById('schedDevice').value;
+ updateTargetVisibility();
document.getElementById('scheduleModal').style.display = 'flex';
};
document.getElementById('saveScheduleBtn').onclick = async () => {
- const deviceId = document.getElementById('schedDevice').value;
+ const isGroup = groupRadio.checked;
const contentId = document.getElementById('schedContent').value;
const startTime = document.getElementById('schedStart').value;
const endTime = document.getElementById('schedEnd').value;
+ if (isGroup && groups.length === 0) {
+ showToast('No groups available. Create a group first.', 'error');
+ return;
+ }
+
+ const playlistId = document.getElementById('schedPlaylist').value;
+ const layoutId = document.getElementById('schedLayout').value;
+
const today = new Date().toISOString().split('T')[0];
const data = {
- device_id: deviceId,
- content_id: contentId,
+ content_id: contentId || null,
+ playlist_id: playlistId || null,
+ layout_id: layoutId || null,
title: document.getElementById('schedTitle').value,
start_time: `${today}T${startTime}:00`,
end_time: `${today}T${endTime}:00`,
@@ -176,6 +268,12 @@ export async function render(container) {
color: document.getElementById('schedColor').value,
};
+ if (isGroup) {
+ data.group_id = groupSelect.value;
+ } else {
+ data.device_id = deviceSelect.value;
+ }
+
try {
if (editingId) {
await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) });
diff --git a/server/db/database.js b/server/db/database.js
index 63de5b0..26c2e27 100644
--- a/server/db/database.js
+++ b/server/db/database.js
@@ -63,6 +63,8 @@ const migrations = [
// Phase 3: playlist publish/draft state
"ALTER TABLE playlists ADD COLUMN status TEXT NOT NULL DEFAULT 'draft'",
"ALTER TABLE playlists ADD COLUMN published_snapshot TEXT",
+ // Phase 4: group scheduling (column add only — full migration with CHECK below)
+ "ALTER TABLE schedules ADD COLUMN group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL",
];
for (const sql of migrations) {
try { db.exec(sql); } catch (e) { /* already exists */ }
@@ -244,6 +246,62 @@ function migratePublishSnapshots() {
migratePublishSnapshots();
+// Phase 4 migration: add group_id to schedules, make device_id nullable, add CHECK constraint
+const PHASE4_MIGRATION_ID = 'phase4_group_schedules';
+
+function migrateGroupSchedules() {
+ const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE4_MIGRATION_ID);
+ if (already) return;
+
+ console.log('Phase 4 migration: adding group_id to schedules, making device_id nullable...');
+
+ const migrate = db.transaction(() => {
+ db.exec(`
+ CREATE TABLE schedules_new (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id),
+ device_id TEXT REFERENCES devices(id) ON DELETE CASCADE,
+ group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL,
+ zone_id TEXT REFERENCES layout_zones(id) ON DELETE CASCADE,
+ content_id TEXT REFERENCES content(id) ON DELETE CASCADE,
+ widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE,
+ layout_id TEXT REFERENCES layouts(id) ON DELETE SET NULL,
+ playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL,
+ title TEXT NOT NULL DEFAULT '',
+ start_time TEXT NOT NULL,
+ end_time TEXT NOT NULL,
+ timezone TEXT NOT NULL DEFAULT 'UTC',
+ recurrence TEXT,
+ recurrence_end TEXT,
+ priority INTEGER NOT NULL DEFAULT 0,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ color TEXT DEFAULT '#3B82F6',
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
+ updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
+ CHECK ((device_id IS NOT NULL AND group_id IS NULL) OR (device_id IS NULL AND group_id IS NOT NULL))
+ );
+
+ INSERT INTO schedules_new (id, user_id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id,
+ title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at, updated_at)
+ SELECT id, user_id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id,
+ title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at, updated_at
+ FROM schedules;
+
+ DROP TABLE schedules;
+ ALTER TABLE schedules_new RENAME TO schedules;
+
+ CREATE INDEX idx_schedules_device ON schedules(device_id, enabled);
+ CREATE INDEX idx_schedules_group ON schedules(group_id, enabled);
+ `);
+
+ db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE4_MIGRATION_ID);
+ console.log('Phase 4 migration complete: schedules table rebuilt with group_id support.');
+ });
+ migrate();
+}
+
+migrateGroupSchedules();
+
// Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000)
function pruneTelemetry(deviceId) {
db.prepare(`
diff --git a/server/db/schema.sql b/server/db/schema.sql
index 60fa7ae..b8c4050 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -195,7 +195,8 @@ CREATE TABLE IF NOT EXISTS widgets (
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
- device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
+ device_id TEXT REFERENCES devices(id) ON DELETE CASCADE,
+ group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL,
zone_id TEXT REFERENCES layout_zones(id) ON DELETE CASCADE,
content_id TEXT REFERENCES content(id) ON DELETE CASCADE,
widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE,
@@ -211,10 +212,12 @@ CREATE TABLE IF NOT EXISTS schedules (
enabled INTEGER NOT NULL DEFAULT 1,
color TEXT DEFAULT '#3B82F6',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
- updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
+ updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
+ CHECK ((device_id IS NOT NULL AND group_id IS NULL) OR (device_id IS NULL AND group_id IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS idx_schedules_device ON schedules(device_id, enabled);
+CREATE INDEX IF NOT EXISTS idx_schedules_group ON schedules(group_id, enabled);
-- ===================== VIDEO WALLS =====================
diff --git a/server/player/index.html b/server/player/index.html
index a7626cb..5367ea8 100644
--- a/server/player/index.html
+++ b/server/player/index.html
@@ -108,13 +108,14 @@
let streamTimer = null;
let layout = null;
let zones = {};
- let userHasInteracted = false;
+ let userHasInteracted = !!localStorage.getItem('rd_audio_unlocked');
let advanceTimer = null;
// Track user interaction for autoplay policy
['click', 'touchstart', 'keydown'].forEach(evt => {
document.addEventListener(evt, () => {
userHasInteracted = true;
+ localStorage.setItem('rd_audio_unlocked', '1');
// Try to unmute any playing HTML5 video
const video = document.querySelector('#playerContainer video');
if (video && video.muted) {
@@ -181,30 +182,37 @@
playCurrentItem();
}
- // Show tap-to-start overlay to unlock audio on auto-reconnect
- const tapOverlay = document.createElement('div');
- tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer';
- tapOverlay.innerHTML = `
-
ScreenTinker
-
Tap anywhere to start
-
Audio requires user interaction
- `;
- tapOverlay.onclick = () => {
+ // If audio was previously unlocked (user tapped in a prior session), skip the overlay
+ if (userHasInteracted) {
+ console.log('Audio previously unlocked, skipping tap overlay');
unlockAudio();
- tapOverlay.remove();
- if (!isPlaying) showStatus('Connecting...');
connect(config.serverUrl);
- };
- document.body.appendChild(tapOverlay);
-
- // Auto-dismiss after 5 seconds if no interaction (plays muted)
- setTimeout(() => {
- if (tapOverlay.parentNode) {
+ } else {
+ // Show tap-to-start overlay to unlock audio on auto-reconnect
+ const tapOverlay = document.createElement('div');
+ tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer';
+ tapOverlay.innerHTML = `
+
ScreenTinker
+
Tap anywhere to start
+
Audio requires user interaction
+ `;
+ tapOverlay.onclick = () => {
+ unlockAudio();
tapOverlay.remove();
- if (!isPlaying) showStatus('Connecting (audio muted)...');
+ if (!isPlaying) showStatus('Connecting...');
connect(config.serverUrl);
- }
- }, 5000);
+ };
+ document.body.appendChild(tapOverlay);
+
+ // Auto-dismiss after 5 seconds if no interaction (plays muted)
+ setTimeout(() => {
+ if (tapOverlay.parentNode) {
+ tapOverlay.remove();
+ if (!isPlaying) showStatus('Connecting (audio muted)...');
+ connect(config.serverUrl);
+ }
+ }, 5000);
+ }
}
// ==================== Setup UI ====================
@@ -215,6 +223,7 @@
// Unlock audio on any user interaction
function unlockAudio() {
userHasInteracted = true;
+ localStorage.setItem('rd_audio_unlocked', '1');
// Create and resume AudioContext (unlocks audio for the session)
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
diff --git a/server/routes/device-groups.js b/server/routes/device-groups.js
index 90e7fc4..7c9f43d 100644
--- a/server/routes/device-groups.js
+++ b/server/routes/device-groups.js
@@ -47,10 +47,53 @@ router.put('/:id', requireGroupOwnership, (req, res) => {
res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id));
});
-// Delete group
+// Delete group — converts group schedules to per-device schedules first
router.delete('/:id', requireGroupOwnership, (req, res) => {
- db.prepare('DELETE FROM device_groups WHERE id = ?').run(req.params.id);
- res.json({ success: true });
+ const groupId = req.params.id;
+
+ const convert = db.transaction(() => {
+ // Find group schedules that need conversion
+ const groupSchedules = db.prepare('SELECT * FROM schedules WHERE group_id = ?').all(groupId);
+
+ // Find current group members
+ const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(groupId);
+
+ let converted = 0;
+
+ if (groupSchedules.length > 0 && members.length > 0) {
+ const insert = db.prepare(`
+ INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id,
+ widget_id, layout_id, playlist_id, title, start_time, end_time, timezone,
+ recurrence, recurrence_end, priority, enabled, color, created_at, updated_at)
+ VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+
+ for (const schedule of groupSchedules) {
+ for (const member of members) {
+ insert.run(
+ uuidv4(), schedule.user_id, member.device_id,
+ schedule.zone_id, schedule.content_id, schedule.widget_id,
+ schedule.layout_id, schedule.playlist_id, schedule.title,
+ schedule.start_time, schedule.end_time, schedule.timezone,
+ schedule.recurrence, schedule.recurrence_end, schedule.priority,
+ schedule.enabled, schedule.color, schedule.created_at, schedule.updated_at
+ );
+ }
+ converted++;
+ }
+ }
+
+ // Delete group schedules explicitly (before group delete turns group_id to NULL via ON DELETE SET NULL)
+ db.prepare('DELETE FROM schedules WHERE group_id = ?').run(groupId);
+
+ // Delete the group (cascades to device_group_members)
+ db.prepare('DELETE FROM device_groups WHERE id = ?').run(groupId);
+
+ return { converted, devices: members.length };
+ });
+
+ const result = convert();
+ res.json({ success: true, schedules_converted: result.converted, devices: result.devices });
});
// Get devices in a group
diff --git a/server/routes/schedules.js b/server/routes/schedules.js
index 82a9c68..9f5035b 100644
--- a/server/routes/schedules.js
+++ b/server/routes/schedules.js
@@ -3,13 +3,49 @@ const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
+// Helper: build the expanded schedule query for a device (device-level + group-level)
+function getDeviceSchedulesQuery() {
+ return `
+ SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name,
+ dg.name as group_name, dg.color as group_color
+ FROM schedules s
+ LEFT JOIN content c ON s.content_id = c.id
+ LEFT JOIN widgets w ON s.widget_id = w.id
+ LEFT JOIN playlists p ON s.playlist_id = p.id
+ LEFT JOIN device_groups dg ON s.group_id = dg.id
+ WHERE s.enabled = 1
+ AND (
+ s.device_id = ?
+ OR s.group_id IN (
+ SELECT group_id FROM device_group_members WHERE device_id = ?
+ )
+ )
+ ORDER BY
+ CASE WHEN s.device_id IS NOT NULL THEN 1 ELSE 0 END DESC,
+ s.priority DESC,
+ s.created_at ASC
+ `;
+}
+
// List schedules (filterable)
router.get('/', (req, res) => {
- const { device_id, start, end } = req.query;
- let sql = 'SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name FROM schedules s LEFT JOIN content c ON s.content_id = c.id LEFT JOIN widgets w ON s.widget_id = w.id LEFT JOIN playlists p ON s.playlist_id = p.id WHERE s.user_id = ?';
+ const { device_id, group_id, start, end } = req.query;
+ let sql = `SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name,
+ dg.name as group_name, dg.color as group_color
+ FROM schedules s
+ LEFT JOIN content c ON s.content_id = c.id
+ LEFT JOIN widgets w ON s.widget_id = w.id
+ LEFT JOIN playlists p ON s.playlist_id = p.id
+ LEFT JOIN device_groups dg ON s.group_id = dg.id
+ WHERE s.user_id = ?`;
const params = [req.user.id];
- if (device_id) { sql += ' AND s.device_id = ?'; params.push(device_id); }
+ if (device_id) {
+ // Return both device-level and group-level schedules affecting this device
+ sql += ` AND (s.device_id = ? OR s.group_id IN (SELECT group_id FROM device_group_members WHERE device_id = ?))`;
+ params.push(device_id, device_id);
+ }
+ if (group_id) { sql += ' AND s.group_id = ?'; params.push(group_id); }
if (start) { sql += ' AND s.end_time >= ?'; params.push(start); }
if (end) { sql += ' AND s.start_time <= ?'; params.push(end); }
@@ -23,15 +59,7 @@ router.get('/device/:deviceId', (req, res) => {
if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
- const schedules = db.prepare(`
- SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name
- FROM schedules s
- LEFT JOIN content c ON s.content_id = c.id
- LEFT JOIN widgets w ON s.widget_id = w.id
- LEFT JOIN playlists p ON s.playlist_id = p.id
- WHERE s.device_id = ? AND s.enabled = 1
- ORDER BY s.priority DESC, s.start_time ASC
- `).all(req.params.deviceId);
+ const schedules = db.prepare(getDeviceSchedulesQuery()).all(req.params.deviceId, req.params.deviceId);
res.json(schedules);
});
@@ -51,15 +79,7 @@ router.get('/week', (req, res) => {
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 7);
- const schedules = db.prepare(`
- SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name
- FROM schedules s
- LEFT JOIN content c ON s.content_id = c.id
- LEFT JOIN widgets w ON s.widget_id = w.id
- LEFT JOIN playlists p ON s.playlist_id = p.id
- WHERE s.device_id = ? AND s.enabled = 1
- ORDER BY s.priority DESC, s.start_time ASC
- `).all(device_id);
+ const schedules = db.prepare(getDeviceSchedulesQuery()).all(device_id, device_id);
const events = [];
for (const s of schedules) {
@@ -72,19 +92,43 @@ router.get('/week', (req, res) => {
// Create schedule
router.post('/', (req, res) => {
- const { device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time,
+ const { device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time,
timezone, recurrence, recurrence_end, priority, color } = req.body;
- if (!device_id || !start_time || !end_time) {
- return res.status(400).json({ error: 'device_id, start_time, and end_time required' });
+ if (!start_time || !end_time) {
+ return res.status(400).json({ error: 'start_time and end_time required' });
+ }
+
+ // Mutual exclusion: exactly one of device_id or group_id
+ if (device_id && group_id) {
+ return res.status(400).json({ error: 'Cannot set both device_id and group_id. A schedule applies to one device OR one group.' });
+ }
+ if (!device_id && !group_id) {
+ return res.status(400).json({ error: 'Either device_id or group_id is required' });
+ }
+
+ // Ownership checks
+ if (device_id) {
+ const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
+ if (!device) return res.status(404).json({ error: 'Device not found' });
+ if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+ }
+ if (group_id) {
+ const group = db.prepare('SELECT user_id FROM device_groups WHERE id = ?').get(group_id);
+ if (!group) return res.status(404).json({ error: 'Group not found' });
+ if (!['admin','superadmin'].includes(req.user.role) && group.user_id !== req.user.id) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
}
const id = uuidv4();
db.prepare(`
- INSERT INTO schedules (id, user_id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title,
+ INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title,
start_time, end_time, timezone, recurrence, recurrence_end, priority, color)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `).run(id, req.user.id, device_id, zone_id || null, content_id || null, widget_id || null,
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(id, req.user.id, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null,
layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || 'UTC',
recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6');
@@ -98,7 +142,26 @@ router.put('/:id', (req, res) => {
if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
if (!['admin','superadmin'].includes(req.user.role) && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
- const fields = ['device_id', 'zone_id', 'content_id', 'widget_id', 'layout_id', 'playlist_id', 'title',
+ // If changing target, enforce mutual exclusion
+ const newDeviceId = req.body.device_id !== undefined ? req.body.device_id : schedule.device_id;
+ const newGroupId = req.body.group_id !== undefined ? req.body.group_id : schedule.group_id;
+ if (newDeviceId && newGroupId) {
+ return res.status(400).json({ error: 'Cannot set both device_id and group_id' });
+ }
+ if (!newDeviceId && !newGroupId) {
+ return res.status(400).json({ error: 'Either device_id or group_id is required' });
+ }
+
+ // Ownership check if changing to a new group
+ if (req.body.group_id && req.body.group_id !== schedule.group_id) {
+ const group = db.prepare('SELECT user_id FROM device_groups WHERE id = ?').get(req.body.group_id);
+ if (!group) return res.status(404).json({ error: 'Group not found' });
+ if (!['admin','superadmin'].includes(req.user.role) && group.user_id !== req.user.id) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+ }
+
+ const fields = ['device_id', 'group_id', 'zone_id', 'content_id', 'widget_id', 'layout_id', 'playlist_id', 'title',
'start_time', 'end_time', 'timezone', 'recurrence', 'recurrence_end', 'priority', 'enabled', 'color'];
const updates = [];
const values = [];
@@ -106,6 +169,14 @@ router.put('/:id', (req, res) => {
if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); }
});
+ // When switching from device to group (or vice versa), null out the other field
+ if (req.body.group_id && !updates.some(u => u.startsWith('device_id'))) {
+ updates.push('device_id = ?'); values.push(null);
+ }
+ if (req.body.device_id && !updates.some(u => u.startsWith('group_id'))) {
+ updates.push('group_id = ?'); values.push(null);
+ }
+
if (updates.length > 0) {
updates.push("updated_at = strftime('%s','now')");
values.push(req.params.id);
diff --git a/server/routes/status.js b/server/routes/status.js
index dc268fb..03e39bb 100644
--- a/server/routes/status.js
+++ b/server/routes/status.js
@@ -89,7 +89,7 @@ router.get('/export', (req, res) => {
const playlistPlaceholders = playlistIds.map(() => '?').join(',') || "'__none__'";
const playlistItems = playlistIds.length ? db.prepare(`SELECT id, playlist_id, content_id, widget_id, sort_order, duration_sec FROM playlist_items WHERE playlist_id IN (${playlistPlaceholders})`).all(...playlistIds) : [];
- const schedules = db.prepare('SELECT id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at FROM schedules WHERE user_id = ?').all(userId);
+ const schedules = db.prepare('SELECT id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at FROM schedules WHERE user_id = ?').all(userId);
const videoWalls = db.prepare('SELECT * FROM video_walls WHERE user_id = ?').all(userId);
const wallIds = videoWalls.map(w => w.id);
const wallPlaceholders = wallIds.map(() => '?').join(',') || "'__none__'";
@@ -355,11 +355,13 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
// Import schedules
for (const s of (data.schedules || [])) {
- const devId = idMap.devices[s.device_id];
- if (!devId) continue;
+ const devId = s.device_id ? (idMap.devices[s.device_id] || null) : null;
+ const grpId = s.group_id ? (idMap.groups[s.group_id] || null) : null;
+ // Must have either a mapped device or group target
+ if (!devId && !grpId) continue;
const newId = uuid.v4();
const playlistId = s.playlist_id ? (idMap.playlists[s.playlist_id] || null) : null;
- db.prepare(`INSERT INTO schedules (id, user_id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, playlistId, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000));
+ db.prepare(`INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, grpId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, playlistId, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000));
stats.schedules++;
}
diff --git a/server/services/scheduler.js b/server/services/scheduler.js
index e987288..085ef26 100644
--- a/server/services/scheduler.js
+++ b/server/services/scheduler.js
@@ -23,9 +23,18 @@ function evaluateSchedules() {
const schedules = db.prepare(`
SELECT s.*
FROM schedules s
- WHERE s.device_id = ? AND s.enabled = 1
- ORDER BY s.priority DESC
- `).all(device.id);
+ WHERE s.enabled = 1
+ AND (
+ s.device_id = ?
+ OR s.group_id IN (
+ SELECT group_id FROM device_group_members WHERE device_id = ?
+ )
+ )
+ ORDER BY
+ CASE WHEN s.device_id IS NOT NULL THEN 1 ELSE 0 END DESC,
+ s.priority DESC,
+ s.created_at ASC
+ `).all(device.id, device.id);
const active = schedules.find(s => isScheduleActiveNow(s, now));
const override = activeOverrides.get(device.id);