@xiboplayer/schedule 0.5.5 → 0.5.7
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/package.json +2 -2
- package/src/index.js +1 -1
- package/src/schedule.js +93 -26
- package/src/schedule.test.js +91 -52
- package/src/timeline.js +140 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/schedule",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
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.5.
|
|
14
|
+
"@xiboplayer/utils": "0.5.7"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"vitest": "^2.0.0"
|
package/src/index.js
CHANGED
|
@@ -25,4 +25,4 @@ export { OverlayScheduler } from './overlays.js';
|
|
|
25
25
|
* Offline timeline calculator — duration parser + timeline simulator
|
|
26
26
|
* @module @xiboplayer/schedule/timeline
|
|
27
27
|
*/
|
|
28
|
-
export { calculateTimeline, parseLayoutDuration } from './timeline.js';
|
|
28
|
+
export { calculateTimeline, parseLayoutDuration, buildScheduleQueue } from './timeline.js';
|
package/src/schedule.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { createLogger } from '@xiboplayer/utils';
|
|
6
6
|
import { evaluateCriteria } from './criteria.js';
|
|
7
|
+
import { buildScheduleQueue } from './timeline.js';
|
|
7
8
|
|
|
8
9
|
const log = createLogger('Schedule');
|
|
9
10
|
|
|
@@ -16,6 +17,11 @@ export class ScheduleManager {
|
|
|
16
17
|
this.weatherData = {}; // Weather data from GetWeather XMDS call
|
|
17
18
|
this.playerLocation = null; // { latitude, longitude } from Geolocation API
|
|
18
19
|
this._layoutMetadata = new Map(); // layoutFile → { syncEvent, shareOfVoice, ... }
|
|
20
|
+
|
|
21
|
+
// Pre-calculated schedule queue (LCM-based deterministic timeline)
|
|
22
|
+
this._scheduleQueue = null; // { queue: [{layoutId, duration}], periodSeconds }
|
|
23
|
+
this._queuePosition = 0; // Current position in the queue
|
|
24
|
+
this._queueLayoutSet = null; // Stringified active layout set (for invalidation)
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -23,6 +29,7 @@ export class ScheduleManager {
|
|
|
23
29
|
*/
|
|
24
30
|
setSchedule(schedule) {
|
|
25
31
|
this.schedule = schedule;
|
|
32
|
+
this._invalidateQueue();
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
/**
|
|
@@ -606,42 +613,102 @@ export class ScheduleManager {
|
|
|
606
613
|
}
|
|
607
614
|
|
|
608
615
|
/**
|
|
609
|
-
* Get
|
|
616
|
+
* Get (or build) the deterministic schedule queue.
|
|
610
617
|
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
618
|
+
* Uses LCM-based even distribution to pre-calculate a repeating cycle where
|
|
619
|
+
* each rate-limited layout plays at evenly spaced intervals and gaps are
|
|
620
|
+
* filled by unlimited layouts and the CMS default.
|
|
614
621
|
*
|
|
615
|
-
*
|
|
616
|
-
* -
|
|
617
|
-
* -
|
|
618
|
-
* -
|
|
622
|
+
* The queue is cached and only rebuilt when:
|
|
623
|
+
* - The schedule changes (setSchedule)
|
|
624
|
+
* - The active layout set changes (time boundary crossed)
|
|
625
|
+
* - durations are updated
|
|
619
626
|
*
|
|
620
|
-
* @
|
|
627
|
+
* @param {Map<string, number>} durations - layoutFile → duration in seconds
|
|
628
|
+
* @param {Object} [options]
|
|
629
|
+
* @param {Set<string>} [options.dynamicLayouts] - Set of layout files with useDuration=0
|
|
630
|
+
* @returns {{ queue: Array<{layoutId: string, duration: number}>, periodSeconds: number }}
|
|
621
631
|
*/
|
|
622
|
-
|
|
623
|
-
const
|
|
624
|
-
const
|
|
632
|
+
getScheduleQueue(durations, options = {}) {
|
|
633
|
+
const allLayouts = this.getAllLayoutsAtTime(new Date());
|
|
634
|
+
const layoutSetKey = allLayouts.map(l => `${l.file}:${l.priority}:${l.maxPlaysPerHour}`).sort().join('|');
|
|
625
635
|
|
|
626
|
-
//
|
|
627
|
-
if (
|
|
628
|
-
return
|
|
636
|
+
// Return cached queue if the active layout set hasn't changed
|
|
637
|
+
if (this._scheduleQueue && this._queueLayoutSet === layoutSetKey) {
|
|
638
|
+
return this._scheduleQueue;
|
|
629
639
|
}
|
|
630
640
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
641
|
+
const result = buildScheduleQueue(allLayouts, durations, {
|
|
642
|
+
defaultLayout: this.schedule?.default || null,
|
|
643
|
+
defaultDuration: 60,
|
|
644
|
+
dynamicLayouts: options.dynamicLayouts || new Set(),
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
this._scheduleQueue = result;
|
|
648
|
+
this._queueLayoutSet = layoutSetKey;
|
|
649
|
+
// Reset position when queue is rebuilt
|
|
650
|
+
this._queuePosition = 0;
|
|
635
651
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
interleaved.push(layout);
|
|
640
|
-
interleaved.push(defaultLayout);
|
|
652
|
+
if (result.queue.length > 0) {
|
|
653
|
+
log.info(`[Schedule] Built queue: ${result.queue.length} entries, period ${result.periodSeconds}s`);
|
|
654
|
+
log.info(`[Schedule] Queue: ${result.queue.map(e => `${e.layoutId}(${e.duration}s)`).join(' → ')}`);
|
|
641
655
|
}
|
|
642
656
|
|
|
643
|
-
|
|
644
|
-
|
|
657
|
+
return result;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Pop the next entry from the schedule queue.
|
|
662
|
+
* Wraps around at the end (the LCM period guarantees the pattern repeats).
|
|
663
|
+
*
|
|
664
|
+
* @param {Map<string, number>} durations - layoutFile → duration in seconds
|
|
665
|
+
* @param {Object} [options]
|
|
666
|
+
* @param {Set<string>} [options.dynamicLayouts] - Dynamic layout set
|
|
667
|
+
* @returns {{ layoutId: string, duration: number } | null}
|
|
668
|
+
*/
|
|
669
|
+
popNextFromQueue(durations, options = {}) {
|
|
670
|
+
const { queue } = this.getScheduleQueue(durations, options);
|
|
671
|
+
if (queue.length === 0) return null;
|
|
672
|
+
|
|
673
|
+
const entry = queue[this._queuePosition % queue.length];
|
|
674
|
+
this._queuePosition = (this._queuePosition + 1) % queue.length;
|
|
675
|
+
return entry;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Peek at the next entry in the schedule queue without advancing.
|
|
680
|
+
*
|
|
681
|
+
* @param {Map<string, number>} durations - layoutFile → duration in seconds
|
|
682
|
+
* @param {Object} [options]
|
|
683
|
+
* @returns {{ layoutId: string, duration: number } | null}
|
|
684
|
+
*/
|
|
685
|
+
peekNextInQueue(durations, options = {}) {
|
|
686
|
+
const { queue } = this.getScheduleQueue(durations, options);
|
|
687
|
+
if (queue.length === 0) return null;
|
|
688
|
+
return queue[this._queuePosition % queue.length];
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Peek at the entry after the current one (two positions ahead).
|
|
693
|
+
* Used for preloading.
|
|
694
|
+
*
|
|
695
|
+
* @param {Map<string, number>} durations
|
|
696
|
+
* @param {Object} [options]
|
|
697
|
+
* @returns {{ layoutId: string, duration: number } | null}
|
|
698
|
+
*/
|
|
699
|
+
peekAfterNext(durations, options = {}) {
|
|
700
|
+
const { queue } = this.getScheduleQueue(durations, options);
|
|
701
|
+
if (queue.length <= 1) return null;
|
|
702
|
+
return queue[(this._queuePosition + 1) % queue.length];
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Invalidate the cached queue (called on schedule change, time boundaries, etc.)
|
|
707
|
+
*/
|
|
708
|
+
_invalidateQueue() {
|
|
709
|
+
this._scheduleQueue = null;
|
|
710
|
+
this._queueLayoutSet = null;
|
|
711
|
+
this._queuePosition = 0;
|
|
645
712
|
}
|
|
646
713
|
|
|
647
714
|
/**
|
package/src/schedule.test.js
CHANGED
|
@@ -196,107 +196,150 @@ describe('ScheduleManager - Campaigns', () => {
|
|
|
196
196
|
});
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
-
describe('ScheduleManager -
|
|
199
|
+
describe('ScheduleManager - Schedule Queue (LCM-based)', () => {
|
|
200
200
|
let manager;
|
|
201
|
+
const durations = new Map();
|
|
201
202
|
|
|
202
203
|
beforeEach(() => {
|
|
203
204
|
manager = new ScheduleManager();
|
|
205
|
+
durations.clear();
|
|
206
|
+
durations.set('100', 60);
|
|
207
|
+
durations.set('200', 60);
|
|
208
|
+
durations.set('300', 60);
|
|
209
|
+
durations.set('999', 60);
|
|
204
210
|
});
|
|
205
211
|
|
|
206
|
-
it('should
|
|
212
|
+
it('should build a queue with unlimited layouts and default', () => {
|
|
207
213
|
manager.setSchedule({
|
|
208
214
|
default: '999',
|
|
209
215
|
layouts: [
|
|
210
216
|
{ file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
|
|
211
217
|
{ file: '200', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
|
|
212
|
-
{ file: '300', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) }
|
|
213
218
|
],
|
|
214
219
|
campaigns: []
|
|
215
220
|
});
|
|
216
221
|
|
|
217
|
-
const
|
|
222
|
+
const { queue } = manager.getScheduleQueue(durations);
|
|
218
223
|
|
|
219
|
-
|
|
224
|
+
// Should include all layouts
|
|
225
|
+
expect(queue.length).toBeGreaterThan(0);
|
|
226
|
+
const layoutIds = queue.map(e => e.layoutId);
|
|
227
|
+
expect(layoutIds).toContain('100');
|
|
228
|
+
expect(layoutIds).toContain('200');
|
|
220
229
|
});
|
|
221
230
|
|
|
222
|
-
it('should
|
|
231
|
+
it('should return only default when no layouts are scheduled', () => {
|
|
223
232
|
manager.setSchedule({
|
|
224
233
|
default: '999',
|
|
225
234
|
layouts: [],
|
|
226
235
|
campaigns: []
|
|
227
236
|
});
|
|
228
237
|
|
|
229
|
-
const
|
|
238
|
+
const { queue } = manager.getScheduleQueue(durations);
|
|
230
239
|
|
|
231
|
-
expect(
|
|
240
|
+
expect(queue).toHaveLength(1);
|
|
241
|
+
expect(queue[0].layoutId).toBe('999');
|
|
232
242
|
});
|
|
233
243
|
|
|
234
|
-
it('should
|
|
244
|
+
it('should return empty queue when no schedule set', () => {
|
|
245
|
+
const { queue } = manager.getScheduleQueue(durations);
|
|
246
|
+
|
|
247
|
+
expect(queue).toEqual([]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should place rate-limited layouts at even intervals', () => {
|
|
251
|
+
durations.set('472', 219);
|
|
235
252
|
manager.setSchedule({
|
|
236
253
|
default: '999',
|
|
237
254
|
layouts: [
|
|
238
|
-
{ file: '
|
|
255
|
+
{ file: '472', priority: 10, maxPlaysPerHour: 3, fromdt: dateStr(-1), todt: dateStr(1) },
|
|
239
256
|
],
|
|
240
257
|
campaigns: []
|
|
241
258
|
});
|
|
242
259
|
|
|
243
|
-
const
|
|
260
|
+
const { queue, periodSeconds } = manager.getScheduleQueue(durations);
|
|
244
261
|
|
|
245
|
-
|
|
262
|
+
// With maxPlaysPerHour=3, interval=1200s, LCM=1200s
|
|
263
|
+
expect(periodSeconds).toBe(1200);
|
|
264
|
+
// Should have exactly 1 play of 472 in a 1200s period
|
|
265
|
+
const plays472 = queue.filter(e => e.layoutId === '472');
|
|
266
|
+
expect(plays472).toHaveLength(1);
|
|
267
|
+
// Gaps filled by default
|
|
268
|
+
const defaultPlays = queue.filter(e => e.layoutId === '999');
|
|
269
|
+
expect(defaultPlays.length).toBeGreaterThan(0);
|
|
246
270
|
});
|
|
247
271
|
|
|
248
|
-
it('should
|
|
272
|
+
it('should pop entries in order and wrap around', () => {
|
|
249
273
|
manager.setSchedule({
|
|
250
274
|
default: '999',
|
|
251
|
-
layouts: [
|
|
252
|
-
|
|
253
|
-
{
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
fromdt: dateStr(-1),
|
|
257
|
-
todt: dateStr(1),
|
|
258
|
-
layouts: [
|
|
259
|
-
{ file: '205' },
|
|
260
|
-
{ file: '203' },
|
|
261
|
-
{ file: '204' }
|
|
262
|
-
]
|
|
263
|
-
}
|
|
264
|
-
]
|
|
275
|
+
layouts: [
|
|
276
|
+
{ file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
|
|
277
|
+
{ file: '200', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
|
|
278
|
+
],
|
|
279
|
+
campaigns: []
|
|
265
280
|
});
|
|
266
281
|
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
expect(layouts).toEqual(['205', '999', '203', '999', '204', '999']);
|
|
271
|
-
});
|
|
282
|
+
const { queue } = manager.getScheduleQueue(durations);
|
|
283
|
+
const firstPop = manager.popNextFromQueue(durations);
|
|
284
|
+
const secondPop = manager.popNextFromQueue(durations);
|
|
272
285
|
|
|
273
|
-
|
|
274
|
-
|
|
286
|
+
expect(firstPop.layoutId).toBe(queue[0].layoutId);
|
|
287
|
+
expect(secondPop.layoutId).toBe(queue[1].layoutId);
|
|
275
288
|
|
|
276
|
-
|
|
289
|
+
// Pop through entire queue to test wrap-around
|
|
290
|
+
for (let i = 2; i < queue.length; i++) {
|
|
291
|
+
manager.popNextFromQueue(durations);
|
|
292
|
+
}
|
|
293
|
+
const wrapped = manager.popNextFromQueue(durations);
|
|
294
|
+
expect(wrapped.layoutId).toBe(queue[0].layoutId);
|
|
277
295
|
});
|
|
278
296
|
|
|
279
|
-
it('should
|
|
297
|
+
it('should invalidate queue on schedule change', () => {
|
|
280
298
|
manager.setSchedule({
|
|
299
|
+
default: '999',
|
|
281
300
|
layouts: [
|
|
282
301
|
{ file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
|
|
283
|
-
{ file: '200', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) }
|
|
284
302
|
],
|
|
285
303
|
campaigns: []
|
|
286
304
|
});
|
|
287
305
|
|
|
288
|
-
|
|
306
|
+
manager.popNextFromQueue(durations); // advance position
|
|
289
307
|
|
|
290
|
-
//
|
|
291
|
-
|
|
308
|
+
// Change schedule — should reset
|
|
309
|
+
manager.setSchedule({
|
|
310
|
+
default: '999',
|
|
311
|
+
layouts: [
|
|
312
|
+
{ file: '200', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
|
|
313
|
+
],
|
|
314
|
+
campaigns: []
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const { queue } = manager.getScheduleQueue(durations);
|
|
318
|
+
expect(queue.map(e => e.layoutId)).toContain('200');
|
|
319
|
+
expect(queue.map(e => e.layoutId)).not.toContain('100');
|
|
292
320
|
});
|
|
293
321
|
|
|
294
|
-
it('should
|
|
322
|
+
it('should cache queue and return same result for unchanged schedule', () => {
|
|
295
323
|
manager.setSchedule({
|
|
296
324
|
default: '999',
|
|
297
325
|
layouts: [
|
|
298
|
-
{ file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) }
|
|
326
|
+
{ file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
|
|
299
327
|
],
|
|
328
|
+
campaigns: []
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const result1 = manager.getScheduleQueue(durations);
|
|
332
|
+
const result2 = manager.getScheduleQueue(durations);
|
|
333
|
+
|
|
334
|
+
expect(result1).toBe(result2); // Same reference (cached)
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should include campaign layouts in queue', () => {
|
|
338
|
+
durations.set('205', 60);
|
|
339
|
+
durations.set('203', 60);
|
|
340
|
+
manager.setSchedule({
|
|
341
|
+
default: '999',
|
|
342
|
+
layouts: [],
|
|
300
343
|
campaigns: [
|
|
301
344
|
{
|
|
302
345
|
id: '1',
|
|
@@ -304,21 +347,17 @@ describe('ScheduleManager - Default Layout Interleaving', () => {
|
|
|
304
347
|
fromdt: dateStr(-1),
|
|
305
348
|
todt: dateStr(1),
|
|
306
349
|
layouts: [
|
|
307
|
-
{ file: '
|
|
308
|
-
{ file: '
|
|
350
|
+
{ file: '205' },
|
|
351
|
+
{ file: '203' },
|
|
309
352
|
]
|
|
310
353
|
}
|
|
311
354
|
]
|
|
312
355
|
});
|
|
313
356
|
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
expect(
|
|
318
|
-
// Every odd-indexed element should be the default
|
|
319
|
-
expect(layouts[1]).toBe('999');
|
|
320
|
-
expect(layouts[3]).toBe('999');
|
|
321
|
-
expect(layouts[5]).toBe('999');
|
|
357
|
+
const { queue } = manager.getScheduleQueue(durations);
|
|
358
|
+
const layoutIds = queue.map(e => e.layoutId);
|
|
359
|
+
expect(layoutIds).toContain('205');
|
|
360
|
+
expect(layoutIds).toContain('203');
|
|
322
361
|
});
|
|
323
362
|
});
|
|
324
363
|
|
package/src/timeline.js
CHANGED
|
@@ -16,19 +16,20 @@
|
|
|
16
16
|
* 3. Fallback: 60s
|
|
17
17
|
*
|
|
18
18
|
* @param {string} xlfXml - Raw XLF XML string
|
|
19
|
-
* @returns {number} Duration in seconds
|
|
19
|
+
* @returns {{ duration: number, isDynamic: boolean }} Duration in seconds and whether any widget has useDuration=0
|
|
20
20
|
*/
|
|
21
21
|
export function parseLayoutDuration(xlfXml) {
|
|
22
22
|
const doc = new DOMParser().parseFromString(xlfXml, 'text/xml');
|
|
23
23
|
const layoutEl = doc.querySelector('layout');
|
|
24
|
-
if (!layoutEl) return 60;
|
|
24
|
+
if (!layoutEl) return { duration: 60, isDynamic: false };
|
|
25
25
|
|
|
26
26
|
// 1. Explicit layout duration attribute
|
|
27
27
|
const explicit = parseInt(layoutEl.getAttribute('duration') || '0', 10);
|
|
28
|
-
if (explicit > 0) return explicit;
|
|
28
|
+
if (explicit > 0) return { duration: explicit, isDynamic: false };
|
|
29
29
|
|
|
30
30
|
// 2. Calculate from widget durations (max region wins — regions play in parallel)
|
|
31
31
|
let maxDuration = 0;
|
|
32
|
+
let isDynamic = false;
|
|
32
33
|
for (const regionEl of layoutEl.querySelectorAll('region')) {
|
|
33
34
|
let regionDuration = 0;
|
|
34
35
|
for (const mediaEl of regionEl.querySelectorAll('media')) {
|
|
@@ -40,12 +41,14 @@ export function parseLayoutDuration(xlfXml) {
|
|
|
40
41
|
// Video with useDuration=0 means "play to end" — estimate 60s,
|
|
41
42
|
// corrected later via recordLayoutDuration() when video metadata loads
|
|
42
43
|
regionDuration += 60;
|
|
44
|
+
isDynamic = true;
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
maxDuration = Math.max(maxDuration, regionDuration);
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
const duration = maxDuration > 0 ? maxDuration : 60;
|
|
51
|
+
return { duration, isDynamic };
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
/**
|
|
@@ -267,3 +270,136 @@ export function calculateTimeline(schedule, durations, options = {}) {
|
|
|
267
270
|
|
|
268
271
|
return timeline;
|
|
269
272
|
}
|
|
273
|
+
|
|
274
|
+
// ── LCM-based deterministic schedule queue ──────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Greatest common divisor (Euclidean algorithm).
|
|
278
|
+
* @param {number} a
|
|
279
|
+
* @param {number} b
|
|
280
|
+
* @returns {number}
|
|
281
|
+
*/
|
|
282
|
+
function gcd(a, b) {
|
|
283
|
+
a = Math.abs(Math.round(a));
|
|
284
|
+
b = Math.abs(Math.round(b));
|
|
285
|
+
while (b) { [a, b] = [b, a % b]; }
|
|
286
|
+
return a;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Least common multiple of two integers.
|
|
291
|
+
* @param {number} a
|
|
292
|
+
* @param {number} b
|
|
293
|
+
* @returns {number}
|
|
294
|
+
*/
|
|
295
|
+
function lcm(a, b) {
|
|
296
|
+
if (a === 0 || b === 0) return 0;
|
|
297
|
+
return Math.abs(Math.round(a) * Math.round(b)) / gcd(a, b);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* LCM of an array of integers.
|
|
302
|
+
* @param {number[]} values
|
|
303
|
+
* @returns {number}
|
|
304
|
+
*/
|
|
305
|
+
function lcmArray(values) {
|
|
306
|
+
return values.reduce((acc, v) => lcm(acc, v), 1);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Build a deterministic playback queue by simulating one LCM period.
|
|
311
|
+
*
|
|
312
|
+
* Uses getPlayableLayouts() (the same priority-fallback + rate-limit logic
|
|
313
|
+
* that calculateTimeline uses) to simulate playback for one repeating cycle.
|
|
314
|
+
* This ensures the queue matches the timeline overlay exactly: high-priority
|
|
315
|
+
* rate-limited layouts get their slots, then lower-priority layouts fill gaps.
|
|
316
|
+
*
|
|
317
|
+
* @param {Array<{file: string, priority: number, maxPlaysPerHour: number}>} allLayouts
|
|
318
|
+
* All time-active layouts from schedule.getAllLayoutsAtTime()
|
|
319
|
+
* @param {Map<string, number>} durations
|
|
320
|
+
* Map of layoutFile → duration in seconds
|
|
321
|
+
* @param {Object} [options]
|
|
322
|
+
* @param {string} [options.defaultLayout] - Default layout file (CMS fallback)
|
|
323
|
+
* @param {number} [options.defaultDuration] - Fallback duration (default: 60)
|
|
324
|
+
* @param {Set<string>} [options.dynamicLayouts] - Set of layout files that are dynamic (video, useDuration=0)
|
|
325
|
+
* @returns {{ queue: Array<{layoutId: string, duration: number}>, periodSeconds: number }}
|
|
326
|
+
*/
|
|
327
|
+
export function buildScheduleQueue(allLayouts, durations, options = {}) {
|
|
328
|
+
const {
|
|
329
|
+
defaultLayout = null,
|
|
330
|
+
defaultDuration = 60,
|
|
331
|
+
} = options;
|
|
332
|
+
|
|
333
|
+
if (allLayouts.length === 0 && !defaultLayout) {
|
|
334
|
+
return { queue: [], periodSeconds: 0 };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Step 1: Identify rate-limited layouts to calculate LCM period
|
|
338
|
+
const rateLimited = allLayouts.filter(l => l.maxPlaysPerHour > 0);
|
|
339
|
+
|
|
340
|
+
let periodSeconds;
|
|
341
|
+
if (rateLimited.length > 0) {
|
|
342
|
+
const intervals = rateLimited.map(l => Math.round(3600 / l.maxPlaysPerHour));
|
|
343
|
+
periodSeconds = lcmArray(intervals);
|
|
344
|
+
// Cap at 2 hours to prevent absurd periods
|
|
345
|
+
if (periodSeconds > 7200) periodSeconds = 7200;
|
|
346
|
+
} else {
|
|
347
|
+
// No rate-limited layouts — single round-robin cycle
|
|
348
|
+
const totalDuration = allLayouts.reduce((sum, l) => sum + (durations.get(l.file) || defaultDuration), 0)
|
|
349
|
+
+ (defaultLayout && !allLayouts.some(l => l.file === defaultLayout)
|
|
350
|
+
? (durations.get(defaultLayout) || defaultDuration)
|
|
351
|
+
: 0);
|
|
352
|
+
periodSeconds = totalDuration || defaultDuration;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Step 2: Simulate playback for one period using getPlayableLayouts()
|
|
356
|
+
const queue = [];
|
|
357
|
+
const simPlays = new Map(); // file → [timestampMs] for rate-limit tracking
|
|
358
|
+
let cursorMs = 0;
|
|
359
|
+
const periodMs = periodSeconds * 1000;
|
|
360
|
+
const maxEntries = 500; // safety cap
|
|
361
|
+
|
|
362
|
+
while (cursorMs < periodMs && queue.length < maxEntries) {
|
|
363
|
+
// Get playable layouts at current simulated time (priority fallback + rate limits)
|
|
364
|
+
const playable = getPlayableLayouts(allLayouts, simPlays, cursorMs);
|
|
365
|
+
|
|
366
|
+
if (playable.length === 0) {
|
|
367
|
+
// All layouts exhausted — use default
|
|
368
|
+
if (defaultLayout) {
|
|
369
|
+
const dur = durations.get(defaultLayout) || defaultDuration;
|
|
370
|
+
queue.push({ layoutId: defaultLayout, duration: dur });
|
|
371
|
+
cursorMs += dur * 1000;
|
|
372
|
+
} else {
|
|
373
|
+
// No default — skip ahead 60s to avoid infinite loop
|
|
374
|
+
cursorMs += 60000;
|
|
375
|
+
}
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Play all playable layouts in round-robin order (one each), then re-evaluate
|
|
380
|
+
for (let i = 0; i < playable.length && cursorMs < periodMs && queue.length < maxEntries; i++) {
|
|
381
|
+
const file = playable[i];
|
|
382
|
+
const dur = durations.get(file) || defaultDuration;
|
|
383
|
+
|
|
384
|
+
queue.push({ layoutId: file, duration: dur });
|
|
385
|
+
|
|
386
|
+
// Record simulated play for rate-limit tracking
|
|
387
|
+
if (!simPlays.has(file)) simPlays.set(file, []);
|
|
388
|
+
simPlays.get(file).push(cursorMs);
|
|
389
|
+
|
|
390
|
+
cursorMs += dur * 1000;
|
|
391
|
+
|
|
392
|
+
// Re-evaluate after each play: if the playable set changed, break to outer loop
|
|
393
|
+
const nextPlayable = getPlayableLayouts(allLayouts, simPlays, cursorMs);
|
|
394
|
+
if (!arraysEqual(playable, nextPlayable)) break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Handle edge case: no layouts and only default
|
|
399
|
+
if (queue.length === 0 && defaultLayout) {
|
|
400
|
+
const defDur = durations.get(defaultLayout) || defaultDuration;
|
|
401
|
+
queue.push({ layoutId: defaultLayout, duration: defDur });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return { queue, periodSeconds };
|
|
405
|
+
}
|