@xiboplayer/schedule 0.6.3 → 0.6.5

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 CHANGED
@@ -1,17 +1,59 @@
1
1
  # @xiboplayer/schedule
2
2
 
3
- **Campaign scheduling with dayparting, interrupts, overlays, and timeline prediction.**
3
+ **Complete scheduling solution: campaigns, dayparting, interrupts, overlays, and timeline prediction.**
4
4
 
5
5
  ## Overview
6
6
 
7
- Complete scheduling solution for Xibo digital signage:
7
+ Manages all aspects of digital signage scheduling to determine which layouts play when:
8
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
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
- import { Schedule } from '@xiboplayer/schedule';
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
- const schedule = new Schedule();
28
- schedule.update(scheduleXml);
116
+ ### Rate limiting with even distribution
29
117
 
30
- const currentLayouts = schedule.getCurrentLayouts();
31
- const timeline = schedule.getTimeline(now, now + 3600000); // next hour
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
- - `@xiboplayer/utils` logger, events
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",
3
+ "version": "0.6.5",
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.3"
15
+ "@xiboplayer/utils": "0.6.5"
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
- // First entry: use remaining duration if we know when the current layout started
191
- if (isFirstEntry && currentLayoutStartedAt) {
192
- const elapsedSec = (from.getTime() - currentLayoutStartedAt.getTime()) / 1000;
193
- dur = Math.max(1, Math.round(dur - elapsedSec));
194
- isFirstEntry = false;
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
 
@@ -116,11 +116,12 @@ describe('calculateTimeline', () => {
116
116
  });
117
117
  });
118
118
 
119
- describe('currentLayoutStartedAt (remaining time adjustment)', () => {
120
- it('should adjust first entry duration to remaining time', () => {
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
- // Layout started 20 seconds ago 40 seconds remaining
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(40); // 60 - 20 = 40
132
+ expect(timeline[0].duration).toBe(60); // Full duration, no adjustment
132
133
  });
133
134
 
134
- it('should clamp remaining time to at least 1 second', () => {
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).toBeGreaterThanOrEqual(1);
145
+ expect(timeline[0].duration).toBe(30); // Full duration
146
146
  });
147
147
 
148
- it('should only adjust first entry, not subsequent', () => {
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(40);
157
+ expect(timeline[0].duration).toBe(60); // Full duration
158
158
  expect(timeline[1].duration).toBe(60); // Full duration
159
159
  });
160
160
  });