eyevinn-channel-engine 3.2.1 → 3.2.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/engine/session.js +19 -6
- package/engine/stream_switcher.js +337 -197
- package/engine/util.js +67 -0
- package/package.json +1 -1
- package/server-livemix.js +6 -1
- package/spec/engine/init_switching_spec.js +3 -0
package/engine/session.js
CHANGED
|
@@ -289,6 +289,14 @@ class Session {
|
|
|
289
289
|
this.switchDataForSession.transitionSegments = segments;
|
|
290
290
|
this.switchDataForSession.mediaSeqOffset = mSeqOffset;
|
|
291
291
|
|
|
292
|
+
let waitTimeMs = 2000;
|
|
293
|
+
for (let i = segments[Object.keys(segments)[0]].length - 1; 0 < i; i--) {
|
|
294
|
+
if (segments[Object.keys(segments)[0]][i].duration) {
|
|
295
|
+
waitTimeMs = parseInt(1000 * (segments[Object.keys(segments)[0]][i].duration / 3), 10);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
292
300
|
let isLeader = await this._sessionStateStore.isLeader(this._instanceId);
|
|
293
301
|
if (!isLeader) {
|
|
294
302
|
debug(`[${this._sessionId}]: FOLLOWER: Invalidate cache to ensure having the correct VOD!`);
|
|
@@ -315,14 +323,14 @@ class Session {
|
|
|
315
323
|
}
|
|
316
324
|
debug(`[${this._sessionId}]: NEW LEADER: Setting state=VOD_RELOAD_INIT`);
|
|
317
325
|
this.isSwitchingBackToV2L = true;
|
|
318
|
-
await this._sessionState.set("state", SessionState.VOD_RELOAD_INIT)
|
|
326
|
+
await this._sessionState.set("state", SessionState.VOD_RELOAD_INIT);
|
|
319
327
|
|
|
320
328
|
} else {
|
|
321
329
|
let vodReloaded = await this._sessionState.get("vodReloaded");
|
|
322
|
-
let attempts =
|
|
330
|
+
let attempts = 12;
|
|
323
331
|
while (!vodReloaded && attempts > 0) {
|
|
324
|
-
debug(`[${this._sessionId}]: LEADER: Waiting (
|
|
325
|
-
await timer(
|
|
332
|
+
debug(`[${this._sessionId}]: LEADER: Waiting (${waitTimeMs}ms) to buy some time reloading vod and adding it to store! (tries left=${attempts})`);
|
|
333
|
+
await timer(waitTimeMs);
|
|
326
334
|
vodReloaded = await this._sessionState.get("vodReloaded");
|
|
327
335
|
attempts--;
|
|
328
336
|
}
|
|
@@ -333,7 +341,7 @@ class Session {
|
|
|
333
341
|
}
|
|
334
342
|
}
|
|
335
343
|
|
|
336
|
-
async getCurrentMediaSequenceSegments() {
|
|
344
|
+
async getCurrentMediaSequenceSegments(opts) {
|
|
337
345
|
if (!this._sessionState) {
|
|
338
346
|
throw new Error('Session not ready');
|
|
339
347
|
}
|
|
@@ -353,7 +361,10 @@ class Session {
|
|
|
353
361
|
state = await this.getSessionState();
|
|
354
362
|
}
|
|
355
363
|
|
|
356
|
-
const playheadState = await this._playheadState.getValues(["
|
|
364
|
+
const playheadState = await this._playheadState.getValues(["vodMediaSeqVideo"]);
|
|
365
|
+
if (opts && opts.targetMseq) {
|
|
366
|
+
playheadState.vodMediaSeqVideo = opts.targetMseq;
|
|
367
|
+
}
|
|
357
368
|
|
|
358
369
|
// NOTE: Assume that VOD cache was already cleared in 'getCurrentMediaAndDiscSequenceCount()'
|
|
359
370
|
// and that we now have access to the correct vod cache
|
|
@@ -418,6 +429,7 @@ class Session {
|
|
|
418
429
|
return {
|
|
419
430
|
'mediaSeq': (playheadState.mediaSeq + playheadState.vodMediaSeqVideo),
|
|
420
431
|
'discSeq': discSeqCount,
|
|
432
|
+
'vodMediaSeqVideo': playheadState.vodMediaSeqVideo,
|
|
421
433
|
};
|
|
422
434
|
} catch (err) {
|
|
423
435
|
logerror(this._sessionId, err);
|
|
@@ -961,6 +973,7 @@ class Session {
|
|
|
961
973
|
if (!isLeader) {
|
|
962
974
|
debug(`[${this._sessionId}]: not the leader so just waiting for the VOD to be initiated`);
|
|
963
975
|
}
|
|
976
|
+
this.alreadyClearedVodCache = false;
|
|
964
977
|
return;
|
|
965
978
|
case SessionState.VOD_NEXT_INIT:
|
|
966
979
|
try {
|
|
@@ -2,8 +2,9 @@ const debug = require("debug")("engine-stream-switcher");
|
|
|
2
2
|
const crypto = require("crypto");
|
|
3
3
|
const fetch = require("node-fetch");
|
|
4
4
|
const { AbortController } = require("abort-controller");
|
|
5
|
-
const { SessionState } = require(
|
|
6
|
-
const { timer } = require("./util")
|
|
5
|
+
const { SessionState } = require("./session_state");
|
|
6
|
+
const { timer, findNearestValue, isValidUrl, fetchWithRetry } = require("./util");
|
|
7
|
+
const m3u8 = require("@eyevinn/m3u8");
|
|
7
8
|
|
|
8
9
|
const SwitcherState = Object.freeze({
|
|
9
10
|
V2L_TO_LIVE: 1,
|
|
@@ -31,8 +32,6 @@ class StreamSwitcher {
|
|
|
31
32
|
this.working = false;
|
|
32
33
|
this.timeDiff = null;
|
|
33
34
|
this.abortTimeStamp = null;
|
|
34
|
-
|
|
35
|
-
|
|
36
35
|
if (config) {
|
|
37
36
|
if (config.sessionId) {
|
|
38
37
|
this.sessionId = config.sessionId;
|
|
@@ -47,6 +46,7 @@ class StreamSwitcher {
|
|
|
47
46
|
this.streamSwitchManager = config.streamSwitchManager;
|
|
48
47
|
}
|
|
49
48
|
}
|
|
49
|
+
this.prerollsCache = {};
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
getEventId() {
|
|
@@ -59,12 +59,7 @@ class StreamSwitcher {
|
|
|
59
59
|
debug(`[${this.sessionId}]: Abort Live Stream! Reason: ${message}`);
|
|
60
60
|
try {
|
|
61
61
|
this.abortTimeStamp = Date.now();
|
|
62
|
-
status = await this._initSwitching(
|
|
63
|
-
SwitcherState.LIVE_TO_V2L,
|
|
64
|
-
session,
|
|
65
|
-
sessionLive,
|
|
66
|
-
null
|
|
67
|
-
);
|
|
62
|
+
status = await this._initSwitching(SwitcherState.LIVE_TO_V2L, session, sessionLive, null);
|
|
68
63
|
return status;
|
|
69
64
|
} catch (err) {
|
|
70
65
|
debug(`Failed to force a switch off live feed: ${err}`);
|
|
@@ -89,158 +84,135 @@ class StreamSwitcher {
|
|
|
89
84
|
debug(`[${this.sessionId}]: streamSwitcher is currently busy`);
|
|
90
85
|
return null;
|
|
91
86
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
this.streamTypeLive
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Handle Complete Storage Reset
|
|
90
|
+
let sessionState = await session.getSessionState();
|
|
91
|
+
if (sessionState === SessionState.VOD_INIT || !sessionState) {
|
|
92
|
+
this.working = true;
|
|
93
|
+
sessionLive.waitForPlayhead = false;
|
|
94
|
+
sessionLive.allowedToSet = false; // only have effect on leader
|
|
95
|
+
await sessionLive.resetSession();
|
|
96
|
+
await sessionLive.resetLiveStoreAsync(0);
|
|
97
|
+
this.working = false;
|
|
98
|
+
this.abortTimeStamp = Date.now() + 30 * 1000; // 30 second V2L->LIVE timeout
|
|
99
|
+
if (this.streamTypeLive) {
|
|
100
|
+
debug(`[${this.sessionId}]: [ Ending LIVE Abruptly, Going to -> V2L ]`);
|
|
101
|
+
this.streamTypeLive = false;
|
|
102
|
+
}
|
|
103
|
+
debug(`[${this.sessionId}]: StreamSwitcher reacting to Full Store Reset`);
|
|
104
|
+
return false; // Go to V2L feed
|
|
105
105
|
}
|
|
106
|
-
debug(`[${this.sessionId}]: StreamSwitcher reacting to Full Store Reset`);
|
|
107
|
-
return false; // Go to V2L feed
|
|
108
|
-
}
|
|
109
106
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
107
|
+
// Filter out schedule objects from the past
|
|
108
|
+
const tsNow = Date.now();
|
|
109
|
+
const strmSchedule = await this.streamSwitchManager.getSchedule(this.sessionId);
|
|
110
|
+
const schedule = strmSchedule.filter((obj) => obj.end_time > tsNow);
|
|
111
|
+
|
|
112
|
+
// Load Preroll, if any, once per channel every (maxAge) seconds
|
|
113
|
+
if (!this.prerollsCache[this.sessionId] || this.prerollsCache[this.sessionId].maxAge < tsNow) {
|
|
114
|
+
if (this.streamSwitchManager.getPrerollUri) {
|
|
115
|
+
const prerollUri = await this.streamSwitchManager.getPrerollUri(this.sessionId);
|
|
116
|
+
if (isValidUrl(prerollUri)) {
|
|
117
|
+
try {
|
|
118
|
+
const segments = await this._loadPreroll(prerollUri);
|
|
119
|
+
const prerollItem = {
|
|
120
|
+
segments: segments,
|
|
121
|
+
maxAge: tsNow + 30 * 60 * 1000,
|
|
122
|
+
};
|
|
123
|
+
this.prerollsCache[this.sessionId] = prerollItem;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
debug(`[${this.sessionId}]: Failed loading preroll vod for channel=${this.sessionId}`);
|
|
126
|
+
console.error(err);
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
debug(`[${this.sessionId}]: Preroll uri:'${prerollUri}' is not a valid URL. Using No preroll.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (schedule.length === 0 && this.streamTypeLive) {
|
|
135
|
+
status = await this._initSwitching(SwitcherState.LIVE_TO_V2L, session, sessionLive, null);
|
|
137
136
|
return status;
|
|
138
137
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
if (schedule.length === 0) {
|
|
139
|
+
this.eventId = null;
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const scheduleObj = schedule[0];
|
|
143
|
+
this.timeDiff = scheduleObj;
|
|
144
|
+
if (tsNow < scheduleObj.start_time) {
|
|
145
|
+
if (this.streamTypeLive) {
|
|
146
|
+
status = await this._initSwitching(SwitcherState.LIVE_TO_V2L, session, sessionLive, null);
|
|
147
|
+
return status;
|
|
148
|
+
}
|
|
149
|
+
this.eventId = null;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
142
152
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
let tries = 0;
|
|
154
|
+
let validURI = false;
|
|
155
|
+
while (!validURI && tries < MAX_FAILS) {
|
|
156
|
+
debug(`[${this.sessionId}]: Switcher is validating Master URI... (tries left=${MAX_FAILS - tries})`);
|
|
157
|
+
validURI = await this._validURI(scheduleObj.uri);
|
|
158
|
+
tries++;
|
|
159
|
+
if (!validURI) {
|
|
160
|
+
const delayMs = tries * 500;
|
|
161
|
+
debug(`[${this.sessionId}]: Going to try validating Master URI again in ${delayMs}ms`);
|
|
162
|
+
await timer(delayMs);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
155
165
|
if (!validURI) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
debug(`[${this.sessionId}]: Unreachable URI: [${scheduleObj.uri}]`);
|
|
167
|
+
if (this.streamTypeLive) {
|
|
168
|
+
await this.abortLiveFeed(session, sessionLive, "Switching back to VOD2Live due to unreachable URI");
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
159
171
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
"Switching back to VOD2Live due to unreachable URI"
|
|
168
|
-
);
|
|
172
|
+
debug(`[${this.sessionId}]: ....Master URI -> VALID`);
|
|
173
|
+
|
|
174
|
+
if (this.abortTimeStamp && tsNow - this.abortTimeStamp <= 10000) {
|
|
175
|
+
// If we have a valid URI and no more than 10 seconds have passed since switching from Live->V2L.
|
|
176
|
+
// Stay on V2L to give live sessionLive some time to prepare before switching back to live.
|
|
177
|
+
debug(`[${this.sessionId}]: Waiting [${10000 - (tsNow - this.abortTimeStamp)}ms] before switching back to Live due to unreachable URI`);
|
|
178
|
+
return false;
|
|
169
179
|
}
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
debug(`[${this.sessionId}]: ....Master URI -> VALID`);
|
|
173
|
-
|
|
174
|
-
if (this.abortTimeStamp && tsNow - this.abortTimeStamp <= 10000) {
|
|
175
|
-
// If we have a valid URI and no more than 10 seconds have passed since switching from Live->V2L.
|
|
176
|
-
// Stay on V2L to give live sessionLive some time to prepare before switching back to live.
|
|
177
|
-
debug(
|
|
178
|
-
`[${this.sessionId}]: Waiting [${
|
|
179
|
-
10000 - (tsNow - this.abortTimeStamp)
|
|
180
|
-
}ms] before switching back to Live due to unreachable URI`
|
|
181
|
-
);
|
|
182
|
-
return false;
|
|
183
|
-
}
|
|
184
|
-
this.abortTimeStamp = null;
|
|
180
|
+
this.abortTimeStamp = null;
|
|
185
181
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
status = await this._initSwitching(
|
|
193
|
-
SwitcherState.LIVE_TO_LIVE,
|
|
194
|
-
session,
|
|
195
|
-
sessionLive,
|
|
196
|
-
scheduleObj
|
|
197
|
-
);
|
|
182
|
+
if (this.streamTypeLive) {
|
|
183
|
+
if (tsNow >= scheduleObj.start_time && this.eventId !== scheduleObj.eventId) {
|
|
184
|
+
if (scheduleObj.type === StreamType.LIVE) {
|
|
185
|
+
status = await this._initSwitching(SwitcherState.LIVE_TO_LIVE, session, sessionLive, scheduleObj);
|
|
186
|
+
return status;
|
|
187
|
+
}
|
|
188
|
+
status = await this._initSwitching(SwitcherState.LIVE_TO_VOD, session, sessionLive, scheduleObj);
|
|
198
189
|
return status;
|
|
199
190
|
}
|
|
200
|
-
status = await this._initSwitching(
|
|
201
|
-
SwitcherState.LIVE_TO_VOD,
|
|
202
|
-
session,
|
|
203
|
-
sessionLive,
|
|
204
|
-
scheduleObj
|
|
205
|
-
);
|
|
206
|
-
return status;
|
|
207
191
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (!this.streamTypeLive) {
|
|
216
|
-
status = await this._initSwitching(
|
|
217
|
-
SwitcherState.V2L_TO_LIVE,
|
|
218
|
-
session,
|
|
219
|
-
sessionLive,
|
|
220
|
-
scheduleObj
|
|
221
|
-
);
|
|
222
|
-
return status;
|
|
192
|
+
if (tsNow >= scheduleObj.start_time && tsNow < scheduleObj.end_time && scheduleObj.end_time - tsNow > 10000) {
|
|
193
|
+
if (scheduleObj.type === StreamType.LIVE) {
|
|
194
|
+
if (!this.streamTypeLive) {
|
|
195
|
+
status = await this._initSwitching(SwitcherState.V2L_TO_LIVE, session, sessionLive, scheduleObj);
|
|
196
|
+
return status;
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
223
199
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
200
|
+
if (!this.streamTypeLive) {
|
|
201
|
+
if (!scheduleObj.duration) {
|
|
202
|
+
debug(`[${this.sessionId}]: Cannot switch VOD no duration specified for schedule item: [${scheduleObj.assetId}]`);
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
if (this.eventId !== scheduleObj.eventId) {
|
|
206
|
+
status = await this._initSwitching(SwitcherState.V2L_TO_VOD, session, sessionLive, scheduleObj);
|
|
207
|
+
return status;
|
|
208
|
+
}
|
|
231
209
|
return false;
|
|
232
210
|
}
|
|
233
|
-
if (this.eventId !== scheduleObj.eventId) {
|
|
234
|
-
status = await this._initSwitching(
|
|
235
|
-
SwitcherState.V2L_TO_VOD,
|
|
236
|
-
session,
|
|
237
|
-
sessionLive,
|
|
238
|
-
scheduleObj
|
|
239
|
-
);
|
|
240
|
-
return status;
|
|
241
|
-
}
|
|
242
|
-
return false;
|
|
243
211
|
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
debug(`[${this.sessionId}]: Unexpected failure in Stream Switcher...`);
|
|
214
|
+
console.error(err);
|
|
215
|
+
throw new Error(err);
|
|
244
216
|
}
|
|
245
217
|
}
|
|
246
218
|
|
|
@@ -261,15 +233,17 @@ class StreamSwitcher {
|
|
|
261
233
|
debug(`[${this.sessionId}]: [ INIT Switching from V2L->LIVE ]`);
|
|
262
234
|
this.eventId = scheduleObj.eventId;
|
|
263
235
|
currVodCounts = await session.getCurrentMediaAndDiscSequenceCount();
|
|
264
|
-
currVodSegments = await session.getCurrentMediaSequenceSegments();
|
|
236
|
+
currVodSegments = await session.getCurrentMediaSequenceSegments({ targetMseq: currVodCounts.vodMediaSeqVideo });
|
|
265
237
|
|
|
238
|
+
// Insert preroll if available for current channel
|
|
239
|
+
if (this.prerollsCache[this.sessionId]) {
|
|
240
|
+
const prerollSegments = this.prerollsCache[this.sessionId].segments;
|
|
241
|
+
currVodSegments = this._mergeSegments(prerollSegments, currVodSegments, false);
|
|
242
|
+
}
|
|
266
243
|
// In risk that the SL-playhead might have updated some data after
|
|
267
244
|
// we reset last time... we should Reset SessionLive before sending new data.
|
|
268
245
|
await sessionLive.resetLiveStoreAsync(0);
|
|
269
|
-
await sessionLive.setCurrentMediaAndDiscSequenceCount(
|
|
270
|
-
currVodCounts.mediaSeq,
|
|
271
|
-
currVodCounts.discSeq
|
|
272
|
-
);
|
|
246
|
+
await sessionLive.setCurrentMediaAndDiscSequenceCount(currVodCounts.mediaSeq, currVodCounts.discSeq);
|
|
273
247
|
await sessionLive.setCurrentMediaSequenceSegments(currVodSegments);
|
|
274
248
|
liveUri = await sessionLive.setLiveUri(scheduleObj.uri);
|
|
275
249
|
|
|
@@ -289,6 +263,7 @@ class StreamSwitcher {
|
|
|
289
263
|
this.working = false;
|
|
290
264
|
this.eventId = null;
|
|
291
265
|
debug(`[${this.sessionId}]: [ ERROR Switching from V2L->LIVE ]`);
|
|
266
|
+
console.error(err);
|
|
292
267
|
throw new Error(err);
|
|
293
268
|
}
|
|
294
269
|
|
|
@@ -297,10 +272,7 @@ class StreamSwitcher {
|
|
|
297
272
|
debug(`[${this.sessionId}]: [ INIT Switching from V2L->VOD ]`);
|
|
298
273
|
this.eventId = scheduleObj.eventId;
|
|
299
274
|
currVodCounts = await session.getCurrentMediaAndDiscSequenceCount();
|
|
300
|
-
eventSegments = await session.getTruncatedVodSegments(
|
|
301
|
-
scheduleObj.uri,
|
|
302
|
-
scheduleObj.duration / 1000
|
|
303
|
-
);
|
|
275
|
+
eventSegments = await session.getTruncatedVodSegments(scheduleObj.uri, scheduleObj.duration / 1000);
|
|
304
276
|
|
|
305
277
|
if (!eventSegments) {
|
|
306
278
|
debug(`[${this.sessionId}]: [ ERROR Switching from V2L->VOD ]`);
|
|
@@ -309,10 +281,13 @@ class StreamSwitcher {
|
|
|
309
281
|
return false;
|
|
310
282
|
}
|
|
311
283
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
284
|
+
// Insert preroll if available for current channel
|
|
285
|
+
if (this.prerollsCache[this.sessionId]) {
|
|
286
|
+
const prerollSegments = this.prerollsCache[this.sessionId].segments;
|
|
287
|
+
eventSegments = this._mergeSegments(prerollSegments, eventSegments, true);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await session.setCurrentMediaAndDiscSequenceCount(currVodCounts.mediaSeq, currVodCounts.discSeq);
|
|
316
291
|
await session.setCurrentMediaSequenceSegments(eventSegments, 0, true);
|
|
317
292
|
|
|
318
293
|
this.working = false;
|
|
@@ -336,9 +311,7 @@ class StreamSwitcher {
|
|
|
336
311
|
sessionLive.resetLiveStoreAsync(RESET_DELAY); // In parallel
|
|
337
312
|
|
|
338
313
|
if (scheduleObj && !scheduleObj.duration) {
|
|
339
|
-
debug(
|
|
340
|
-
`[${this.sessionId}]: Cannot switch VOD. No duration specified for schedule item: [${scheduleObj.assetId}]`
|
|
341
|
-
);
|
|
314
|
+
debug(`[${this.sessionId}]: Cannot switch VOD. No duration specified for schedule item: [${scheduleObj.assetId}]`);
|
|
342
315
|
}
|
|
343
316
|
|
|
344
317
|
if (this._isEmpty(liveSegments.currMseqSegs)) {
|
|
@@ -348,14 +321,15 @@ class StreamSwitcher {
|
|
|
348
321
|
return false;
|
|
349
322
|
}
|
|
350
323
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
);
|
|
324
|
+
// Insert preroll, if available, for current channel
|
|
325
|
+
if (this.prerollsCache[this.sessionId]) {
|
|
326
|
+
const prerollSegments = this.prerollsCache[this.sessionId].segments;
|
|
327
|
+
liveSegments.currMseqSegs = this._mergeSegments(prerollSegments, liveSegments.currMseqSegs, false);
|
|
328
|
+
liveSegments.segCount += prerollSegments.length;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
await session.setCurrentMediaAndDiscSequenceCount(liveCounts.mediaSeq, liveCounts.discSeq);
|
|
332
|
+
await session.setCurrentMediaSequenceSegments(liveSegments.currMseqSegs, liveSegments.segCount);
|
|
359
333
|
|
|
360
334
|
this.working = false;
|
|
361
335
|
this.streamTypeLive = false;
|
|
@@ -378,10 +352,7 @@ class StreamSwitcher {
|
|
|
378
352
|
await sessionLive.resetSession();
|
|
379
353
|
sessionLive.resetLiveStoreAsync(RESET_DELAY); // In parallel
|
|
380
354
|
|
|
381
|
-
eventSegments = await session.getTruncatedVodSegments(
|
|
382
|
-
scheduleObj.uri,
|
|
383
|
-
scheduleObj.duration / 1000
|
|
384
|
-
);
|
|
355
|
+
eventSegments = await session.getTruncatedVodSegments(scheduleObj.uri, scheduleObj.duration / 1000);
|
|
385
356
|
if (!eventSegments) {
|
|
386
357
|
debug(`[${this.sessionId}]: [ ERROR Switching from LIVE->VOD ]`);
|
|
387
358
|
this.streamTypeLive = false;
|
|
@@ -390,14 +361,15 @@ class StreamSwitcher {
|
|
|
390
361
|
return false;
|
|
391
362
|
}
|
|
392
363
|
|
|
393
|
-
await session.setCurrentMediaAndDiscSequenceCount(
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
364
|
+
await session.setCurrentMediaAndDiscSequenceCount(liveCounts.mediaSeq - 1, liveCounts.discSeq - 1);
|
|
365
|
+
await session.setCurrentMediaSequenceSegments(liveSegments.currMseqSegs, liveSegments.segCount);
|
|
366
|
+
|
|
367
|
+
// Insert preroll, if available, for current channel
|
|
368
|
+
if (this.prerollsCache[this.sessionId]) {
|
|
369
|
+
const prerollSegments = this.prerollsCache[this.sessionId].segments;
|
|
370
|
+
eventSegments = this._mergeSegments(prerollSegments, eventSegments, true);
|
|
371
|
+
}
|
|
372
|
+
|
|
401
373
|
await session.setCurrentMediaSequenceSegments(eventSegments, 0, true);
|
|
402
374
|
|
|
403
375
|
this.working = false;
|
|
@@ -417,19 +389,19 @@ class StreamSwitcher {
|
|
|
417
389
|
// TODO: Not yet fully tested/supported
|
|
418
390
|
this.eventId = scheduleObj.eventId;
|
|
419
391
|
eventSegments = await sessionLive.getCurrentMediaSequenceSegments();
|
|
420
|
-
currLiveCounts =
|
|
421
|
-
await sessionLive.getCurrentMediaAndDiscSequenceCount();
|
|
392
|
+
currLiveCounts = await sessionLive.getCurrentMediaAndDiscSequenceCount();
|
|
422
393
|
|
|
423
394
|
await sessionLive.resetSession();
|
|
424
395
|
await sessionLive.resetLiveStoreAsync(0);
|
|
425
396
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
);
|
|
397
|
+
// Insert preroll, if available, for current channel
|
|
398
|
+
if (this.prerollsCache[this.sessionId]) {
|
|
399
|
+
const prerollSegments = this.prerollsCache[this.sessionId].segments;
|
|
400
|
+
eventSegments = this._mergeSegments(prerollSegments, eventSegments, false);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await sessionLive.setCurrentMediaAndDiscSequenceCount(currLiveCounts.mediaSeq, currLiveCounts.discSeq);
|
|
404
|
+
await sessionLive.setCurrentMediaSequenceSegments(eventSegments.currMseqSegs);
|
|
433
405
|
liveUri = await sessionLive.setLiveUri(scheduleObj.uri);
|
|
434
406
|
|
|
435
407
|
if (!liveUri) {
|
|
@@ -483,19 +455,187 @@ class StreamSwitcher {
|
|
|
483
455
|
if (online.status >= 200 && online.status < 300) {
|
|
484
456
|
return true;
|
|
485
457
|
}
|
|
486
|
-
debug(
|
|
487
|
-
`[${this.sessionId}]: Failed to validate URI: ${uri}\nERROR! Returned Status Code: ${online.status}`
|
|
488
|
-
);
|
|
458
|
+
debug(`[${this.sessionId}]: Failed to validate URI: ${uri}\nERROR! Returned Status Code: ${online.status}`);
|
|
489
459
|
return false;
|
|
490
460
|
} catch (err) {
|
|
491
|
-
debug(
|
|
492
|
-
`[${this.sessionId}]: Failed to validate URI: ${uri}\nERROR! ${err}`
|
|
493
|
-
);
|
|
461
|
+
debug(`[${this.sessionId}]: Failed to validate URI: ${uri}\nERROR: ${err}`);
|
|
494
462
|
return false;
|
|
495
463
|
} finally {
|
|
496
464
|
clearTimeout(timeout);
|
|
497
465
|
}
|
|
498
466
|
}
|
|
467
|
+
|
|
468
|
+
async _loadPreroll(uri) {
|
|
469
|
+
const prerollSegments = {};
|
|
470
|
+
const mediaM3UPlaylists = {};
|
|
471
|
+
const mediaURIs = {};
|
|
472
|
+
try {
|
|
473
|
+
const m3u = await this._fetchParseM3u8(uri);
|
|
474
|
+
debug(`[${this.sessionId}]: ...Fetched a New Preroll Slate Master Manifest from:\n${uri}`);
|
|
475
|
+
// Is the first URI an actual Multivariant manifest
|
|
476
|
+
if (m3u.items.StreamItem.length > 0) {
|
|
477
|
+
// Process Master M3U. Collect Media URIs
|
|
478
|
+
for (let i = 0; i < m3u.items.StreamItem.length; i++) {
|
|
479
|
+
const streamItem = m3u.items.StreamItem[i];
|
|
480
|
+
const bw = streamItem.get("bandwidth");
|
|
481
|
+
const mediaUri = streamItem.get("uri");
|
|
482
|
+
if (mediaUri.match("^http")) {
|
|
483
|
+
mediaURIs[bw] = mediaUri;
|
|
484
|
+
} else {
|
|
485
|
+
mediaURIs[bw] = new URL(mediaUri, uri);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Fetch and parse Media URIs
|
|
490
|
+
const bandwidths = Object.keys(mediaURIs);
|
|
491
|
+
const loadMediaPromises = [];
|
|
492
|
+
// Queue up...
|
|
493
|
+
bandwidths.forEach((bw) => loadMediaPromises.push(this._fetchParseM3u8(mediaURIs[bw])));
|
|
494
|
+
// Execute...
|
|
495
|
+
const results = await Promise.allSettled(loadMediaPromises);
|
|
496
|
+
// Process...
|
|
497
|
+
results.forEach((item, idx) => {
|
|
498
|
+
if (item.status === "fulfilled" && item.value) {
|
|
499
|
+
const resultM3U = item.value;
|
|
500
|
+
const bw = bandwidths[idx];
|
|
501
|
+
mediaM3UPlaylists[bw] = resultM3U.items.PlaylistItem;
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
} else if (m3u.items.PlaylistItem.length > 0) {
|
|
505
|
+
// Process the Media M3U.
|
|
506
|
+
const arbitraryBw = 1;
|
|
507
|
+
mediaURIs[arbitraryBw] = uri;
|
|
508
|
+
mediaM3UPlaylists[arbitraryBw] = m3u.items.PlaylistItem;
|
|
509
|
+
} else {
|
|
510
|
+
debug(`[${this.sessionId}]: WARNING! M3U has no variants nor playlist segments!`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Turn original m3u playlist items into custom-simple segment items list
|
|
514
|
+
const bandwidths = Object.keys(mediaM3UPlaylists);
|
|
515
|
+
for (let i = 0; i < bandwidths.length; i++) {
|
|
516
|
+
const bw = bandwidths[i];
|
|
517
|
+
if (!prerollSegments[bw]) {
|
|
518
|
+
prerollSegments[bw] = [];
|
|
519
|
+
}
|
|
520
|
+
for (let k = 0; k < mediaM3UPlaylists[bw].length; k++) {
|
|
521
|
+
let seg = {};
|
|
522
|
+
let playlistItem = mediaM3UPlaylists[bw][k];
|
|
523
|
+
let segmentUri;
|
|
524
|
+
let cueData = null;
|
|
525
|
+
let daterangeData = null;
|
|
526
|
+
let attributes = playlistItem["attributes"].attributes;
|
|
527
|
+
if (playlistItem.properties.discontinuity) {
|
|
528
|
+
prerollSegments[bw].push({ discontinuity: true });
|
|
529
|
+
}
|
|
530
|
+
if ("cuein" in attributes) {
|
|
531
|
+
if (!cueData) {
|
|
532
|
+
cueData = {};
|
|
533
|
+
}
|
|
534
|
+
cueData["in"] = true;
|
|
535
|
+
}
|
|
536
|
+
if ("cueout" in attributes) {
|
|
537
|
+
if (!cueData) {
|
|
538
|
+
cueData = {};
|
|
539
|
+
}
|
|
540
|
+
cueData["out"] = true;
|
|
541
|
+
cueData["duration"] = attributes["cueout"];
|
|
542
|
+
}
|
|
543
|
+
if ("cuecont" in attributes) {
|
|
544
|
+
if (!cueData) {
|
|
545
|
+
cueData = {};
|
|
546
|
+
}
|
|
547
|
+
cueData["cont"] = true;
|
|
548
|
+
}
|
|
549
|
+
if ("scteData" in attributes) {
|
|
550
|
+
if (!cueData) {
|
|
551
|
+
cueData = {};
|
|
552
|
+
}
|
|
553
|
+
cueData["scteData"] = attributes["scteData"];
|
|
554
|
+
}
|
|
555
|
+
if ("assetData" in attributes) {
|
|
556
|
+
if (!cueData) {
|
|
557
|
+
cueData = {};
|
|
558
|
+
}
|
|
559
|
+
cueData["assetData"] = attributes["assetData"];
|
|
560
|
+
}
|
|
561
|
+
if ("daterange" in attributes) {
|
|
562
|
+
if (!daterangeData) {
|
|
563
|
+
daterangeData = {};
|
|
564
|
+
}
|
|
565
|
+
let allDaterangeAttributes = Object.keys(attributes["daterange"]);
|
|
566
|
+
allDaterangeAttributes.forEach((attr) => {
|
|
567
|
+
if (attr.match(/DURATION$/)) {
|
|
568
|
+
daterangeData[attr.toLowerCase()] = parseFloat(attributes["daterange"][attr]);
|
|
569
|
+
} else {
|
|
570
|
+
daterangeData[attr.toLowerCase()] = attributes["daterange"][attr];
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
if (playlistItem.properties.uri) {
|
|
575
|
+
if (playlistItem.properties.uri.match("^http")) {
|
|
576
|
+
segmentUri = playlistItem.properties.uri;
|
|
577
|
+
} else {
|
|
578
|
+
segmentUri = new URL(playlistItem.properties.uri, mediaURIs[bw]);
|
|
579
|
+
}
|
|
580
|
+
seg["duration"] = playlistItem.properties.duration;
|
|
581
|
+
seg["uri"] = segmentUri;
|
|
582
|
+
seg["cue"] = cueData;
|
|
583
|
+
if (daterangeData) {
|
|
584
|
+
seg["daterange"] = daterangeData;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
prerollSegments[bw].push(seg);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
debug(`[${this.sessionId}]: Loaded all Variants of the Preroll Slate!`);
|
|
591
|
+
return prerollSegments;
|
|
592
|
+
} catch (err) {
|
|
593
|
+
throw new Error(err);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Input: hls vod uri. Output: an M3U object.
|
|
598
|
+
async _fetchParseM3u8(uri) {
|
|
599
|
+
const parser = m3u8.createStream();
|
|
600
|
+
try {
|
|
601
|
+
const res = await fetchWithRetry(uri, {}, 5, 1000, 1500, debug);
|
|
602
|
+
res.body.pipe(parser);
|
|
603
|
+
return new Promise((resolve, reject) => {
|
|
604
|
+
parser.on("m3u", (m3u) => {
|
|
605
|
+
resolve(m3u);
|
|
606
|
+
parser.on("error", (exc) => {
|
|
607
|
+
debug(`Parser Error: ${JSON.stringify(exc)}`);
|
|
608
|
+
reject(exc);
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
} catch (err) {
|
|
613
|
+
return Promise.reject(`[${this.sessionId}]: Failed to Fetch URI: ${uri}\nERROR, ${err}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
_mergeSegments(fromSegments, toSegments, prepend) {
|
|
618
|
+
const OUTPUT_SEGMENTS = {};
|
|
619
|
+
const fromBws = Object.keys(fromSegments);
|
|
620
|
+
const toBws = Object.keys(toSegments);
|
|
621
|
+
toBws.forEach((bw) => {
|
|
622
|
+
const targetBw = findNearestValue(bw, fromBws);
|
|
623
|
+
if (prepend) {
|
|
624
|
+
OUTPUT_SEGMENTS[bw] = fromSegments[targetBw].concat(toSegments[bw]);
|
|
625
|
+
OUTPUT_SEGMENTS[bw].unshift({ discontinuity: true });
|
|
626
|
+
} else {
|
|
627
|
+
const lastSeg = toSegments[bw][toSegments[bw].length - 1];
|
|
628
|
+
if (lastSeg.uri && !lastSeg.discontinuity) {
|
|
629
|
+
toSegments[bw].push({ discontinuity: true });
|
|
630
|
+
OUTPUT_SEGMENTS[bw] = toSegments[bw].concat(fromSegments[targetBw]);
|
|
631
|
+
} else {
|
|
632
|
+
OUTPUT_SEGMENTS[bw] = toSegments[bw].concat(fromSegments[targetBw]);
|
|
633
|
+
OUTPUT_SEGMENTS[bw].push({ discontinuity: true });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
return OUTPUT_SEGMENTS;
|
|
638
|
+
}
|
|
499
639
|
}
|
|
500
640
|
|
|
501
641
|
module.exports = StreamSwitcher;
|
package/engine/util.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const { version } = require("../package.json");
|
|
2
|
+
const fetch = require("node-fetch");
|
|
3
|
+
const { AbortController } = require("abort-controller");
|
|
2
4
|
|
|
3
5
|
const filterQueryParser = (filterQuery) => {
|
|
4
6
|
const conditions = filterQuery.match(/\(([^\(\)]*?)\)/g);
|
|
@@ -150,6 +152,68 @@ class WaitTimeGenerator {
|
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
const findNearestValue = (val, array) => {
|
|
156
|
+
const sorted = array.sort((a, b) => b - a);
|
|
157
|
+
return sorted.reduce((a, b) => {
|
|
158
|
+
return Math.abs(b - val) < Math.abs(a - val) ? b : a;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const isValidUrl = (url) => {
|
|
163
|
+
try {
|
|
164
|
+
const _url = new URL(url);
|
|
165
|
+
return true;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
const fetchWithRetry = async (uri, opts, maxRetries, retryDelayMs, timeoutMs, debug) => {
|
|
173
|
+
if (!debug) {
|
|
174
|
+
var debug = (msg) => { console.log(msg); };
|
|
175
|
+
}
|
|
176
|
+
let tryFetchCount = 0;
|
|
177
|
+
const RETRY_LIMIT = maxRetries || 5;
|
|
178
|
+
const TIMEOUT_LIMIT = timeoutMs || 10 * 1000;
|
|
179
|
+
const RETRY_DELAY = retryDelayMs || 1000;
|
|
180
|
+
while (tryFetchCount < RETRY_LIMIT) {
|
|
181
|
+
tryFetchCount++;
|
|
182
|
+
debug(`Fetching URI -> ${uri}, attempt ${tryFetchCount} of ${maxRetries}`);
|
|
183
|
+
//
|
|
184
|
+
const controller = new AbortController();
|
|
185
|
+
const timeout = setTimeout(() => {
|
|
186
|
+
`Request Timeout after (${TIMEOUT_LIMIT})ms @ ${uri}`;
|
|
187
|
+
controller.abort();
|
|
188
|
+
}, TIMEOUT_LIMIT);
|
|
189
|
+
try {
|
|
190
|
+
const fetchOpts = Object.assign({ signal: controller.signal }, opts);
|
|
191
|
+
const response = await fetch(uri, fetchOpts);
|
|
192
|
+
|
|
193
|
+
if (response.status >= 400 && response.status < 600) {
|
|
194
|
+
const msg = `Bad response from URI: ${uri}\nReturned Status Code: ${response.status}`;
|
|
195
|
+
debug(msg);
|
|
196
|
+
if (tryFetchCount === maxRetries) {
|
|
197
|
+
return Promise.resolve(response);
|
|
198
|
+
}
|
|
199
|
+
debug(`Going to try fetch again in ${RETRY_DELAY}ms`);
|
|
200
|
+
await timer(RETRY_DELAY);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// Return Good response
|
|
204
|
+
return Promise.resolve(response);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
debug(`Node-Fetch Error on URI: ${uri}\nFull Error -> ${err}`);
|
|
207
|
+
if (tryFetchCount === maxRetries) {
|
|
208
|
+
return Promise.reject(err);
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
} finally {
|
|
212
|
+
clearTimeout(timeout);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
153
217
|
module.exports = {
|
|
154
218
|
filterQueryParser,
|
|
155
219
|
applyFilter,
|
|
@@ -159,4 +223,7 @@ module.exports = {
|
|
|
159
223
|
logerror,
|
|
160
224
|
timer,
|
|
161
225
|
WaitTimeGenerator,
|
|
226
|
+
findNearestValue,
|
|
227
|
+
isValidUrl,
|
|
228
|
+
fetchWithRetry
|
|
162
229
|
};
|
package/package.json
CHANGED
package/server-livemix.js
CHANGED
|
@@ -136,6 +136,11 @@ class StreamSwitchManager {
|
|
|
136
136
|
return uuidv4();
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
getPrerollUri(channelId) {
|
|
140
|
+
const defaultPrerollSlateUri = "https://maitv-vod.lab.eyevinn.technology/slate-consuo.mp4/master.m3u8"
|
|
141
|
+
return new Promise((resolve, reject) => { resolve(defaultPrerollSlateUri); });
|
|
142
|
+
}
|
|
143
|
+
|
|
139
144
|
getSchedule(channelId) {
|
|
140
145
|
return new Promise((resolve, reject) => {
|
|
141
146
|
if (this.schedule[channelId]) {
|
|
@@ -155,7 +160,7 @@ class StreamSwitchManager {
|
|
|
155
160
|
type: StreamType.LIVE,
|
|
156
161
|
start_time: startOffset,
|
|
157
162
|
end_time: endTime,
|
|
158
|
-
uri: "https://d2fz24s2fts31b.cloudfront.net/out/v1/
|
|
163
|
+
uri: "https://d2fz24s2fts31b.cloudfront.net/out/v1/6484d7c664924b77893f9b4f63080e5d/manifest.m3u8",
|
|
159
164
|
},
|
|
160
165
|
{
|
|
161
166
|
eventId: this.generateID(),
|
|
@@ -324,6 +324,7 @@ describe("The initialize switching", () => {
|
|
|
324
324
|
expect(currVodCounts).toEqual({
|
|
325
325
|
mediaSeq: 2,
|
|
326
326
|
discSeq: 0,
|
|
327
|
+
vodMediaSeqVideo: 2
|
|
327
328
|
});
|
|
328
329
|
expect(currVodSegments[1313000][0]).toEqual({
|
|
329
330
|
duration: 7.5,
|
|
@@ -403,6 +404,7 @@ describe("The initialize switching", () => {
|
|
|
403
404
|
const expectedCounts = {
|
|
404
405
|
mediaSeq: 23,
|
|
405
406
|
discSeq: 1,
|
|
407
|
+
vodMediaSeqVideo: 3
|
|
406
408
|
};
|
|
407
409
|
|
|
408
410
|
expect(expectedCounts).toEqual(sessionCounts);
|
|
@@ -448,6 +450,7 @@ describe("The initialize switching", () => {
|
|
|
448
450
|
const expectedCounts = {
|
|
449
451
|
mediaSeq: 8,
|
|
450
452
|
discSeq: 0,
|
|
453
|
+
vodMediaSeqVideo: 2
|
|
451
454
|
};
|
|
452
455
|
|
|
453
456
|
expect(expectedCounts).toEqual(sessionCounts);
|