@xiboplayer/schedule 0.1.3 → 0.3.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.
- package/README.md +40 -0
- package/package.json +2 -2
- package/src/index.js +8 -0
- package/src/schedule.js +100 -22
- package/src/timeline.js +245 -0
- package/docs/README.md +0 -102
- package/docs/advanced-features.md +0 -425
- package/docs/integration.md +0 -284
- package/docs/interrupts-implementation.md +0 -344
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @xiboplayer/schedule
|
|
2
|
+
|
|
3
|
+
**Campaign scheduling with dayparting, interrupts, overlays, and timeline prediction.**
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Complete scheduling solution for Xibo digital signage:
|
|
8
|
+
|
|
9
|
+
- **Campaign scheduling** — priority-based campaign rotation with configurable play counts
|
|
10
|
+
- **Dayparting** — weekly time slots with midnight-crossing support
|
|
11
|
+
- **Interrupts** — percentage-based share-of-voice scheduling with even interleaving across the hour
|
|
12
|
+
- **Overlays** — multiple simultaneous overlay layouts with independent scheduling and priority
|
|
13
|
+
- **Geo-fencing** — location-based schedule filtering with criteria evaluation
|
|
14
|
+
- **Timeline prediction** — deterministic future schedule simulation for proactive content preloading
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @xiboplayer/schedule
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```javascript
|
|
25
|
+
import { Schedule } from '@xiboplayer/schedule';
|
|
26
|
+
|
|
27
|
+
const schedule = new Schedule();
|
|
28
|
+
schedule.update(scheduleXml);
|
|
29
|
+
|
|
30
|
+
const currentLayouts = schedule.getCurrentLayouts();
|
|
31
|
+
const timeline = schedule.getTimeline(now, now + 3600000); // next hour
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Dependencies
|
|
35
|
+
|
|
36
|
+
- `@xiboplayer/utils` — logger, events
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
**Part of the [XiboPlayer SDK](https://github.com/linuxnow/xiboplayer)**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/schedule",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Complete scheduling solution: campaigns, dayparting, interrupts, and overlays",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"./overlays": "./src/overlays.js"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@xiboplayer/utils": "0.
|
|
14
|
+
"@xiboplayer/utils": "0.3.0"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"vitest": "^2.0.0"
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// @xiboplayer/schedule - Campaign scheduling and advanced features
|
|
2
2
|
// Basic scheduling, interrupts, overlays, and dayparting
|
|
3
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
4
|
+
export const VERSION = pkg.version;
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Core schedule manager for basic scheduling and dayparting
|
|
@@ -18,3 +20,9 @@ export { InterruptScheduler } from './interrupts.js';
|
|
|
18
20
|
* @module @xiboplayer/schedule/overlays
|
|
19
21
|
*/
|
|
20
22
|
export { OverlayScheduler } from './overlays.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Offline timeline calculator — duration parser + timeline simulator
|
|
26
|
+
* @module @xiboplayer/schedule/timeline
|
|
27
|
+
*/
|
|
28
|
+
export { calculateTimeline, parseLayoutDuration } from './timeline.js';
|
package/src/schedule.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Schedule manager - determines which layouts to show
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
5
6
|
import { evaluateCriteria } from './criteria.js';
|
|
6
7
|
|
|
8
|
+
const log = createLogger('Schedule');
|
|
9
|
+
|
|
7
10
|
export class ScheduleManager {
|
|
8
11
|
constructor(options = {}) {
|
|
9
12
|
this.schedule = null;
|
|
@@ -131,11 +134,86 @@ export class ScheduleManager {
|
|
|
131
134
|
* - Interrupts are interleaved with normal layouts
|
|
132
135
|
*/
|
|
133
136
|
getCurrentLayouts() {
|
|
137
|
+
return this._getLayoutsAt(new Date());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get layouts active at a specific time.
|
|
142
|
+
* Skips rate limiting and interrupt processing (those depend on real-time state).
|
|
143
|
+
* Used by timeline calculator to predict future playback.
|
|
144
|
+
* @param {Date} time - The time to evaluate
|
|
145
|
+
* @returns {string[]} Layout files active at that time
|
|
146
|
+
*/
|
|
147
|
+
getLayoutsAtTime(time) {
|
|
148
|
+
return this._getLayoutsAt(time, { skipRateLimiting: true, skipInterrupts: true, quiet: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get ALL time-active layouts with metadata, without priority or rate-limit filtering.
|
|
153
|
+
* Used by calculateTimeline() to simulate real playback with rate limiting and
|
|
154
|
+
* priority fallback (e.g., when high-priority layouts hit maxPlaysPerHour, lower
|
|
155
|
+
* priority layouts fill the gap).
|
|
156
|
+
*
|
|
157
|
+
* @param {Date} time - The time to evaluate
|
|
158
|
+
* @returns {Array<{file: string, priority: number, maxPlaysPerHour: number}>}
|
|
159
|
+
*/
|
|
160
|
+
getAllLayoutsAtTime(time) {
|
|
161
|
+
if (!this.schedule) return [];
|
|
162
|
+
|
|
163
|
+
const now = time;
|
|
164
|
+
const results = [];
|
|
165
|
+
|
|
166
|
+
// Standalone layouts
|
|
167
|
+
if (this.schedule.layouts) {
|
|
168
|
+
for (const layout of this.schedule.layouts) {
|
|
169
|
+
if (!this.isRecurringScheduleActive(layout, now)) continue;
|
|
170
|
+
if (!this.isTimeActive(layout, now)) continue;
|
|
171
|
+
if (layout.criteria && layout.criteria.length > 0) {
|
|
172
|
+
if (!evaluateCriteria(layout.criteria, { now, displayProperties: this.displayProperties })) continue;
|
|
173
|
+
}
|
|
174
|
+
if (layout.isGeoAware && layout.geoLocation) {
|
|
175
|
+
if (!this.isWithinGeoFence(layout.geoLocation)) continue;
|
|
176
|
+
}
|
|
177
|
+
results.push({
|
|
178
|
+
file: layout.file,
|
|
179
|
+
priority: layout.priority || 0,
|
|
180
|
+
maxPlaysPerHour: layout.maxPlaysPerHour || 0,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Campaign layouts
|
|
186
|
+
if (this.schedule.campaigns) {
|
|
187
|
+
for (const campaign of this.schedule.campaigns) {
|
|
188
|
+
if (!this.isRecurringScheduleActive(campaign, now)) continue;
|
|
189
|
+
if (!this.isTimeActive(campaign, now)) continue;
|
|
190
|
+
for (const layout of campaign.layouts) {
|
|
191
|
+
results.push({
|
|
192
|
+
file: layout.file,
|
|
193
|
+
priority: campaign.priority || 0,
|
|
194
|
+
maxPlaysPerHour: layout.maxPlaysPerHour || 0,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return results;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Internal: evaluate schedule at a given time.
|
|
205
|
+
* @param {Date} now - Time to evaluate
|
|
206
|
+
* @param {Object} [options] - Options
|
|
207
|
+
* @param {boolean} [options.skipRateLimiting] - Skip maxPlaysPerHour checks
|
|
208
|
+
* @param {boolean} [options.skipInterrupts] - Skip interrupt/shareOfVoice processing
|
|
209
|
+
*/
|
|
210
|
+
_getLayoutsAt(now, options = {}) {
|
|
134
211
|
if (!this.schedule) {
|
|
135
212
|
return [];
|
|
136
213
|
}
|
|
137
214
|
|
|
138
|
-
const
|
|
215
|
+
const { skipRateLimiting = false, skipInterrupts = false, quiet = false } = options;
|
|
216
|
+
const _log = quiet ? () => {} : (...args) => log.info(...args);
|
|
139
217
|
const activeItems = []; // Mix of campaign objects and standalone layouts
|
|
140
218
|
|
|
141
219
|
// Track the highest priority of any time-active layout BEFORE rate-limit
|
|
@@ -181,7 +259,7 @@ export class ScheduleManager {
|
|
|
181
259
|
// Check criteria conditions (date/time, display properties)
|
|
182
260
|
if (layout.criteria && layout.criteria.length > 0) {
|
|
183
261
|
if (!evaluateCriteria(layout.criteria, { now, displayProperties: this.displayProperties })) {
|
|
184
|
-
|
|
262
|
+
_log('[Schedule] Layout', layout.id, 'filtered by criteria');
|
|
185
263
|
continue;
|
|
186
264
|
}
|
|
187
265
|
}
|
|
@@ -189,7 +267,7 @@ export class ScheduleManager {
|
|
|
189
267
|
// Check geo-fencing
|
|
190
268
|
if (layout.isGeoAware && layout.geoLocation) {
|
|
191
269
|
if (!this.isWithinGeoFence(layout.geoLocation)) {
|
|
192
|
-
|
|
270
|
+
_log('[Schedule] Layout', layout.id, 'filtered by geofence');
|
|
193
271
|
continue;
|
|
194
272
|
}
|
|
195
273
|
}
|
|
@@ -197,9 +275,9 @@ export class ScheduleManager {
|
|
|
197
275
|
// Track priority before rate-limit filtering
|
|
198
276
|
this._maxActivePriority = Math.max(this._maxActivePriority, layout.priority || 0);
|
|
199
277
|
|
|
200
|
-
// Check max plays per hour
|
|
201
|
-
if (!this.canPlayLayout(layout.id, layout.maxPlaysPerHour)) {
|
|
202
|
-
|
|
278
|
+
// Check max plays per hour (skip for future time queries)
|
|
279
|
+
if (!skipRateLimiting && !this.canPlayLayout(layout.id, layout.maxPlaysPerHour)) {
|
|
280
|
+
_log('[Schedule] Layout', layout.id, 'filtered by maxPlaysPerHour (limit:', layout.maxPlaysPerHour, ')');
|
|
203
281
|
// Continue to check other layouts, but don't add this one
|
|
204
282
|
continue;
|
|
205
283
|
}
|
|
@@ -220,17 +298,17 @@ export class ScheduleManager {
|
|
|
220
298
|
|
|
221
299
|
// Find maximum priority across all items (campaigns and layouts)
|
|
222
300
|
let maxPriority = Math.max(...activeItems.map(item => item.priority));
|
|
223
|
-
|
|
301
|
+
_log('[Schedule] Max priority:', maxPriority, 'from', activeItems.length, 'active items');
|
|
224
302
|
|
|
225
303
|
// Collect all layouts from items with max priority
|
|
226
304
|
let allLayouts = [];
|
|
227
305
|
for (const item of activeItems) {
|
|
228
306
|
if (item.priority === maxPriority) {
|
|
229
|
-
|
|
307
|
+
_log('[Schedule] Including priority', item.priority, 'layouts:', item.layouts.map(l => l.file));
|
|
230
308
|
// Add all layouts from this campaign or standalone layout
|
|
231
309
|
allLayouts.push(...item.layouts);
|
|
232
310
|
} else {
|
|
233
|
-
|
|
311
|
+
_log('[Schedule] Skipping priority', item.priority, '< max', maxPriority);
|
|
234
312
|
}
|
|
235
313
|
}
|
|
236
314
|
|
|
@@ -245,23 +323,23 @@ export class ScheduleManager {
|
|
|
245
323
|
});
|
|
246
324
|
}
|
|
247
325
|
|
|
248
|
-
// Process interrupts if interrupt scheduler is available
|
|
249
|
-
if (this.interruptScheduler) {
|
|
326
|
+
// Process interrupts if interrupt scheduler is available (skip for future time queries)
|
|
327
|
+
if (!skipInterrupts && this.interruptScheduler) {
|
|
250
328
|
const { normalLayouts, interruptLayouts } = this.interruptScheduler.separateLayouts(allLayouts);
|
|
251
329
|
|
|
252
330
|
if (interruptLayouts.length > 0) {
|
|
253
|
-
|
|
331
|
+
_log('[Schedule] Found', interruptLayouts.length, 'interrupt layouts with shareOfVoice');
|
|
254
332
|
const processedLayouts = this.interruptScheduler.processInterrupts(normalLayouts, interruptLayouts);
|
|
255
333
|
// Extract file IDs from processed layouts
|
|
256
334
|
const result = processedLayouts.map(l => l.file);
|
|
257
|
-
|
|
335
|
+
_log('[Schedule] Final layouts (with interrupts):', result);
|
|
258
336
|
return result;
|
|
259
337
|
}
|
|
260
338
|
}
|
|
261
339
|
|
|
262
340
|
// No interrupts, return layout files
|
|
263
341
|
const result = allLayouts.map(l => l.file);
|
|
264
|
-
|
|
342
|
+
_log('[Schedule] Final layouts:', result);
|
|
265
343
|
return result;
|
|
266
344
|
}
|
|
267
345
|
|
|
@@ -307,7 +385,7 @@ export class ScheduleManager {
|
|
|
307
385
|
|
|
308
386
|
// Check 1: Total plays in last hour must be under limit
|
|
309
387
|
if (playsInLastHour.length >= maxPlaysPerHour) {
|
|
310
|
-
|
|
388
|
+
log.info(`Layout ${layoutId} has reached max plays per hour (${playsInLastHour.length}/${maxPlaysPerHour})`);
|
|
311
389
|
return false;
|
|
312
390
|
}
|
|
313
391
|
|
|
@@ -320,7 +398,7 @@ export class ScheduleManager {
|
|
|
320
398
|
|
|
321
399
|
if (elapsed < minGapMs) {
|
|
322
400
|
const remainingMin = ((minGapMs - elapsed) / 60000).toFixed(1);
|
|
323
|
-
|
|
401
|
+
log.info(`Layout ${layoutId} spacing: next play in ${remainingMin} min (${playsInLastHour.length}/${maxPlaysPerHour} plays, ${Math.round(minGapMs/60000)} min gap)`);
|
|
324
402
|
return false;
|
|
325
403
|
}
|
|
326
404
|
}
|
|
@@ -345,7 +423,7 @@ export class ScheduleManager {
|
|
|
345
423
|
const cleaned = history.filter(timestamp => timestamp > oneHourAgo);
|
|
346
424
|
this.playHistory.set(layoutId, cleaned);
|
|
347
425
|
|
|
348
|
-
|
|
426
|
+
log.info(`Recorded play for layout ${layoutId} (${cleaned.length} plays in last hour)`);
|
|
349
427
|
}
|
|
350
428
|
|
|
351
429
|
/**
|
|
@@ -421,7 +499,7 @@ export class ScheduleManager {
|
|
|
421
499
|
*/
|
|
422
500
|
clearPlayHistory() {
|
|
423
501
|
this.playHistory.clear();
|
|
424
|
-
|
|
502
|
+
log.info('Play history cleared');
|
|
425
503
|
}
|
|
426
504
|
|
|
427
505
|
/**
|
|
@@ -431,7 +509,7 @@ export class ScheduleManager {
|
|
|
431
509
|
*/
|
|
432
510
|
setLocation(latitude, longitude) {
|
|
433
511
|
this.playerLocation = { latitude, longitude };
|
|
434
|
-
|
|
512
|
+
log.info(`Location set: ${latitude}, ${longitude}`);
|
|
435
513
|
}
|
|
436
514
|
|
|
437
515
|
/**
|
|
@@ -456,7 +534,7 @@ export class ScheduleManager {
|
|
|
456
534
|
isWithinGeoFence(geoLocation, defaultRadius = 500) {
|
|
457
535
|
if (!this.playerLocation) {
|
|
458
536
|
// No location available — be permissive, show the content
|
|
459
|
-
|
|
537
|
+
log.debug('No player location, skipping geofence check');
|
|
460
538
|
return true;
|
|
461
539
|
}
|
|
462
540
|
|
|
@@ -465,7 +543,7 @@ export class ScheduleManager {
|
|
|
465
543
|
// Parse "lat,lng" format
|
|
466
544
|
const parts = geoLocation.split(',').map(s => parseFloat(s.trim()));
|
|
467
545
|
if (parts.length < 2 || isNaN(parts[0]) || isNaN(parts[1])) {
|
|
468
|
-
|
|
546
|
+
log.warn('Invalid geoLocation format:', geoLocation);
|
|
469
547
|
return true; // Invalid format, be permissive
|
|
470
548
|
}
|
|
471
549
|
|
|
@@ -479,7 +557,7 @@ export class ScheduleManager {
|
|
|
479
557
|
);
|
|
480
558
|
|
|
481
559
|
const within = distance <= radius;
|
|
482
|
-
|
|
560
|
+
log.info(`Geofence: ${distance.toFixed(0)}m from (${fenceLat},${fenceLng}), radius ${radius}m → ${within ? 'WITHIN' : 'OUTSIDE'}`);
|
|
483
561
|
return within;
|
|
484
562
|
}
|
|
485
563
|
|
package/src/timeline.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline Schedule Timeline Calculator
|
|
3
|
+
*
|
|
4
|
+
* Calculates deterministic playback timelines by parsing layout XLF durations
|
|
5
|
+
* and simulating round-robin scheduling. Enables the player to answer
|
|
6
|
+
* "what's the playback plan for the next N hours?" while offline.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse layout duration from XLF XML string.
|
|
11
|
+
* Lightweight parser — uses DOMParser, no rendering.
|
|
12
|
+
*
|
|
13
|
+
* Duration resolution order:
|
|
14
|
+
* 1. Explicit <layout duration="60"> attribute
|
|
15
|
+
* 2. Sum of widget <media duration="X"> per region (max across regions)
|
|
16
|
+
* 3. Fallback: 60s
|
|
17
|
+
*
|
|
18
|
+
* @param {string} xlfXml - Raw XLF XML string
|
|
19
|
+
* @returns {number} Duration in seconds
|
|
20
|
+
*/
|
|
21
|
+
export function parseLayoutDuration(xlfXml) {
|
|
22
|
+
const doc = new DOMParser().parseFromString(xlfXml, 'text/xml');
|
|
23
|
+
const layoutEl = doc.querySelector('layout');
|
|
24
|
+
if (!layoutEl) return 60;
|
|
25
|
+
|
|
26
|
+
// 1. Explicit layout duration attribute
|
|
27
|
+
const explicit = parseInt(layoutEl.getAttribute('duration') || '0', 10);
|
|
28
|
+
if (explicit > 0) return explicit;
|
|
29
|
+
|
|
30
|
+
// 2. Calculate from widget durations (max region wins — regions play in parallel)
|
|
31
|
+
let maxDuration = 0;
|
|
32
|
+
for (const regionEl of layoutEl.querySelectorAll('region')) {
|
|
33
|
+
let regionDuration = 0;
|
|
34
|
+
for (const mediaEl of regionEl.querySelectorAll('media')) {
|
|
35
|
+
const dur = parseInt(mediaEl.getAttribute('duration') || '0', 10);
|
|
36
|
+
const useDuration = parseInt(mediaEl.getAttribute('useDuration') || '1', 10);
|
|
37
|
+
if (dur > 0 && useDuration !== 0) {
|
|
38
|
+
regionDuration += dur;
|
|
39
|
+
} else {
|
|
40
|
+
// Video with useDuration=0 means "play to end" — estimate 60s,
|
|
41
|
+
// corrected later via recordLayoutDuration() when video metadata loads
|
|
42
|
+
regionDuration += 60;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
maxDuration = Math.max(maxDuration, regionDuration);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return maxDuration > 0 ? maxDuration : 60;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compare two arrays of layout files for equality.
|
|
53
|
+
* @param {string[]} a
|
|
54
|
+
* @param {string[]} b
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
function arraysEqual(a, b) {
|
|
58
|
+
if (a.length !== b.length) return false;
|
|
59
|
+
for (let i = 0; i < a.length; i++) {
|
|
60
|
+
if (a[i] !== b[i]) return false;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if a layout can play at a given time based on simulated play history.
|
|
67
|
+
* Replicates ScheduleManager.canPlayLayout() logic for timeline prediction.
|
|
68
|
+
*
|
|
69
|
+
* Even-distribution rules:
|
|
70
|
+
* 1. Total plays in sliding 1-hour window < maxPlaysPerHour
|
|
71
|
+
* 2. Time since last play >= (60 / maxPlaysPerHour) minutes
|
|
72
|
+
*
|
|
73
|
+
* @param {number[]} history - Simulated play timestamps (ms) for this layout
|
|
74
|
+
* @param {number} maxPlaysPerHour - Max plays per hour (0 = unlimited)
|
|
75
|
+
* @param {number} timeMs - Current simulated time in ms
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
function canSimulatedPlay(history, maxPlaysPerHour, timeMs) {
|
|
79
|
+
if (!maxPlaysPerHour || maxPlaysPerHour === 0) return true;
|
|
80
|
+
|
|
81
|
+
const oneHourAgo = timeMs - 3600000;
|
|
82
|
+
const playsInLastHour = history.filter(t => t > oneHourAgo);
|
|
83
|
+
|
|
84
|
+
// Check 1: under hourly limit
|
|
85
|
+
if (playsInLastHour.length >= maxPlaysPerHour) return false;
|
|
86
|
+
|
|
87
|
+
// Check 2: minimum gap for even distribution
|
|
88
|
+
if (playsInLastHour.length > 0) {
|
|
89
|
+
const minGapMs = 3600000 / maxPlaysPerHour;
|
|
90
|
+
const lastPlay = Math.max(...playsInLastHour);
|
|
91
|
+
if (timeMs - lastPlay < minGapMs) return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Seed simulated play history from real play history.
|
|
99
|
+
* Maps layoutId-based history to layoutFile-based history.
|
|
100
|
+
* @param {Map<string, number[]>} realHistory - schedule.playHistory (layoutId → [timestamps])
|
|
101
|
+
* @returns {Map<string, number[]>} layoutFile → [timestamps]
|
|
102
|
+
*/
|
|
103
|
+
function seedPlayHistory(realHistory) {
|
|
104
|
+
const simulated = new Map();
|
|
105
|
+
if (!realHistory) return simulated;
|
|
106
|
+
|
|
107
|
+
for (const [layoutId, timestamps] of realHistory) {
|
|
108
|
+
const file = `${layoutId}.xlf`;
|
|
109
|
+
simulated.set(file, [...timestamps]);
|
|
110
|
+
}
|
|
111
|
+
return simulated;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* From a list of layout metadata, apply simulated rate limiting and priority
|
|
116
|
+
* filtering to determine which layouts can actually play at the given time.
|
|
117
|
+
* Mirrors the real player logic: filter rate-limited layouts first, then
|
|
118
|
+
* pick highest remaining priority.
|
|
119
|
+
*
|
|
120
|
+
* @param {Array<{file: string, priority: number, maxPlaysPerHour: number}>} allLayouts
|
|
121
|
+
* @param {Map<string, number[]>} simPlays - Simulated play history
|
|
122
|
+
* @param {number} timeMs - Current simulated time in ms
|
|
123
|
+
* @returns {string[]} Layout files that can play, highest priority first
|
|
124
|
+
*/
|
|
125
|
+
function getPlayableLayouts(allLayouts, simPlays, timeMs) {
|
|
126
|
+
// Step 1: Filter out rate-limited layouts
|
|
127
|
+
const eligible = allLayouts.filter(l => {
|
|
128
|
+
if (!l.maxPlaysPerHour || l.maxPlaysPerHour === 0) return true;
|
|
129
|
+
const history = simPlays.get(l.file) || [];
|
|
130
|
+
return canSimulatedPlay(history, l.maxPlaysPerHour, timeMs);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (eligible.length === 0) return [];
|
|
134
|
+
|
|
135
|
+
// Step 2: Pick highest priority from remaining layouts
|
|
136
|
+
const maxPriority = Math.max(...eligible.map(l => l.priority));
|
|
137
|
+
return eligible
|
|
138
|
+
.filter(l => l.priority === maxPriority)
|
|
139
|
+
.map(l => l.file);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Calculate a deterministic playback timeline by simulating round-robin scheduling
|
|
144
|
+
* with rate limiting (maxPlaysPerHour) and priority fallback. Produces a real
|
|
145
|
+
* schedule prediction that matches actual player behavior.
|
|
146
|
+
*
|
|
147
|
+
* When high-priority layouts hit their maxPlaysPerHour limit, the simulation
|
|
148
|
+
* falls back to lower-priority scheduled layouts before using the CMS default.
|
|
149
|
+
*
|
|
150
|
+
* @param {Object} schedule - ScheduleManager instance (needs getAllLayoutsAtTime(), schedule.default, playHistory)
|
|
151
|
+
* @param {Map<string, number>} durations - Map of layoutFile → duration in seconds
|
|
152
|
+
* @param {Object} [options]
|
|
153
|
+
* @param {Date} [options.from] - Start time (default: now)
|
|
154
|
+
* @param {number} [options.hours] - Hours to simulate (default: 2)
|
|
155
|
+
* @param {number} [options.defaultDuration] - Fallback duration in seconds (default: 60)
|
|
156
|
+
* @returns {Array<{layoutFile: string, startTime: Date, endTime: Date, duration: number, isDefault: boolean}>}
|
|
157
|
+
*/
|
|
158
|
+
export function calculateTimeline(schedule, durations, options = {}) {
|
|
159
|
+
const from = options.from || new Date();
|
|
160
|
+
const hours = options.hours || 2;
|
|
161
|
+
const to = new Date(from.getTime() + hours * 3600000);
|
|
162
|
+
const defaultDuration = options.defaultDuration || 60;
|
|
163
|
+
const timeline = [];
|
|
164
|
+
let currentTime = new Date(from);
|
|
165
|
+
|
|
166
|
+
// Use getAllLayoutsAtTime if available (new API), fall back to getLayoutsAtTime (old API)
|
|
167
|
+
const hasFullApi = typeof schedule.getAllLayoutsAtTime === 'function';
|
|
168
|
+
|
|
169
|
+
// Seed simulated play history from real plays
|
|
170
|
+
const simPlays = seedPlayHistory(schedule.playHistory);
|
|
171
|
+
|
|
172
|
+
const maxEntries = 500;
|
|
173
|
+
|
|
174
|
+
while (currentTime < to && timeline.length < maxEntries) {
|
|
175
|
+
const timeMs = currentTime.getTime();
|
|
176
|
+
let playable;
|
|
177
|
+
|
|
178
|
+
if (hasFullApi) {
|
|
179
|
+
// Full simulation: get ALL active layouts, apply rate limiting + priority
|
|
180
|
+
const allLayouts = schedule.getAllLayoutsAtTime(currentTime);
|
|
181
|
+
playable = allLayouts.length > 0
|
|
182
|
+
? getPlayableLayouts(allLayouts, simPlays, timeMs)
|
|
183
|
+
: [];
|
|
184
|
+
} else {
|
|
185
|
+
// Legacy fallback: no rate limiting simulation
|
|
186
|
+
playable = schedule.getLayoutsAtTime(currentTime);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (playable.length === 0) {
|
|
190
|
+
// No playable layouts — use CMS default or skip ahead
|
|
191
|
+
const defaultFile = schedule.schedule?.default;
|
|
192
|
+
if (defaultFile) {
|
|
193
|
+
const dur = durations.get(defaultFile) || defaultDuration;
|
|
194
|
+
timeline.push({
|
|
195
|
+
layoutFile: defaultFile,
|
|
196
|
+
startTime: new Date(currentTime),
|
|
197
|
+
endTime: new Date(timeMs + dur * 1000),
|
|
198
|
+
duration: dur,
|
|
199
|
+
isDefault: true,
|
|
200
|
+
});
|
|
201
|
+
currentTime = new Date(timeMs + dur * 1000);
|
|
202
|
+
} else {
|
|
203
|
+
currentTime = new Date(timeMs + 60000);
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Round-robin through playable layouts
|
|
209
|
+
for (let i = 0; i < playable.length && currentTime < to && timeline.length < maxEntries; i++) {
|
|
210
|
+
const file = playable[i];
|
|
211
|
+
const dur = durations.get(file) || defaultDuration;
|
|
212
|
+
const endMs = currentTime.getTime() + dur * 1000;
|
|
213
|
+
|
|
214
|
+
timeline.push({
|
|
215
|
+
layoutFile: file,
|
|
216
|
+
startTime: new Date(currentTime),
|
|
217
|
+
endTime: new Date(endMs),
|
|
218
|
+
duration: dur,
|
|
219
|
+
isDefault: false,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Record simulated play
|
|
223
|
+
if (hasFullApi) {
|
|
224
|
+
if (!simPlays.has(file)) simPlays.set(file, []);
|
|
225
|
+
simPlays.get(file).push(currentTime.getTime());
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
currentTime = new Date(endMs);
|
|
229
|
+
|
|
230
|
+
// Re-evaluate: if playable set changed, re-enter outer loop
|
|
231
|
+
if (hasFullApi) {
|
|
232
|
+
const nextAll = schedule.getAllLayoutsAtTime(currentTime);
|
|
233
|
+
const nextPlayable = nextAll.length > 0
|
|
234
|
+
? getPlayableLayouts(nextAll, simPlays, currentTime.getTime())
|
|
235
|
+
: [];
|
|
236
|
+
if (!arraysEqual(playable, nextPlayable)) break;
|
|
237
|
+
} else {
|
|
238
|
+
const next = schedule.getLayoutsAtTime(currentTime);
|
|
239
|
+
if (!arraysEqual(playable, next)) break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return timeline;
|
|
245
|
+
}
|
package/docs/README.md
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
# @xiboplayer/schedule Documentation
|
|
2
|
-
|
|
3
|
-
**Campaign scheduling, dayparting, and priority logic.**
|
|
4
|
-
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
The `@xiboplayer/schedule` package provides:
|
|
8
|
-
|
|
9
|
-
- **Campaign scheduler** - Multi-campaign priority handling
|
|
10
|
-
- **Dayparting** - Time-based scheduling
|
|
11
|
-
- **Geo-scheduling** - Location-based campaigns
|
|
12
|
-
- **Interrupt campaigns** - High-priority content
|
|
13
|
-
- **Default fallback** - Graceful degradation
|
|
14
|
-
|
|
15
|
-
## Installation
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
npm install @xiboplayer/schedule
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
## Usage
|
|
22
|
-
|
|
23
|
-
```javascript
|
|
24
|
-
import { Scheduler } from '@xiboplayer/schedule';
|
|
25
|
-
|
|
26
|
-
const scheduler = new Scheduler({
|
|
27
|
-
campaigns: campaignData,
|
|
28
|
-
timezone: 'America/New_York'
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
// Get current layout
|
|
32
|
-
const layout = scheduler.getCurrentLayout();
|
|
33
|
-
|
|
34
|
-
// Check next scheduled event
|
|
35
|
-
const nextEvent = scheduler.getNextEvent();
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
## Features
|
|
39
|
-
|
|
40
|
-
### Campaign Priority
|
|
41
|
-
|
|
42
|
-
Campaigns ordered by priority (1 = highest):
|
|
43
|
-
1. Interrupt campaigns (override all)
|
|
44
|
-
2. Normal campaigns (scheduled)
|
|
45
|
-
3. Default layout (fallback)
|
|
46
|
-
|
|
47
|
-
### Dayparting
|
|
48
|
-
|
|
49
|
-
Time-based scheduling:
|
|
50
|
-
```javascript
|
|
51
|
-
{
|
|
52
|
-
dayOfWeek: [1, 2, 3, 4, 5], // Mon-Fri
|
|
53
|
-
startTime: '08:00',
|
|
54
|
-
endTime: '18:00'
|
|
55
|
-
}
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
### Geo-Scheduling
|
|
59
|
-
|
|
60
|
-
Location-based content:
|
|
61
|
-
```javascript
|
|
62
|
-
{
|
|
63
|
-
geofence: {
|
|
64
|
-
latitude: 40.7128,
|
|
65
|
-
longitude: -74.0060,
|
|
66
|
-
radius: 1000 // meters
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
## API Reference
|
|
72
|
-
|
|
73
|
-
### Scheduler
|
|
74
|
-
|
|
75
|
-
```javascript
|
|
76
|
-
class Scheduler {
|
|
77
|
-
constructor(options)
|
|
78
|
-
getCurrentLayout()
|
|
79
|
-
getNextEvent()
|
|
80
|
-
setLocation(lat, lon)
|
|
81
|
-
on(event, callback)
|
|
82
|
-
}
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
### Events
|
|
86
|
-
|
|
87
|
-
- `schedule:change` - Active schedule changed
|
|
88
|
-
- `campaign:start` - Campaign started
|
|
89
|
-
- `campaign:end` - Campaign ended
|
|
90
|
-
|
|
91
|
-
## Dependencies
|
|
92
|
-
|
|
93
|
-
- `@xiboplayer/utils` - Logger, EventEmitter
|
|
94
|
-
|
|
95
|
-
## Related Packages
|
|
96
|
-
|
|
97
|
-
- [@xiboplayer/core](../../core/docs/) - Player orchestration
|
|
98
|
-
|
|
99
|
-
---
|
|
100
|
-
|
|
101
|
-
**Package Version**: 1.0.0
|
|
102
|
-
**Last Updated**: 2026-02-10
|