@xiboplayer/schedule 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Schedule manager - determines which layouts to show
3
+ */
4
+
5
+ import { evaluateCriteria } from './criteria.js';
6
+
7
+ export class ScheduleManager {
8
+ constructor(options = {}) {
9
+ this.schedule = null;
10
+ this.playHistory = new Map(); // Track plays per layout: layoutId -> [timestamps]
11
+ this.interruptScheduler = options.interruptScheduler || null; // Optional interrupt scheduler
12
+ this.displayProperties = options.displayProperties || {}; // CMS display custom properties
13
+ this.playerLocation = null; // { latitude, longitude } from Geolocation API
14
+ this._layoutMetadata = new Map(); // layoutFile → { syncEvent, shareOfVoice, ... }
15
+ }
16
+
17
+ /**
18
+ * Update schedule from XMDS
19
+ */
20
+ setSchedule(schedule) {
21
+ this.schedule = schedule;
22
+ }
23
+
24
+ /**
25
+ * Get data connectors from current schedule
26
+ * @returns {Array} Data connector configurations, or empty array
27
+ */
28
+ getDataConnectors() {
29
+ return this.schedule?.dataConnectors || [];
30
+ }
31
+
32
+ /**
33
+ * Check if a schedule item is active based on recurrence rules
34
+ * Supports weekly dayparting (recurring schedules on specific days/times)
35
+ */
36
+ isRecurringScheduleActive(item, now) {
37
+ // If no recurrence, it's not a recurring schedule
38
+ if (!item.recurrenceType) {
39
+ return true; // Not a recurring schedule, use date/time checks instead
40
+ }
41
+
42
+ // Currently only support Weekly recurrence (dayparting)
43
+ if (item.recurrenceType !== 'Week') {
44
+ return true; // Unsupported recurrence type, fallback to date/time checks
45
+ }
46
+
47
+ // Check if current day of week matches recurrenceRepeatsOn
48
+ // recurrenceRepeatsOn format: "1,2,3,4,5" (1=Monday, 7=Sunday, ISO format)
49
+ if (item.recurrenceRepeatsOn) {
50
+ const currentDayOfWeek = this.getIsoDayOfWeek(now);
51
+ const allowedDays = item.recurrenceRepeatsOn.split(',').map(d => parseInt(d.trim()));
52
+
53
+ if (!allowedDays.includes(currentDayOfWeek)) {
54
+ return false; // Today is not in the allowed days
55
+ }
56
+ }
57
+
58
+ // Check recurrence range if specified
59
+ if (item.recurrenceRange) {
60
+ const rangeEnd = new Date(item.recurrenceRange);
61
+ if (now > rangeEnd) {
62
+ return false; // Recurrence has ended
63
+ }
64
+ }
65
+
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Get ISO day of week (1=Monday, 7=Sunday)
71
+ */
72
+ getIsoDayOfWeek(date) {
73
+ const day = date.getDay(); // 0=Sunday, 6=Saturday
74
+ return day === 0 ? 7 : day; // Convert to ISO (1=Monday, 7=Sunday)
75
+ }
76
+
77
+ /**
78
+ * Check if current time is within the schedule's time window
79
+ * Handles both date ranges and time-of-day for dayparting
80
+ */
81
+ isTimeActive(item, now) {
82
+ const from = item.fromdt ? new Date(item.fromdt) : null;
83
+ const to = item.todt ? new Date(item.todt) : null;
84
+
85
+ // For recurring schedules, check time-of-day instead of full datetime
86
+ if (item.recurrenceType === 'Week') {
87
+ // Extract time from fromdt/todt and compare with current time
88
+ if (from && to) {
89
+ const currentTime = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
90
+ const fromTime = from.getHours() * 3600 + from.getMinutes() * 60 + from.getSeconds();
91
+ const toTime = to.getHours() * 3600 + to.getMinutes() * 60 + to.getSeconds();
92
+
93
+ // Handle midnight crossing
94
+ if (fromTime <= toTime) {
95
+ // Normal case: 09:00 - 17:00
96
+ return currentTime >= fromTime && currentTime <= toTime;
97
+ } else {
98
+ // Midnight crossing: 22:00 - 02:00
99
+ return currentTime >= fromTime || currentTime <= toTime;
100
+ }
101
+ }
102
+ return true;
103
+ }
104
+
105
+ // For non-recurring schedules, use full date/time comparison
106
+ if (from && now < from) return false;
107
+ if (to && now > to) return false;
108
+ return true;
109
+ }
110
+
111
+ /**
112
+ * Get current layouts to display
113
+ * Returns array of layout files, prioritized
114
+ *
115
+ * Campaign behavior:
116
+ * - Priority applies at campaign level, not individual layout level
117
+ * - All layouts in a campaign share the campaign's priority
118
+ * - Layouts within a campaign are returned in order for cycling
119
+ * - Standalone layouts compete with campaigns at their own priority
120
+ *
121
+ * Dayparting behavior:
122
+ * - Schedules can recur weekly on specific days (recurrenceType='Week')
123
+ * - recurrenceRepeatsOn specifies days: "1,2,3,4,5" (Mon-Fri, ISO format)
124
+ * - Time matching uses time-of-day for recurring schedules
125
+ * - Non-recurring schedules use full date/time ranges
126
+ *
127
+ * Interrupt behavior (shareOfVoice):
128
+ * - Layouts with shareOfVoice > 0 are interrupts
129
+ * - They must play for a percentage of each hour
130
+ * - Normal layouts fill remaining time
131
+ * - Interrupts are interleaved with normal layouts
132
+ */
133
+ getCurrentLayouts() {
134
+ if (!this.schedule) {
135
+ return [];
136
+ }
137
+
138
+ const now = new Date();
139
+ const activeItems = []; // Mix of campaign objects and standalone layouts
140
+
141
+ // Track the highest priority of any time-active layout BEFORE rate-limit
142
+ // filtering. Used by advanceToNextLayout() to detect when only lower-
143
+ // priority layouts remain (all high-priority ones are rate-limited) and
144
+ // replay the current layout instead of downgrading.
145
+ this._maxActivePriority = 0;
146
+
147
+ // Find all active campaigns
148
+ if (this.schedule.campaigns) {
149
+ for (const campaign of this.schedule.campaigns) {
150
+ // Check recurrence and time window
151
+ if (!this.isRecurringScheduleActive(campaign, now)) {
152
+ continue;
153
+ }
154
+ if (!this.isTimeActive(campaign, now)) {
155
+ continue;
156
+ }
157
+
158
+ this._maxActivePriority = Math.max(this._maxActivePriority, campaign.priority || 0);
159
+
160
+ // Campaign is active - add it as a single item with its priority
161
+ activeItems.push({
162
+ type: 'campaign',
163
+ priority: campaign.priority,
164
+ layouts: campaign.layouts, // Keep full layout objects for interrupt processing
165
+ campaignId: campaign.id
166
+ });
167
+ }
168
+ }
169
+
170
+ // Find all active standalone layouts
171
+ if (this.schedule.layouts) {
172
+ for (const layout of this.schedule.layouts) {
173
+ // Check recurrence and time window
174
+ if (!this.isRecurringScheduleActive(layout, now)) {
175
+ continue;
176
+ }
177
+ if (!this.isTimeActive(layout, now)) {
178
+ continue;
179
+ }
180
+
181
+ // Check criteria conditions (date/time, display properties)
182
+ if (layout.criteria && layout.criteria.length > 0) {
183
+ if (!evaluateCriteria(layout.criteria, { now, displayProperties: this.displayProperties })) {
184
+ console.log('[Schedule] Layout', layout.id, 'filtered by criteria');
185
+ continue;
186
+ }
187
+ }
188
+
189
+ // Check geo-fencing
190
+ if (layout.isGeoAware && layout.geoLocation) {
191
+ if (!this.isWithinGeoFence(layout.geoLocation)) {
192
+ console.log('[Schedule] Layout', layout.id, 'filtered by geofence');
193
+ continue;
194
+ }
195
+ }
196
+
197
+ // Track priority before rate-limit filtering
198
+ this._maxActivePriority = Math.max(this._maxActivePriority, layout.priority || 0);
199
+
200
+ // Check max plays per hour - but track that we filtered it
201
+ if (!this.canPlayLayout(layout.id, layout.maxPlaysPerHour)) {
202
+ console.log('[Schedule] Layout', layout.id, 'filtered by maxPlaysPerHour (limit:', layout.maxPlaysPerHour, ')');
203
+ // Continue to check other layouts, but don't add this one
204
+ continue;
205
+ }
206
+
207
+ activeItems.push({
208
+ type: 'layout',
209
+ priority: layout.priority || 0,
210
+ layouts: [layout], // Keep full layout object for interrupt processing
211
+ layoutId: layout.id
212
+ });
213
+ }
214
+ }
215
+
216
+ // If no active schedules, return default
217
+ if (activeItems.length === 0) {
218
+ return this.schedule.default ? [this.schedule.default] : [];
219
+ }
220
+
221
+ // Find maximum priority across all items (campaigns and layouts)
222
+ let maxPriority = Math.max(...activeItems.map(item => item.priority));
223
+ console.log('[Schedule] Max priority:', maxPriority, 'from', activeItems.length, 'active items');
224
+
225
+ // Collect all layouts from items with max priority
226
+ let allLayouts = [];
227
+ for (const item of activeItems) {
228
+ if (item.priority === maxPriority) {
229
+ console.log('[Schedule] Including priority', item.priority, 'layouts:', item.layouts.map(l => l.file));
230
+ // Add all layouts from this campaign or standalone layout
231
+ allLayouts.push(...item.layouts);
232
+ } else {
233
+ console.log('[Schedule] Skipping priority', item.priority, '< max', maxPriority);
234
+ }
235
+ }
236
+
237
+ // Build layout metadata map (syncEvent, shareOfVoice, etc.)
238
+ this._layoutMetadata.clear();
239
+ for (const layout of allLayouts) {
240
+ this._layoutMetadata.set(layout.file, {
241
+ syncEvent: layout.syncEvent || false,
242
+ shareOfVoice: layout.shareOfVoice || 0,
243
+ scheduleid: layout.scheduleid,
244
+ priority: layout.priority || 0,
245
+ });
246
+ }
247
+
248
+ // Process interrupts if interrupt scheduler is available
249
+ if (this.interruptScheduler) {
250
+ const { normalLayouts, interruptLayouts } = this.interruptScheduler.separateLayouts(allLayouts);
251
+
252
+ if (interruptLayouts.length > 0) {
253
+ console.log('[Schedule] Found', interruptLayouts.length, 'interrupt layouts with shareOfVoice');
254
+ const processedLayouts = this.interruptScheduler.processInterrupts(normalLayouts, interruptLayouts);
255
+ // Extract file IDs from processed layouts
256
+ const result = processedLayouts.map(l => l.file);
257
+ console.log('[Schedule] Final layouts (with interrupts):', result);
258
+ return result;
259
+ }
260
+ }
261
+
262
+ // No interrupts, return layout files
263
+ const result = allLayouts.map(l => l.file);
264
+ console.log('[Schedule] Final layouts:', result);
265
+ return result;
266
+ }
267
+
268
+ /**
269
+ * Check if schedule needs update (every minute)
270
+ */
271
+ shouldCheckSchedule(lastCheck) {
272
+ if (!lastCheck) return true;
273
+ const elapsed = Date.now() - lastCheck;
274
+ return elapsed >= 60000; // 1 minute
275
+ }
276
+
277
+ /**
278
+ * Check if layout can play based on maxPlaysPerHour with even distribution.
279
+ *
280
+ * Instead of allowing bursts (3 plays back-to-back then nothing for 50 min),
281
+ * plays are distributed evenly across the hour:
282
+ * maxPlaysPerHour=3 → minimum 20 min gap between plays
283
+ * maxPlaysPerHour=6 → minimum 10 min gap between plays
284
+ *
285
+ * Two checks:
286
+ * 1. Total plays in sliding 1-hour window < maxPlaysPerHour
287
+ * 2. Time since last play >= (60 / maxPlaysPerHour) minutes
288
+ *
289
+ * @param {string} layoutId - Layout ID to check
290
+ * @param {number} maxPlaysPerHour - Maximum plays allowed per hour (0 = unlimited)
291
+ * @returns {boolean} True if layout can play, false if exceeded limit
292
+ */
293
+ canPlayLayout(layoutId, maxPlaysPerHour) {
294
+ // If maxPlaysPerHour is 0 or undefined, unlimited plays
295
+ if (!maxPlaysPerHour || maxPlaysPerHour === 0) {
296
+ return true;
297
+ }
298
+
299
+ const now = Date.now();
300
+ const oneHourAgo = now - (60 * 60 * 1000);
301
+
302
+ // Get play history for this layout
303
+ const history = this.playHistory.get(layoutId) || [];
304
+
305
+ // Filter to plays within the last hour
306
+ const playsInLastHour = history.filter(timestamp => timestamp > oneHourAgo);
307
+
308
+ // Check 1: Total plays in last hour must be under limit
309
+ if (playsInLastHour.length >= maxPlaysPerHour) {
310
+ console.log(`[Schedule] Layout ${layoutId} has reached max plays per hour (${playsInLastHour.length}/${maxPlaysPerHour})`);
311
+ return false;
312
+ }
313
+
314
+ // Check 2: Minimum gap between plays for even distribution
315
+ // e.g., 3/hour → 1 every 20 min, 6/hour → 1 every 10 min
316
+ if (playsInLastHour.length > 0) {
317
+ const minGapMs = (60 * 60 * 1000) / maxPlaysPerHour;
318
+ const lastPlayTime = Math.max(...playsInLastHour);
319
+ const elapsed = now - lastPlayTime;
320
+
321
+ if (elapsed < minGapMs) {
322
+ const remainingMin = ((minGapMs - elapsed) / 60000).toFixed(1);
323
+ console.log(`[Schedule] Layout ${layoutId} spacing: next play in ${remainingMin} min (${playsInLastHour.length}/${maxPlaysPerHour} plays, ${Math.round(minGapMs/60000)} min gap)`);
324
+ return false;
325
+ }
326
+ }
327
+
328
+ return true;
329
+ }
330
+
331
+ /**
332
+ * Record that a layout was played
333
+ * @param {string} layoutId - Layout ID that was played
334
+ */
335
+ recordPlay(layoutId) {
336
+ if (!this.playHistory.has(layoutId)) {
337
+ this.playHistory.set(layoutId, []);
338
+ }
339
+
340
+ const history = this.playHistory.get(layoutId);
341
+ history.push(Date.now());
342
+
343
+ // Clean up old entries (older than 1 hour)
344
+ const oneHourAgo = Date.now() - (60 * 60 * 1000);
345
+ const cleaned = history.filter(timestamp => timestamp > oneHourAgo);
346
+ this.playHistory.set(layoutId, cleaned);
347
+
348
+ console.log(`[Schedule] Recorded play for layout ${layoutId} (${cleaned.length} plays in last hour)`);
349
+ }
350
+
351
+ /**
352
+ * Get the max priority of any time-active layout (ignoring rate-limit filtering).
353
+ * Returns 0 if no layouts are active or if getCurrentLayouts() hasn't been called.
354
+ * @returns {number}
355
+ */
356
+ getMaxActivePriority() {
357
+ return this._maxActivePriority || 0;
358
+ }
359
+
360
+ /**
361
+ * Check if a layout file is a sync event (part of multi-display sync group)
362
+ * @param {string} layoutFile - Layout file identifier (e.g., '123')
363
+ * @returns {boolean}
364
+ */
365
+ isSyncEvent(layoutFile) {
366
+ const meta = this._layoutMetadata.get(layoutFile);
367
+ return meta?.syncEvent === true;
368
+ }
369
+
370
+ /**
371
+ * Get metadata for a layout file (syncEvent, shareOfVoice, etc.)
372
+ * @param {string} layoutFile - Layout file identifier
373
+ * @returns {Object|null} Metadata or null if not found
374
+ */
375
+ getLayoutMetadata(layoutFile) {
376
+ return this._layoutMetadata.get(layoutFile) || null;
377
+ }
378
+
379
+ /**
380
+ * Check if any current layouts are sync events
381
+ * @returns {boolean}
382
+ */
383
+ hasSyncEvents() {
384
+ for (const meta of this._layoutMetadata.values()) {
385
+ if (meta.syncEvent) return true;
386
+ }
387
+ return false;
388
+ }
389
+
390
+ /**
391
+ * Get currently active actions (within their time window)
392
+ * @returns {Array} Active action objects
393
+ */
394
+ getActiveActions() {
395
+ if (!this.schedule?.actions) return [];
396
+
397
+ const now = new Date();
398
+ return this.schedule.actions.filter(action => this.isTimeActive(action, now));
399
+ }
400
+
401
+ /**
402
+ * Get scheduled commands
403
+ * @returns {Array} Command objects
404
+ */
405
+ getCommands() {
406
+ return this.schedule?.commands || [];
407
+ }
408
+
409
+ /**
410
+ * Find action by trigger code
411
+ * @param {string} triggerCode - The trigger code to match
412
+ * @returns {Object|null} Matching action or null
413
+ */
414
+ findActionByTrigger(triggerCode) {
415
+ const activeActions = this.getActiveActions();
416
+ return activeActions.find(a => a.triggerCode === triggerCode) || null;
417
+ }
418
+
419
+ /**
420
+ * Clear play history (useful for testing or reset)
421
+ */
422
+ clearPlayHistory() {
423
+ this.playHistory.clear();
424
+ console.log('[Schedule] Play history cleared');
425
+ }
426
+
427
+ /**
428
+ * Set player's current GPS location (from Geolocation API or XMR command)
429
+ * @param {number} latitude
430
+ * @param {number} longitude
431
+ */
432
+ setLocation(latitude, longitude) {
433
+ this.playerLocation = { latitude, longitude };
434
+ console.log(`[Schedule] Location set: ${latitude}, ${longitude}`);
435
+ }
436
+
437
+ /**
438
+ * Set display properties from CMS (custom fields for criteria evaluation)
439
+ * @param {Object} properties - Key-value map of display properties
440
+ */
441
+ setDisplayProperties(properties) {
442
+ this.displayProperties = properties || {};
443
+ }
444
+
445
+ /**
446
+ * Check if player is within a geo-fence.
447
+ * geoLocation format from CMS: "lat,lng" (point + default radius)
448
+ * or "lat1,lng1;lat2,lng2;..." (polygon — future)
449
+ *
450
+ * Default radius: 500 meters (Xibo default for point geofences)
451
+ *
452
+ * @param {string} geoLocation - Geo-fence specification from CMS
453
+ * @param {number} [defaultRadius=500] - Default radius in meters for point geofences
454
+ * @returns {boolean} True if within geofence or no location available
455
+ */
456
+ isWithinGeoFence(geoLocation, defaultRadius = 500) {
457
+ if (!this.playerLocation) {
458
+ // No location available — be permissive, show the content
459
+ console.log('[Schedule] No player location, skipping geofence check');
460
+ return true;
461
+ }
462
+
463
+ if (!geoLocation) return true;
464
+
465
+ // Parse "lat,lng" format
466
+ const parts = geoLocation.split(',').map(s => parseFloat(s.trim()));
467
+ if (parts.length < 2 || isNaN(parts[0]) || isNaN(parts[1])) {
468
+ console.log('[Schedule] Invalid geoLocation format:', geoLocation);
469
+ return true; // Invalid format, be permissive
470
+ }
471
+
472
+ const fenceLat = parts[0];
473
+ const fenceLng = parts[1];
474
+ const radius = parts[2] || defaultRadius; // Optional 3rd param: radius in meters
475
+
476
+ const distance = this.haversineDistance(
477
+ this.playerLocation.latitude, this.playerLocation.longitude,
478
+ fenceLat, fenceLng
479
+ );
480
+
481
+ const within = distance <= radius;
482
+ console.log(`[Schedule] Geofence: ${distance.toFixed(0)}m from (${fenceLat},${fenceLng}), radius ${radius}m → ${within ? 'WITHIN' : 'OUTSIDE'}`);
483
+ return within;
484
+ }
485
+
486
+ /**
487
+ * Haversine formula: calculate distance between two GPS coordinates
488
+ * @param {number} lat1 - Latitude 1 (degrees)
489
+ * @param {number} lon1 - Longitude 1 (degrees)
490
+ * @param {number} lat2 - Latitude 2 (degrees)
491
+ * @param {number} lon2 - Longitude 2 (degrees)
492
+ * @returns {number} Distance in meters
493
+ */
494
+ haversineDistance(lat1, lon1, lat2, lon2) {
495
+ const R = 6371000; // Earth radius in meters
496
+ const toRad = deg => deg * Math.PI / 180;
497
+
498
+ const dLat = toRad(lat2 - lat1);
499
+ const dLon = toRad(lon2 - lon1);
500
+
501
+ const a = Math.sin(dLat / 2) ** 2 +
502
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
503
+ Math.sin(dLon / 2) ** 2;
504
+
505
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
506
+ }
507
+ }
508
+
509
+ export const scheduleManager = new ScheduleManager();