@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.
- package/docs/README.md +102 -0
- package/docs/XIBO_CAMPAIGNS_AND_PRIORITY.md +600 -0
- package/docs/advanced-features.md +425 -0
- package/docs/integration.md +284 -0
- package/docs/interrupts-implementation.md +357 -0
- package/package.json +41 -0
- package/src/criteria.js +135 -0
- package/src/criteria.test.js +376 -0
- package/src/index.js +20 -0
- package/src/integration.test.js +351 -0
- package/src/interrupts.js +298 -0
- package/src/interrupts.test.js +482 -0
- package/src/overlays.js +174 -0
- package/src/schedule.dayparting.test.js +390 -0
- package/src/schedule.js +509 -0
- package/src/schedule.test.js +505 -0
- package/vitest.config.js +8 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests - Schedule + Interrupt Scheduler
|
|
3
|
+
* Tests the integration between @xiboplayer/schedule and interrupt processing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
7
|
+
import { ScheduleManager } from '@xiboplayer/schedule';
|
|
8
|
+
import { InterruptScheduler } from './interrupts.js';
|
|
9
|
+
|
|
10
|
+
describe('Schedule + Interrupt Integration', () => {
|
|
11
|
+
let scheduleManager;
|
|
12
|
+
let interruptScheduler;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
interruptScheduler = new InterruptScheduler();
|
|
16
|
+
scheduleManager = new ScheduleManager({ interruptScheduler });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Helper to create mock schedule
|
|
20
|
+
const createSchedule = (layouts) => ({
|
|
21
|
+
layouts: layouts.map((l, idx) => ({
|
|
22
|
+
id: l.id || idx + 1,
|
|
23
|
+
file: l.file || idx + 100,
|
|
24
|
+
duration: l.duration || 60,
|
|
25
|
+
shareOfVoice: l.shareOfVoice || 0,
|
|
26
|
+
priority: l.priority || 0,
|
|
27
|
+
fromdt: l.fromdt || '2026-01-01 00:00:00',
|
|
28
|
+
todt: l.todt || '2027-01-01 00:00:00',
|
|
29
|
+
maxPlaysPerHour: l.maxPlaysPerHour || 0
|
|
30
|
+
}))
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('Basic interrupt integration', () => {
|
|
34
|
+
it('should process interrupts automatically', () => {
|
|
35
|
+
const schedule = createSchedule([
|
|
36
|
+
{ file: 10, duration: 60, shareOfVoice: 10 }, // Interrupt
|
|
37
|
+
{ file: 20, duration: 60, shareOfVoice: 0 } // Normal
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
scheduleManager.setSchedule(schedule);
|
|
41
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
42
|
+
|
|
43
|
+
// Should have both interrupts and normal layouts
|
|
44
|
+
expect(layouts.length).toBeGreaterThan(0);
|
|
45
|
+
|
|
46
|
+
const interruptCount = layouts.filter(f => f === 10).length;
|
|
47
|
+
const normalCount = layouts.filter(f => f === 20).length;
|
|
48
|
+
|
|
49
|
+
// 10% = 360s / 60s = 6 interrupt plays
|
|
50
|
+
expect(interruptCount).toBe(6);
|
|
51
|
+
|
|
52
|
+
// Remaining 3240s / 60s = 54 normal plays
|
|
53
|
+
expect(normalCount).toBe(54);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should work without interrupt scheduler', () => {
|
|
57
|
+
// Create schedule manager without interrupt scheduler
|
|
58
|
+
const basicManager = new ScheduleManager();
|
|
59
|
+
const schedule = createSchedule([
|
|
60
|
+
{ file: 10, duration: 60, shareOfVoice: 10 },
|
|
61
|
+
{ file: 20, duration: 60, shareOfVoice: 0 }
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
basicManager.setSchedule(schedule);
|
|
65
|
+
const layouts = basicManager.getCurrentLayouts();
|
|
66
|
+
|
|
67
|
+
// Without interrupt scheduler, both layouts treated equally
|
|
68
|
+
expect(layouts).toContain(10);
|
|
69
|
+
expect(layouts).toContain(20);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should handle schedule with only normal layouts', () => {
|
|
73
|
+
const schedule = createSchedule([
|
|
74
|
+
{ file: 10, duration: 60, shareOfVoice: 0 },
|
|
75
|
+
{ file: 20, duration: 60, shareOfVoice: 0 }
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
scheduleManager.setSchedule(schedule);
|
|
79
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
80
|
+
|
|
81
|
+
// Should return both files
|
|
82
|
+
expect(layouts).toContain(10);
|
|
83
|
+
expect(layouts).toContain(20);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should handle schedule with only interrupts', () => {
|
|
87
|
+
const schedule = createSchedule([
|
|
88
|
+
{ file: 10, duration: 60, shareOfVoice: 100 }
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
scheduleManager.setSchedule(schedule);
|
|
92
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
93
|
+
|
|
94
|
+
// Should fill hour with interrupts
|
|
95
|
+
expect(layouts.length).toBeGreaterThan(0);
|
|
96
|
+
expect(layouts.every(f => f === 10)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('Priority + Interrupts', () => {
|
|
101
|
+
it('should respect priority before processing interrupts', () => {
|
|
102
|
+
const schedule = createSchedule([
|
|
103
|
+
{ file: 10, duration: 60, shareOfVoice: 10, priority: 5 }, // Lower priority interrupt
|
|
104
|
+
{ file: 20, duration: 60, shareOfVoice: 0, priority: 10 } // Higher priority normal
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
scheduleManager.setSchedule(schedule);
|
|
108
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
109
|
+
|
|
110
|
+
// Only priority 10 should be included
|
|
111
|
+
expect(layouts.every(f => f === 20)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should process interrupts among same-priority layouts', () => {
|
|
115
|
+
const schedule = createSchedule([
|
|
116
|
+
{ file: 10, duration: 60, shareOfVoice: 25, priority: 10 }, // Interrupt
|
|
117
|
+
{ file: 20, duration: 60, shareOfVoice: 25, priority: 10 }, // Interrupt
|
|
118
|
+
{ file: 30, duration: 60, shareOfVoice: 0, priority: 10 } // Normal
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
scheduleManager.setSchedule(schedule);
|
|
122
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
123
|
+
|
|
124
|
+
const int1Count = layouts.filter(f => f === 10).length;
|
|
125
|
+
const int2Count = layouts.filter(f => f === 20).length;
|
|
126
|
+
const normalCount = layouts.filter(f => f === 30).length;
|
|
127
|
+
|
|
128
|
+
// Each interrupt: 25% = 900s / 60s = 15 plays
|
|
129
|
+
expect(int1Count).toBe(15);
|
|
130
|
+
expect(int2Count).toBe(15);
|
|
131
|
+
|
|
132
|
+
// Remaining: 1800s / 60s = 30 normal plays
|
|
133
|
+
expect(normalCount).toBe(30);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('Campaigns + Interrupts', () => {
|
|
138
|
+
it('should process interrupts from campaigns', () => {
|
|
139
|
+
scheduleManager.setSchedule({
|
|
140
|
+
campaigns: [
|
|
141
|
+
{
|
|
142
|
+
id: 1,
|
|
143
|
+
priority: 10,
|
|
144
|
+
fromdt: '2026-01-01 00:00:00',
|
|
145
|
+
todt: '2027-01-01 00:00:00',
|
|
146
|
+
layouts: [
|
|
147
|
+
{ file: 10, duration: 60, shareOfVoice: 10 },
|
|
148
|
+
{ file: 20, duration: 60, shareOfVoice: 0 }
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
155
|
+
|
|
156
|
+
const interruptCount = layouts.filter(f => f === 10).length;
|
|
157
|
+
const normalCount = layouts.filter(f => f === 20).length;
|
|
158
|
+
|
|
159
|
+
// Interrupt: 10% = 360s / 60s = 6 plays
|
|
160
|
+
expect(interruptCount).toBe(6);
|
|
161
|
+
|
|
162
|
+
// Normal fills remaining time
|
|
163
|
+
expect(normalCount).toBeGreaterThan(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should handle mixed campaigns and standalone interrupts', () => {
|
|
167
|
+
scheduleManager.setSchedule({
|
|
168
|
+
campaigns: [
|
|
169
|
+
{
|
|
170
|
+
id: 1,
|
|
171
|
+
priority: 10,
|
|
172
|
+
fromdt: '2026-01-01 00:00:00',
|
|
173
|
+
todt: '2027-01-01 00:00:00',
|
|
174
|
+
layouts: [
|
|
175
|
+
{ file: 10, duration: 60, shareOfVoice: 0 } // Normal in campaign
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
],
|
|
179
|
+
layouts: [
|
|
180
|
+
{
|
|
181
|
+
id: 2,
|
|
182
|
+
file: 20,
|
|
183
|
+
duration: 60,
|
|
184
|
+
shareOfVoice: 10, // Interrupt standalone
|
|
185
|
+
priority: 10,
|
|
186
|
+
fromdt: '2026-01-01 00:00:00',
|
|
187
|
+
todt: '2027-01-01 00:00:00'
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
193
|
+
|
|
194
|
+
const normalCount = layouts.filter(f => f === 10).length;
|
|
195
|
+
const interruptCount = layouts.filter(f => f === 20).length;
|
|
196
|
+
|
|
197
|
+
// Both should be present
|
|
198
|
+
expect(normalCount).toBeGreaterThan(0);
|
|
199
|
+
expect(interruptCount).toBeGreaterThan(0);
|
|
200
|
+
|
|
201
|
+
// Interrupt: 10% = 6 plays
|
|
202
|
+
expect(interruptCount).toBe(6);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('Time-based filtering + Interrupts', () => {
|
|
207
|
+
it('should filter out-of-date interrupts', () => {
|
|
208
|
+
const schedule = createSchedule([
|
|
209
|
+
{ file: 10, duration: 60, shareOfVoice: 50, todt: '2020-01-01 00:00:00' }, // Expired interrupt
|
|
210
|
+
{ file: 20, duration: 60, shareOfVoice: 0 } // Active normal
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
scheduleManager.setSchedule(schedule);
|
|
214
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
215
|
+
|
|
216
|
+
// Only normal layout should be present
|
|
217
|
+
expect(layouts.every(f => f === 20)).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should handle dayparting with interrupts', () => {
|
|
221
|
+
const now = new Date();
|
|
222
|
+
const currentHour = now.getHours();
|
|
223
|
+
const fromTime = new Date();
|
|
224
|
+
fromTime.setHours(currentHour - 1, 0, 0, 0);
|
|
225
|
+
const toTime = new Date();
|
|
226
|
+
toTime.setHours(currentHour + 1, 0, 0, 0);
|
|
227
|
+
|
|
228
|
+
scheduleManager.setSchedule({
|
|
229
|
+
layouts: [
|
|
230
|
+
{
|
|
231
|
+
id: 1,
|
|
232
|
+
file: 10,
|
|
233
|
+
duration: 60,
|
|
234
|
+
shareOfVoice: 20,
|
|
235
|
+
priority: 0,
|
|
236
|
+
fromdt: fromTime.toISOString(),
|
|
237
|
+
todt: toTime.toISOString(),
|
|
238
|
+
recurrenceType: 'Week',
|
|
239
|
+
recurrenceRepeatsOn: '1,2,3,4,5,6,7' // All days
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: 2,
|
|
243
|
+
file: 20,
|
|
244
|
+
duration: 60,
|
|
245
|
+
shareOfVoice: 0,
|
|
246
|
+
priority: 0,
|
|
247
|
+
fromdt: fromTime.toISOString(),
|
|
248
|
+
todt: toTime.toISOString()
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
254
|
+
|
|
255
|
+
// Should have both layouts
|
|
256
|
+
expect(layouts.length).toBeGreaterThan(0);
|
|
257
|
+
|
|
258
|
+
const interruptCount = layouts.filter(f => f === 10).length;
|
|
259
|
+
|
|
260
|
+
// 20% = 720s / 60s = 12 plays
|
|
261
|
+
expect(interruptCount).toBe(12);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('maxPlaysPerHour + Interrupts', () => {
|
|
266
|
+
it('should respect maxPlaysPerHour for normal layouts', () => {
|
|
267
|
+
const schedule = createSchedule([
|
|
268
|
+
{ file: 10, duration: 60, shareOfVoice: 10 }, // Interrupt
|
|
269
|
+
{ file: 20, duration: 60, shareOfVoice: 0, maxPlaysPerHour: 5 } // Normal with limit
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
scheduleManager.setSchedule(schedule);
|
|
273
|
+
|
|
274
|
+
// Record plays to trigger maxPlaysPerHour
|
|
275
|
+
for (let i = 0; i < 5; i++) {
|
|
276
|
+
scheduleManager.recordPlay(2); // Layout ID 2
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
280
|
+
|
|
281
|
+
// Normal layout should be filtered out
|
|
282
|
+
const normalCount = layouts.filter(f => f === 20).length;
|
|
283
|
+
expect(normalCount).toBe(0);
|
|
284
|
+
|
|
285
|
+
// Only interrupts should remain
|
|
286
|
+
expect(layouts.every(f => f === 10)).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('Real-world scenarios', () => {
|
|
291
|
+
it('should handle typical digital signage with 5% ads', () => {
|
|
292
|
+
const schedule = createSchedule([
|
|
293
|
+
{ file: 100, duration: 120, shareOfVoice: 5 }, // 5% ads
|
|
294
|
+
{ file: 200, duration: 120, shareOfVoice: 0 }, // Content 1
|
|
295
|
+
{ file: 300, duration: 120, shareOfVoice: 0 } // Content 2
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
scheduleManager.setSchedule(schedule);
|
|
299
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
300
|
+
|
|
301
|
+
const adCount = layouts.filter(f => f === 100).length;
|
|
302
|
+
const adDuration = adCount * 120;
|
|
303
|
+
|
|
304
|
+
// 5% of hour = 180 seconds (may overshoot slightly due to rounding)
|
|
305
|
+
expect(adDuration).toBeGreaterThanOrEqual(180);
|
|
306
|
+
expect(adDuration).toBeLessThan(300); // Reasonable upper bound
|
|
307
|
+
|
|
308
|
+
// Should have content layouts
|
|
309
|
+
expect(layouts.filter(f => f === 200).length).toBeGreaterThan(0);
|
|
310
|
+
expect(layouts.filter(f => f === 300).length).toBeGreaterThan(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should handle lunch hour with special promotions', () => {
|
|
314
|
+
const schedule = createSchedule([
|
|
315
|
+
{ file: 1000, duration: 30, shareOfVoice: 20 }, // Lunch specials: 20%
|
|
316
|
+
{ file: 2000, duration: 120, shareOfVoice: 0 } // Regular menu
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
scheduleManager.setSchedule(schedule);
|
|
320
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
321
|
+
|
|
322
|
+
const specialCount = layouts.filter(f => f === 1000).length;
|
|
323
|
+
const specialDuration = specialCount * 30;
|
|
324
|
+
|
|
325
|
+
// 20% of hour = 720 seconds
|
|
326
|
+
expect(specialDuration).toBe(720);
|
|
327
|
+
|
|
328
|
+
// Should have regular content
|
|
329
|
+
expect(layouts.filter(f => f === 2000).length).toBeGreaterThan(0);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should handle emergency alerts (high shareOfVoice)', () => {
|
|
333
|
+
const schedule = createSchedule([
|
|
334
|
+
{ file: 9999, duration: 60, shareOfVoice: 75 }, // Emergency alert: 75%
|
|
335
|
+
{ file: 1000, duration: 60, shareOfVoice: 0 } // Normal content
|
|
336
|
+
]);
|
|
337
|
+
|
|
338
|
+
scheduleManager.setSchedule(schedule);
|
|
339
|
+
const layouts = scheduleManager.getCurrentLayouts();
|
|
340
|
+
|
|
341
|
+
const alertCount = layouts.filter(f => f === 9999).length;
|
|
342
|
+
const contentCount = layouts.filter(f => f === 1000).length;
|
|
343
|
+
|
|
344
|
+
// Alert: 75% = 2700s / 60s = 45 plays
|
|
345
|
+
expect(alertCount).toBe(45);
|
|
346
|
+
|
|
347
|
+
// Content: 25% = 900s / 60s = 15 plays
|
|
348
|
+
expect(contentCount).toBe(15);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interrupt Layout Scheduler (Share of Voice)
|
|
3
|
+
*
|
|
4
|
+
* Implements the shareOfVoice algorithm from upstream electron-player.
|
|
5
|
+
* Interrupts are layouts that must play for a percentage of each hour.
|
|
6
|
+
*
|
|
7
|
+
* Algorithm:
|
|
8
|
+
* 1. Separate interrupts from normal layouts
|
|
9
|
+
* 2. Calculate how many times each interrupt must play per hour
|
|
10
|
+
* 3. Fill remaining time with normal layouts
|
|
11
|
+
* 4. Interleave interrupts and normal layouts evenly
|
|
12
|
+
*
|
|
13
|
+
* Based on: electron-player/src/main/common/scheduleManager.ts (lines 181-321)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
17
|
+
|
|
18
|
+
const logger = createLogger('schedule:interrupts');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Interrupt Scheduler
|
|
22
|
+
* Handles shareOfVoice layouts that must play for a percentage of each hour
|
|
23
|
+
*/
|
|
24
|
+
export class InterruptScheduler {
|
|
25
|
+
constructor() {
|
|
26
|
+
// Track committed duration per interrupt layout
|
|
27
|
+
this.interruptCommittedDurations = new Map(); // layoutId -> seconds
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if a layout is an interrupt (has shareOfVoice > 0)
|
|
32
|
+
* @param {Object} layout - Layout object with shareOfVoice property
|
|
33
|
+
* @returns {boolean} True if layout is an interrupt
|
|
34
|
+
*/
|
|
35
|
+
isInterrupt(layout) {
|
|
36
|
+
return !!(layout.shareOfVoice && layout.shareOfVoice > 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reset committed duration tracking (call this every hour)
|
|
41
|
+
*/
|
|
42
|
+
resetCommittedDurations() {
|
|
43
|
+
this.interruptCommittedDurations.clear();
|
|
44
|
+
logger.debug('Reset interrupt committed durations');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get committed duration for a layout
|
|
49
|
+
* @param {string} layoutId - Layout ID
|
|
50
|
+
* @returns {number} Committed duration in seconds
|
|
51
|
+
*/
|
|
52
|
+
getCommittedDuration(layoutId) {
|
|
53
|
+
return this.interruptCommittedDurations.get(layoutId) || 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Add committed duration for a layout
|
|
58
|
+
* @param {string} layoutId - Layout ID
|
|
59
|
+
* @param {number} duration - Duration to add in seconds
|
|
60
|
+
*/
|
|
61
|
+
addCommittedDuration(layoutId, duration) {
|
|
62
|
+
const current = this.getCommittedDuration(layoutId);
|
|
63
|
+
this.interruptCommittedDurations.set(layoutId, current + duration);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if interrupt layout has satisfied its shareOfVoice requirement
|
|
68
|
+
* @param {Object} layout - Layout with shareOfVoice and duration
|
|
69
|
+
* @returns {boolean} True if satisfied
|
|
70
|
+
*/
|
|
71
|
+
isInterruptDurationSatisfied(layout) {
|
|
72
|
+
if (!layout.shareOfVoice) {
|
|
73
|
+
return true; // Not an interrupt
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const layoutId = layout.id || layout.file;
|
|
77
|
+
const requiredSeconds = (layout.shareOfVoice / 100) * 3600; // shareOfVoice is percentage
|
|
78
|
+
const committedSeconds = this.getCommittedDuration(layoutId);
|
|
79
|
+
|
|
80
|
+
return committedSeconds >= requiredSeconds;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Calculate how many seconds this interrupt needs to play per hour
|
|
85
|
+
* @param {Object} layout - Layout with shareOfVoice
|
|
86
|
+
* @returns {number} Required seconds per hour
|
|
87
|
+
*/
|
|
88
|
+
getRequiredSeconds(layout) {
|
|
89
|
+
if (!layout.shareOfVoice) {
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
return (layout.shareOfVoice / 100) * 3600;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Process interrupt layouts and combine with normal layouts
|
|
97
|
+
* Implements the shareOfVoice algorithm from upstream
|
|
98
|
+
*
|
|
99
|
+
* @param {Array} normalLayouts - Normal scheduled layouts
|
|
100
|
+
* @param {Array} interruptLayouts - Interrupt layouts with shareOfVoice
|
|
101
|
+
* @returns {Array} Combined layout loop for the hour
|
|
102
|
+
*/
|
|
103
|
+
processInterrupts(normalLayouts, interruptLayouts) {
|
|
104
|
+
if (!interruptLayouts || interruptLayouts.length === 0) {
|
|
105
|
+
logger.debug('No interrupt layouts, returning normal layouts');
|
|
106
|
+
return normalLayouts;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!normalLayouts || normalLayouts.length === 0) {
|
|
110
|
+
logger.warn('No normal layouts available, interrupts will fill entire hour');
|
|
111
|
+
return this.fillHourWithInterrupts(interruptLayouts);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
logger.info(`Processing ${interruptLayouts.length} interrupt layouts with ${normalLayouts.length} normal layouts`);
|
|
115
|
+
|
|
116
|
+
// Reset committed durations for this calculation
|
|
117
|
+
for (const layout of interruptLayouts) {
|
|
118
|
+
const layoutId = layout.id || layout.file;
|
|
119
|
+
this.interruptCommittedDurations.set(layoutId, 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const resolvedInterruptLayouts = [];
|
|
123
|
+
let interruptSecondsInHour = 0;
|
|
124
|
+
let index = 0;
|
|
125
|
+
let satisfied = false;
|
|
126
|
+
|
|
127
|
+
// Step 1: Build interrupt loop by cycling through interrupts until all are satisfied
|
|
128
|
+
while (!satisfied) {
|
|
129
|
+
// Gone all the way around? Check if all satisfied
|
|
130
|
+
if (index >= interruptLayouts.length) {
|
|
131
|
+
index = 0;
|
|
132
|
+
|
|
133
|
+
// Check if all interrupts are satisfied
|
|
134
|
+
let allSatisfied = true;
|
|
135
|
+
for (const layout of interruptLayouts) {
|
|
136
|
+
if (!this.isInterruptDurationSatisfied(layout)) {
|
|
137
|
+
allSatisfied = false;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (allSatisfied) {
|
|
143
|
+
satisfied = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const currentInterrupt = interruptLayouts[index];
|
|
149
|
+
|
|
150
|
+
// If this interrupt is not satisfied, add it to the loop
|
|
151
|
+
if (!this.isInterruptDurationSatisfied(currentInterrupt)) {
|
|
152
|
+
const layoutId = currentInterrupt.id || currentInterrupt.file;
|
|
153
|
+
this.addCommittedDuration(layoutId, currentInterrupt.duration);
|
|
154
|
+
interruptSecondsInHour += currentInterrupt.duration;
|
|
155
|
+
resolvedInterruptLayouts.push(currentInterrupt);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
index++;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
logger.debug(`Resolved ${resolvedInterruptLayouts.length} interrupt plays (${interruptSecondsInHour}s total)`);
|
|
162
|
+
|
|
163
|
+
// Step 2: If interrupts fill the entire hour, return only interrupts
|
|
164
|
+
if (interruptSecondsInHour >= 3600) {
|
|
165
|
+
logger.info('Interrupts fill entire hour (>= 3600s), no room for normal layouts');
|
|
166
|
+
return resolvedInterruptLayouts;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Step 3: Fill remaining time with normal layouts
|
|
170
|
+
const normalSecondsInHour = 3600 - interruptSecondsInHour;
|
|
171
|
+
const resolvedNormalLayouts = this.fillTimeWithLayouts(normalLayouts, normalSecondsInHour);
|
|
172
|
+
|
|
173
|
+
logger.debug(`Resolved ${resolvedNormalLayouts.length} normal plays (${normalSecondsInHour}s target)`);
|
|
174
|
+
|
|
175
|
+
// Step 4: Interleave interrupts and normal layouts
|
|
176
|
+
const loop = this.interleaveLayouts(resolvedNormalLayouts, resolvedInterruptLayouts);
|
|
177
|
+
|
|
178
|
+
logger.info(`Final loop: ${loop.length} layouts (${resolvedNormalLayouts.length} normal + ${resolvedInterruptLayouts.length} interrupts)`);
|
|
179
|
+
|
|
180
|
+
return loop;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Fill time with layouts by repeating them until duration is reached
|
|
185
|
+
* @param {Array} layouts - Layouts to use
|
|
186
|
+
* @param {number} targetSeconds - Target duration in seconds
|
|
187
|
+
* @returns {Array} Resolved layout array
|
|
188
|
+
*/
|
|
189
|
+
fillTimeWithLayouts(layouts, targetSeconds) {
|
|
190
|
+
const resolved = [];
|
|
191
|
+
let remainingSeconds = targetSeconds;
|
|
192
|
+
let index = 0;
|
|
193
|
+
|
|
194
|
+
while (remainingSeconds > 0) {
|
|
195
|
+
if (index >= layouts.length) {
|
|
196
|
+
index = 0; // Loop back
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const layout = layouts[index];
|
|
200
|
+
resolved.push(layout);
|
|
201
|
+
remainingSeconds -= layout.duration;
|
|
202
|
+
index++;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return resolved;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Fill entire hour with interrupt layouts only
|
|
210
|
+
* @param {Array} interruptLayouts - Interrupt layouts
|
|
211
|
+
* @returns {Array} Layout loop
|
|
212
|
+
*/
|
|
213
|
+
fillHourWithInterrupts(interruptLayouts) {
|
|
214
|
+
return this.fillTimeWithLayouts(interruptLayouts, 3600);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Interleave normal and interrupt layouts evenly
|
|
219
|
+
* Based on upstream algorithm (scheduleManager.ts lines 268-316)
|
|
220
|
+
*
|
|
221
|
+
* @param {Array} normalLayouts - Normal layouts
|
|
222
|
+
* @param {Array} interruptLayouts - Interrupt layouts
|
|
223
|
+
* @returns {Array} Interleaved layout array
|
|
224
|
+
*/
|
|
225
|
+
interleaveLayouts(normalLayouts, interruptLayouts) {
|
|
226
|
+
const loop = [];
|
|
227
|
+
const pickCount = Math.max(normalLayouts.length, interruptLayouts.length);
|
|
228
|
+
|
|
229
|
+
// Calculate pick intervals
|
|
230
|
+
// Normal: ceiling (pick more often from normal)
|
|
231
|
+
// Interrupt: floor (pick less often from interrupts)
|
|
232
|
+
const normalPick = Math.ceil(1.0 * pickCount / normalLayouts.length);
|
|
233
|
+
const interruptPick = Math.floor(1.0 * pickCount / interruptLayouts.length);
|
|
234
|
+
|
|
235
|
+
logger.debug(`Interleaving: pickCount=${pickCount}, normalPick=${normalPick}, interruptPick=${interruptPick}`);
|
|
236
|
+
|
|
237
|
+
let normalIndex = 0;
|
|
238
|
+
let interruptIndex = 0;
|
|
239
|
+
let totalSecondsAllocated = 0;
|
|
240
|
+
|
|
241
|
+
for (let i = 0; i < pickCount; i++) {
|
|
242
|
+
// Pick from normal list
|
|
243
|
+
if (i % normalPick === 0) {
|
|
244
|
+
// Allow wrapping around
|
|
245
|
+
if (normalIndex >= normalLayouts.length) {
|
|
246
|
+
normalIndex = 0;
|
|
247
|
+
}
|
|
248
|
+
loop.push(normalLayouts[normalIndex]);
|
|
249
|
+
totalSecondsAllocated += normalLayouts[normalIndex].duration;
|
|
250
|
+
normalIndex++;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Pick from interrupt list (only if we haven't picked them all yet)
|
|
254
|
+
if (i % interruptPick === 0 && interruptIndex < interruptLayouts.length) {
|
|
255
|
+
loop.push(interruptLayouts[interruptIndex]);
|
|
256
|
+
totalSecondsAllocated += interruptLayouts[interruptIndex].duration;
|
|
257
|
+
interruptIndex++;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Fill remaining time with normal layouts (due to ceiling/floor rounding)
|
|
262
|
+
while (totalSecondsAllocated < 3600) {
|
|
263
|
+
if (normalIndex >= normalLayouts.length) {
|
|
264
|
+
normalIndex = 0;
|
|
265
|
+
}
|
|
266
|
+
loop.push(normalLayouts[normalIndex]);
|
|
267
|
+
totalSecondsAllocated += normalLayouts[normalIndex].duration;
|
|
268
|
+
normalIndex++;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
logger.debug(`Interleaved ${loop.length} layouts, total duration: ${totalSecondsAllocated}s`);
|
|
272
|
+
|
|
273
|
+
return loop;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Separate layouts into normal and interrupt arrays
|
|
278
|
+
* @param {Array} layouts - All layouts
|
|
279
|
+
* @returns {Object} { normalLayouts, interruptLayouts }
|
|
280
|
+
*/
|
|
281
|
+
separateLayouts(layouts) {
|
|
282
|
+
const normalLayouts = [];
|
|
283
|
+
const interruptLayouts = [];
|
|
284
|
+
|
|
285
|
+
for (const layout of layouts) {
|
|
286
|
+
if (this.isInterrupt(layout)) {
|
|
287
|
+
interruptLayouts.push(layout);
|
|
288
|
+
} else {
|
|
289
|
+
normalLayouts.push(layout);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { normalLayouts, interruptLayouts };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Export singleton instance for convenience
|
|
298
|
+
export const interruptScheduler = new InterruptScheduler();
|