@xiboplayer/schedule 0.6.3 → 0.6.4
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 +282 -14
- package/package.json +2 -2
- package/src/timeline.js +5 -6
- package/src/timeline.test.js +9 -9
package/README.md
CHANGED
|
@@ -1,17 +1,59 @@
|
|
|
1
1
|
# @xiboplayer/schedule
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Complete scheduling solution: campaigns, dayparting, interrupts, overlays, and timeline prediction.**
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Manages all aspects of digital signage scheduling to determine which layouts play when:
|
|
8
8
|
|
|
9
|
-
- **Campaign scheduling**
|
|
10
|
-
- **Dayparting**
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
9
|
+
- **Campaign scheduling** -- groups of layouts with time windows and priorities
|
|
10
|
+
- **Dayparting** -- weekly time slots (Mon-Fri 09:00-17:00, evenings, weekends) with midnight-crossing support
|
|
11
|
+
- **Priority fallback** -- higher-priority layouts hide lower-priority ones; rate limiting triggers automatic fallback
|
|
12
|
+
- **Rate limiting** -- `maxPlaysPerHour` with even distribution (prevents bursts, ensures spacing)
|
|
13
|
+
- **Interrupts (Share of Voice)** -- layouts that must play X% of each hour, interleaved with normal content
|
|
14
|
+
- **Overlays** -- layouts that appear on top of main layouts without interrupting playback
|
|
15
|
+
- **Criteria evaluation** -- conditional display based on time, weather, custom display properties
|
|
16
|
+
- **Geo-fencing** -- location-based filtering (point + radius, Haversine distance)
|
|
17
|
+
- **Timeline prediction** -- deterministic simulation of future playback for UI overlays
|
|
18
|
+
- **Default layout** -- fallback when no campaigns are active
|
|
19
|
+
|
|
20
|
+
## Architecture
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
CMS Schedule XML
|
|
24
|
+
|
|
|
25
|
+
v
|
|
26
|
+
+-------------------------------------+
|
|
27
|
+
| Schedule Parser |
|
|
28
|
+
| +- campaigns[] |
|
|
29
|
+
| +- layouts[] |
|
|
30
|
+
| +- overlays[] |
|
|
31
|
+
| +- default layout |
|
|
32
|
+
+-------------------------------------+
|
|
33
|
+
|
|
|
34
|
+
v
|
|
35
|
+
+-------------------------------------+
|
|
36
|
+
| Evaluation Engine |
|
|
37
|
+
| +- Recurrence (Week/Day/Month) |
|
|
38
|
+
| +- Time windows (dayparting) |
|
|
39
|
+
| +- Criteria (weather, properties) |
|
|
40
|
+
| +- Geo-fencing |
|
|
41
|
+
| +- Priority + rate-limit filtering |
|
|
42
|
+
+-------------------------------------+
|
|
43
|
+
|
|
|
44
|
+
v
|
|
45
|
+
+-------------------------------------+
|
|
46
|
+
| Schedule Queue Builder (LCM-based) |
|
|
47
|
+
| Deterministic round-robin with: |
|
|
48
|
+
| +- Rate-limited slots (even spaced)|
|
|
49
|
+
| +- Priority fallback |
|
|
50
|
+
| +- Default fills gaps |
|
|
51
|
+
+-------------------------------------+
|
|
52
|
+
|
|
|
53
|
+
+-> getCurrentLayouts() -> Renderer
|
|
54
|
+
+-> getLayoutsInTimeRange() -> Timeline Overlay
|
|
55
|
+
+-> Track play history -> Rate limiting
|
|
56
|
+
```
|
|
15
57
|
|
|
16
58
|
## Installation
|
|
17
59
|
|
|
@@ -21,19 +63,245 @@ npm install @xiboplayer/schedule
|
|
|
21
63
|
|
|
22
64
|
## Usage
|
|
23
65
|
|
|
66
|
+
### Basic scheduling
|
|
67
|
+
|
|
68
|
+
```javascript
|
|
69
|
+
import { ScheduleManager } from '@xiboplayer/schedule';
|
|
70
|
+
|
|
71
|
+
const schedule = new ScheduleManager();
|
|
72
|
+
|
|
73
|
+
schedule.setSchedule({
|
|
74
|
+
campaigns: [
|
|
75
|
+
{
|
|
76
|
+
id: 1,
|
|
77
|
+
priority: 100,
|
|
78
|
+
fromdt: '2025-01-01 09:00',
|
|
79
|
+
todt: '2025-12-31 17:00',
|
|
80
|
+
recurrenceType: 'Week',
|
|
81
|
+
recurrenceRepeatsOn: '1,2,3,4,5', // Mon-Fri
|
|
82
|
+
layouts: [
|
|
83
|
+
{ id: 10, file: '10.xlf', duration: 30 },
|
|
84
|
+
{ id: 11, file: '11.xlf', duration: 30 },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
default: '99.xlf',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const layoutsToPlay = schedule.getCurrentLayouts();
|
|
92
|
+
// Business hours: ['10.xlf', '11.xlf']
|
|
93
|
+
// After hours: ['99.xlf'] (default)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Dayparting with midnight crossing
|
|
97
|
+
|
|
24
98
|
```javascript
|
|
25
|
-
|
|
99
|
+
schedule.setSchedule({
|
|
100
|
+
layouts: [
|
|
101
|
+
{
|
|
102
|
+
id: 1,
|
|
103
|
+
file: '1.xlf',
|
|
104
|
+
recurrenceType: 'Week',
|
|
105
|
+
recurrenceRepeatsOn: '1,2,3,4,5,6,7',
|
|
106
|
+
fromdt: '1970-01-01 22:00:00', // 10 PM
|
|
107
|
+
todt: '1970-01-01 02:00:00', // 2 AM (next day)
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Friday 23:00: returns ['1.xlf']
|
|
113
|
+
// Saturday 01:00: returns ['1.xlf'] (midnight crossing works)
|
|
114
|
+
```
|
|
26
115
|
|
|
27
|
-
|
|
28
|
-
schedule.update(scheduleXml);
|
|
116
|
+
### Rate limiting with even distribution
|
|
29
117
|
|
|
30
|
-
|
|
31
|
-
|
|
118
|
+
```javascript
|
|
119
|
+
schedule.setSchedule({
|
|
120
|
+
layouts: [
|
|
121
|
+
{
|
|
122
|
+
id: 1,
|
|
123
|
+
file: '1.xlf',
|
|
124
|
+
maxPlaysPerHour: 3, // 3 times per hour, evenly spaced
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
schedule.recordPlay('1'); // Play at 09:00
|
|
130
|
+
// Can't play again until 09:20 (60 / 3 = 20 min minimum gap)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Interrupts (Share of Voice)
|
|
134
|
+
|
|
135
|
+
```javascript
|
|
136
|
+
schedule.setSchedule({
|
|
137
|
+
layouts: [
|
|
138
|
+
{ id: 1, file: '1.xlf', duration: 30 },
|
|
139
|
+
{ id: 2, file: '2.xlf', duration: 30, shareOfVoice: 20 }, // 20% of each hour
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const layouts = schedule.getCurrentLayouts();
|
|
144
|
+
// Interleaved: normal, normal, interrupt, normal, normal, interrupt, ...
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Criteria-based display
|
|
148
|
+
|
|
149
|
+
```javascript
|
|
150
|
+
schedule.setSchedule({
|
|
151
|
+
layouts: [
|
|
152
|
+
{
|
|
153
|
+
id: 1,
|
|
154
|
+
file: '1.xlf',
|
|
155
|
+
criteria: [
|
|
156
|
+
{ metric: 'weatherTemp', condition: 'greaterThan', value: '25', type: 'number' },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
schedule.setWeatherData({ temperature: 28, humidity: 65 });
|
|
163
|
+
// Layout 1 displays only when temperature > 25
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Geo-fencing
|
|
167
|
+
|
|
168
|
+
```javascript
|
|
169
|
+
schedule.setLocation(37.7749, -122.4194);
|
|
170
|
+
|
|
171
|
+
schedule.setSchedule({
|
|
172
|
+
layouts: [
|
|
173
|
+
{
|
|
174
|
+
id: 1,
|
|
175
|
+
file: '1.xlf',
|
|
176
|
+
isGeoAware: true,
|
|
177
|
+
geoLocation: '37.7749,-122.4194,500', // lat,lng,radius_meters
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Returns ['1.xlf'] only if player is within 500m
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Timeline prediction
|
|
186
|
+
|
|
187
|
+
```javascript
|
|
188
|
+
const timeline = calculateTimeline(queue, queuePosition, {
|
|
189
|
+
from: new Date(),
|
|
190
|
+
hours: 2,
|
|
191
|
+
defaultLayout: schedule.schedule.default,
|
|
192
|
+
durations: durations,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Returns:
|
|
196
|
+
// [
|
|
197
|
+
// { layoutFile: '10.xlf', startTime, endTime, duration: 30, isDefault: false },
|
|
198
|
+
// { layoutFile: '11.xlf', startTime, endTime, duration: 30, isDefault: false },
|
|
199
|
+
// ...
|
|
200
|
+
// ]
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Campaign Evaluation Algorithm
|
|
204
|
+
|
|
205
|
+
When `getCurrentLayouts()` is called:
|
|
206
|
+
|
|
207
|
+
1. **Filter time-active items** -- campaigns and standalone layouts within their date/time window and recurrence rules
|
|
208
|
+
2. **Apply criteria** -- filter by weather, display properties, geo-fencing
|
|
209
|
+
3. **Apply rate limiting** -- exclude layouts that exceeded `maxPlaysPerHour`
|
|
210
|
+
4. **Find max priority** -- only max priority items win
|
|
211
|
+
5. **Extract layouts** -- campaigns return all their layouts; standalone layouts contribute themselves
|
|
212
|
+
6. **Process interrupts** -- separate interrupt layouts, calculate share-of-voice, interleave
|
|
213
|
+
7. **Return layout files** -- ready for the renderer
|
|
214
|
+
|
|
215
|
+
## Key Concepts
|
|
216
|
+
|
|
217
|
+
### Schedule Queue (LCM-based)
|
|
218
|
+
|
|
219
|
+
The queue is a pre-computed, deterministic round-robin cycle:
|
|
220
|
+
|
|
221
|
+
- **LCM period** -- Least Common Multiple of all `maxPlaysPerHour` intervals (capped at 2 hours)
|
|
222
|
+
- **Simulation** -- walks the period applying priority and rate-limit rules at each step
|
|
223
|
+
- **Caching** -- reused until the active layout set changes
|
|
224
|
+
- **Predictable** -- answers "what's playing in 30 minutes?" offline
|
|
225
|
+
|
|
226
|
+
### Dayparting
|
|
227
|
+
|
|
228
|
+
| Type | Pattern | Example |
|
|
229
|
+
|------|---------|---------|
|
|
230
|
+
| Week | Specific days + time-of-day | Mon-Fri 09:00-17:00 |
|
|
231
|
+
| Day | Daily with optional interval | Every 2 days |
|
|
232
|
+
| Month | Specific days of month | 1st, 15th (monthly) |
|
|
233
|
+
|
|
234
|
+
Midnight crossing: `22:00 - 02:00` works across day boundaries.
|
|
235
|
+
|
|
236
|
+
### Criteria Evaluation
|
|
237
|
+
|
|
238
|
+
**Built-in metrics:** `dayOfWeek`, `dayOfMonth`, `month`, `hour`, `isoDay`
|
|
239
|
+
|
|
240
|
+
**Weather metrics:** `weatherTemp`, `weatherHumidity`, `weatherWindSpeed`, `weatherCondition`, `weatherCloudCover`
|
|
241
|
+
|
|
242
|
+
**Operators:** `equals`, `notEquals`, `greaterThan`, `greaterThanOrEquals`, `lessThan`, `lessThanOrEquals`, `contains`, `startsWith`, `endsWith`, `in`
|
|
243
|
+
|
|
244
|
+
### Geo-fencing
|
|
245
|
+
|
|
246
|
+
- Format: `"lat,lng,radius"` (e.g., `"37.7749,-122.4194,500"`)
|
|
247
|
+
- Default radius: 500 meters
|
|
248
|
+
- Calculation: Haversine formula (great-circle distance)
|
|
249
|
+
- Permissive: if no location available, layout displays (fail-open for offline)
|
|
250
|
+
|
|
251
|
+
## API Reference
|
|
252
|
+
|
|
253
|
+
### Constructor
|
|
254
|
+
|
|
255
|
+
```javascript
|
|
256
|
+
new ScheduleManager(options?)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
| Option | Type | Description |
|
|
260
|
+
|--------|------|-------------|
|
|
261
|
+
| `interruptScheduler` | InterruptScheduler? | Optional interrupt handler |
|
|
262
|
+
| `displayProperties` | Object? | Custom display fields from CMS |
|
|
263
|
+
|
|
264
|
+
### Methods
|
|
265
|
+
|
|
266
|
+
| Method | Returns | Description |
|
|
267
|
+
|--------|---------|-------------|
|
|
268
|
+
| `setSchedule(schedule)` | void | Load schedule from XMDS response |
|
|
269
|
+
| `getCurrentLayouts()` | string[] | Layouts active now |
|
|
270
|
+
| `getLayoutsAtTime(date)` | string[] | Layouts at specific time |
|
|
271
|
+
| `getAllLayoutsAtTime(date)` | Array | All time-active layouts with metadata |
|
|
272
|
+
| `getScheduleQueue(durations)` | {queue, periodSeconds} | Pre-computed round-robin queue |
|
|
273
|
+
| `popNextFromQueue(durations)` | {layoutId, duration} | Pop next entry, advance position |
|
|
274
|
+
| `peekNextInQueue(durations)` | {layoutId, duration} | Peek without advancing |
|
|
275
|
+
| `recordPlay(layoutId)` | void | Track a play for rate limiting |
|
|
276
|
+
| `canPlayLayout(layoutId, max)` | boolean | Check if layout can play now |
|
|
277
|
+
| `setWeatherData(data)` | void | Update weather for criteria |
|
|
278
|
+
| `setLocation(lat, lng)` | void | Set GPS location for geo-fencing |
|
|
279
|
+
| `setDisplayProperties(props)` | void | Set custom display fields |
|
|
280
|
+
| `detectConflicts(options)` | Array | Find priority-shadowing conflicts |
|
|
281
|
+
|
|
282
|
+
### Overlay Methods
|
|
283
|
+
|
|
284
|
+
| Method | Returns | Description |
|
|
285
|
+
|--------|---------|-------------|
|
|
286
|
+
| `setOverlays(overlays)` | void | Update overlay list |
|
|
287
|
+
| `getCurrentOverlays()` | Array | Active overlays (sorted by priority) |
|
|
288
|
+
| `shouldCheckOverlays(lastCheck)` | boolean | Check interval (every 60s) |
|
|
289
|
+
|
|
290
|
+
### Timeline Functions
|
|
291
|
+
|
|
292
|
+
```javascript
|
|
293
|
+
import { calculateTimeline, parseLayoutDuration, buildScheduleQueue } from '@xiboplayer/schedule';
|
|
294
|
+
|
|
295
|
+
const { duration } = parseLayoutDuration(xlfXml, videoDurations?);
|
|
296
|
+
const { queue, periodSeconds } = buildScheduleQueue(allLayouts, durations);
|
|
297
|
+
const timeline = calculateTimeline(queue, position, { from, hours, defaultLayout, durations });
|
|
32
298
|
```
|
|
33
299
|
|
|
34
300
|
## Dependencies
|
|
35
301
|
|
|
36
|
-
|
|
302
|
+
No external dependencies -- fully self-contained scheduling engine.
|
|
303
|
+
|
|
304
|
+
- `@xiboplayer/utils` -- logging only
|
|
37
305
|
|
|
38
306
|
---
|
|
39
307
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/schedule",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Complete scheduling solution: campaigns, dayparting, interrupts, and overlays",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"./overlays": "./src/overlays.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@xiboplayer/utils": "0.6.
|
|
15
|
+
"@xiboplayer/utils": "0.6.4"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"vitest": "^2.0.0"
|
package/src/timeline.js
CHANGED
|
@@ -187,12 +187,11 @@ export function calculateTimeline(queue, queuePosition, options = {}) {
|
|
|
187
187
|
// otherwise fall back to the queue's baked-in duration
|
|
188
188
|
let dur = (durations && durations.get(entry.layoutId)) || entry.duration;
|
|
189
189
|
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
190
|
+
// Note: queuePosition has already advanced past the current layout
|
|
191
|
+
// (via popNextFromQueue), so the first entry here is the NEXT layout.
|
|
192
|
+
// No elapsed-time adjustment needed — the overlay handles countdown
|
|
193
|
+
// for the current layout via wall-clock layoutStartedAt.
|
|
194
|
+
isFirstEntry = false;
|
|
196
195
|
|
|
197
196
|
const endMs = currentTime.getTime() + dur * 1000;
|
|
198
197
|
|
package/src/timeline.test.js
CHANGED
|
@@ -116,11 +116,12 @@ describe('calculateTimeline', () => {
|
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
-
describe('currentLayoutStartedAt (
|
|
120
|
-
it('should adjust first entry duration
|
|
119
|
+
describe('currentLayoutStartedAt (no adjustment — queuePosition already advanced)', () => {
|
|
120
|
+
it('should not adjust first entry duration since queue has advanced past current layout', () => {
|
|
121
121
|
const queue = [{ layoutId: '100.xlf', duration: 60 }];
|
|
122
122
|
|
|
123
|
-
//
|
|
123
|
+
// currentLayoutStartedAt is ignored — the first timeline entry is the
|
|
124
|
+
// NEXT layout (queuePosition already advanced via popNextFromQueue)
|
|
124
125
|
const startedAt = new Date(NOW.getTime() - 20000);
|
|
125
126
|
|
|
126
127
|
const timeline = calculateTimeline(queue, 0, {
|
|
@@ -128,13 +129,12 @@ describe('calculateTimeline', () => {
|
|
|
128
129
|
currentLayoutStartedAt: startedAt,
|
|
129
130
|
});
|
|
130
131
|
|
|
131
|
-
expect(timeline[0].duration).toBe(
|
|
132
|
+
expect(timeline[0].duration).toBe(60); // Full duration, no adjustment
|
|
132
133
|
});
|
|
133
134
|
|
|
134
|
-
it('should
|
|
135
|
+
it('should use full duration even when layout would be overdue', () => {
|
|
135
136
|
const queue = [{ layoutId: '100.xlf', duration: 30 }];
|
|
136
137
|
|
|
137
|
-
// Layout started 60 seconds ago but duration is only 30 → already overdue
|
|
138
138
|
const startedAt = new Date(NOW.getTime() - 60000);
|
|
139
139
|
|
|
140
140
|
const timeline = calculateTimeline(queue, 0, {
|
|
@@ -142,10 +142,10 @@ describe('calculateTimeline', () => {
|
|
|
142
142
|
currentLayoutStartedAt: startedAt,
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
-
expect(timeline[0].duration).
|
|
145
|
+
expect(timeline[0].duration).toBe(30); // Full duration
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
-
it('should
|
|
148
|
+
it('should use full duration for all entries', () => {
|
|
149
149
|
const queue = [{ layoutId: '100.xlf', duration: 60 }];
|
|
150
150
|
const startedAt = new Date(NOW.getTime() - 20000);
|
|
151
151
|
|
|
@@ -154,7 +154,7 @@ describe('calculateTimeline', () => {
|
|
|
154
154
|
currentLayoutStartedAt: startedAt,
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
expect(timeline[0].duration).toBe(
|
|
157
|
+
expect(timeline[0].duration).toBe(60); // Full duration
|
|
158
158
|
expect(timeline[1].duration).toBe(60); // Full duration
|
|
159
159
|
});
|
|
160
160
|
});
|