mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Short-lived per-device queue covers the TV-flap window (issue #3): when a device is mid-reconnect, prior code emitted to an empty room and the event vanished. Now playlist-updates and commands targeting an offline device are queued and flushed in order on the next device:register for that device_id. server/lib/command-queue.js (new): - pendingPlaylistUpdate: per-device marker (rebuild via builder on flush -> always fresh DB state, no stale snapshots) - pendingCommands: per-device Map<type, payload> with last-of-type dedup (most recent screen_off wins) - TTL via COMMAND_QUEUE_TTL_MS env (default 30000) - Active sweep every 30s prunes expired entries Memory bounds: ~6 entries per device worst case (1 playlist marker + 5 command types), unref'd sweep timer. Wired emit sites (8 total; the four direct socket.emit calls in deviceSocket register handlers are intentionally NOT queued because the socket is alive by definition at those points): - server/routes/video-walls.js (pushWallPayloadToDevice) - server/routes/device-groups.js (pushPlaylistToDevice) - server/routes/content.js (content-delete fan-out) - server/routes/playlists.js (pushToDevices + assign) - server/services/scheduler.js (scheduled rotations) - server/ws/deviceSocket.js x2 (wall leader reclaim/reassign) server/ws/deviceSocket.js register paths now call flushQueue after heartbeat.registerConnection + socket.join. Existing socket.emit('device:playlist-update', ...) lines kept - they send the initial state on register; the flush replays any queued events. Player's handlePlaylistUpdate fingerprint check dedupes the overlap. Refs #3 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
114 lines
4 KiB
JavaScript
114 lines
4 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 commandQueue = require('../lib/command-queue');
|
|
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, deviceId, buildPlaylistPayload);
|
|
}
|
|
|
|
module.exports = { startScheduler, pushPlaylistToDevice };
|