screentinker/server/services/scheduler.js
ScreenTinker 52dd44a3e8 Add group-level scheduling, group playlist assignment, and persist audio unlock
Phase 4 group scheduling: schema migration adds group_id to schedules with
CHECK constraint, scheduler evaluates group+device schedules with priority,
group deletion converts schedules to per-device copies. Dashboard gets
playlist assignment dropdown and current playlist label on group headers.
Player persists audio unlock state in localStorage so version reloads
don't lose audio on unattended displays.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:22:42 -05:00

114 lines
3.9 KiB
JavaScript

const { db } = require('../db/database');
let io = null;
function startScheduler(socketIo) {
io = socketIo;
// Check schedules every 60 seconds
setInterval(evaluateSchedules, 60000);
console.log('Scheduler service started');
}
// Track which devices have a schedule override active so we can revert
const activeOverrides = new Map(); // deviceId -> { playlist_id, layout_id }
function evaluateSchedules() {
const deviceNs = io?.of('/device');
if (!deviceNs) return;
const now = new Date();
const onlineDevices = db.prepare("SELECT * FROM devices WHERE status = 'online'").all();
for (const device of onlineDevices) {
const schedules = db.prepare(`
SELECT s.*
FROM schedules s
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);
let changed = false;
if (active) {
// Apply layout override if schedule has one
if (active.layout_id && active.layout_id !== device.layout_id) {
if (!override) activeOverrides.set(device.id, { layout_id: device.layout_id, playlist_id: device.playlist_id });
db.prepare("UPDATE devices SET layout_id = ? WHERE id = ?").run(active.layout_id, device.id);
changed = true;
}
// Apply playlist override if schedule has one
if (active.playlist_id && active.playlist_id !== device.playlist_id) {
if (!override) activeOverrides.set(device.id, { layout_id: device.layout_id, playlist_id: device.playlist_id });
db.prepare("UPDATE devices SET playlist_id = ? WHERE id = ?").run(active.playlist_id, device.id);
changed = true;
}
} else if (override) {
// No active schedule — revert to original playlist/layout
db.prepare("UPDATE devices SET playlist_id = ?, layout_id = ? WHERE id = ?")
.run(override.playlist_id, override.layout_id, device.id);
activeOverrides.delete(device.id);
changed = true;
}
if (changed) pushPlaylistToDevice(device.id, deviceNs);
}
}
function isScheduleActiveNow(schedule, now) {
const start = new Date(schedule.start_time);
const end = new Date(schedule.end_time);
if (!schedule.recurrence) {
return now >= start && now <= end;
}
// For recurring schedules, check if current time-of-day falls within range
// and current day matches recurrence pattern
const rule = parseSimpleRRule(schedule.recurrence);
if (!rule) return now >= start && now <= end;
// Check day of week
if (rule.byDay && !rule.byDay.includes(now.getDay())) return false;
// Check time of day
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
return nowMinutes >= startMinutes && nowMinutes <= endMinutes;
}
function parseSimpleRRule(rrule) {
if (!rrule) return null;
const parts = rrule.split(';');
const rule = {};
const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
for (const part of parts) {
const [key, val] = part.split('=');
if (key === 'FREQ') rule.freq = val;
if (key === 'BYDAY') rule.byDay = val.split(',').map(d => dayMap[d]).filter(d => d !== undefined);
if (key === 'INTERVAL') rule.interval = parseInt(val);
}
return rule;
}
function pushPlaylistToDevice(deviceId, deviceNs) {
// Use the single-source buildPlaylistPayload from deviceSocket
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const payload = buildPlaylistPayload(deviceId);
deviceNs.to(deviceId).emit('device:playlist-update', payload);
}
module.exports = { startScheduler, pushPlaylistToDevice };