eyevinn-channel-engine 5.1.2 → 5.2.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/.github/workflows/nodejs.yml +1 -1
- package/.github/workflows/publish.yml +1 -1
- package/.github/workflows/rc.yml +1 -1
- package/CONTRIBUTING.md +40 -0
- package/Dockerfile +1 -1
- package/README.md +70 -8
- package/dist/engine/chaos_monkey.d.ts +1 -1
- package/dist/engine/chaos_monkey.js +1 -0
- package/dist/engine/event_stream.d.ts +1 -6
- package/dist/engine/event_stream.js +2 -0
- package/dist/engine/memcached_state_store.d.ts +1 -13
- package/dist/engine/memcached_state_store.js +4 -0
- package/dist/engine/memory_state_store.d.ts +1 -13
- package/dist/engine/memory_state_store.js +5 -0
- package/dist/engine/playhead_state.d.ts +0 -26
- package/dist/engine/playhead_state.js +9 -0
- package/dist/engine/preflight.d.ts +1 -1
- package/dist/engine/preflight.js +1 -0
- package/dist/engine/redis_state_store.d.ts +1 -19
- package/dist/engine/redis_state_store.js +5 -0
- package/dist/engine/server.d.ts +27 -0
- package/dist/engine/server.js +140 -6
- package/dist/engine/session.d.ts +1 -148
- package/dist/engine/session.js +398 -11
- package/dist/engine/session_live.d.ts +1 -181
- package/dist/engine/session_live.js +19 -4
- package/dist/engine/session_live_state.d.ts +0 -23
- package/dist/engine/session_live_state.js +5 -0
- package/dist/engine/session_state.d.ts +0 -45
- package/dist/engine/session_state.js +10 -0
- package/dist/engine/shared_state_store.d.ts +1 -25
- package/dist/engine/shared_state_store.js +10 -0
- package/dist/engine/state_store_types.d.ts +32 -0
- package/dist/engine/state_store_types.js +5 -0
- package/dist/engine/stream_switcher.d.ts +1 -40
- package/dist/engine/stream_switcher.js +108 -20
- package/dist/engine/subtitle_slicer.d.ts +1 -8
- package/dist/engine/subtitle_slicer.js +1 -0
- package/dist/engine/util.d.ts +1 -29
- package/dist/engine/util.js +2 -1
- package/dist/examples/adbreak.d.ts +1 -0
- package/dist/examples/adbreak.js +104 -0
- package/dist/index.d.ts +1 -1
- package/dist/package.json +20 -4
- package/docs/design/mixing-muxed-demuxed-audio.md +153 -0
- package/docs/design/subtitles-live-mix-demux.md +135 -0
- package/docs/reference.md +67 -0
- package/engine/chaos_monkey.ts +17 -0
- package/engine/{event_stream.js → event_stream.ts} +5 -3
- package/engine/{memcached_state_store.js → memcached_state_store.ts} +19 -12
- package/engine/memory_state_store.ts +71 -0
- package/engine/{playhead_state.js → playhead_state.ts} +92 -82
- package/engine/{preflight.js → preflight.ts} +1 -1
- package/engine/{redis_state_store.js → redis_state_store.ts} +216 -208
- package/engine/server.ts +210 -8
- package/engine/{session.js → session.ts} +421 -22
- package/engine/{session_live.js → session_live.ts} +54 -31
- package/engine/{session_live_state.js → session_live_state.ts} +129 -120
- package/engine/{session_state.js → session_state.ts} +269 -248
- package/engine/{shared_state_store.js → shared_state_store.ts} +31 -19
- package/engine/state_store_types.ts +35 -0
- package/engine/{stream_switcher.js → stream_switcher.ts} +133 -34
- package/engine/{subtitle_slicer.js → subtitle_slicer.ts} +10 -8
- package/engine/{util.js → util.ts} +28 -24
- package/examples/adbreak.ts +115 -0
- package/index.ts +2 -0
- package/package.json +20 -4
- package/spec/engine/custom_vod_request_params_spec.ts +99 -0
- package/spec/engine/drm_spec.ts +128 -0
- package/spec/engine/{engine_spec.js → engine_spec.ts} +44 -1
- package/spec/engine/{ha_spec.js → ha_spec.ts} +7 -4
- package/spec/engine/{init_switching_spec.js → init_switching_spec.ts} +10 -6
- package/spec/engine/{interface_spec.js → interface_spec.ts} +16 -4
- package/spec/engine/{playhead_live_spec.js → playhead_live_spec.ts} +2 -1
- package/spec/engine/{playhead_spec.js → playhead_spec.ts} +15 -8
- package/spec/engine/session_spec.ts +800 -0
- package/spec/engine/stream_switcher_audio_reconcile_spec.ts +158 -0
- package/spec/engine/{stream_switcher_spec.js → stream_switcher_spec.ts} +111 -13
- package/spec/engine/subtitle_missing_language_spec.ts +196 -0
- package/spec/engine/{subtitle_spec.js → subtitle_spec.ts} +28 -27
- package/spec/helpers/ts-resolve.js +65 -0
- package/spec/support/jasmine.json +1 -1
- package/spec/testvectors/hls_subs_no_language/config.subtitleTracks.json +12 -0
- package/spec/testvectors/hls_subs_no_language/evidence_dummy_segments_name_mismatch.m3u8 +24 -0
- package/spec/testvectors/hls_subs_no_language/evidence_real_cues_name_match.m3u8 +24 -0
- package/spec/testvectors/hls_subs_no_language/french.m3u8 +9 -0
- package/spec/testvectors/hls_subs_no_language/master.m3u8 +11 -0
- package/spec/testvectors/hls_subs_no_language/video.m3u8 +25 -0
- package/spec/testvectors/slate/master.m3u8 +3 -0
- package/spec/testvectors/slate/media.m3u8 +9 -0
- package/tsconfig.json +21 -4
- package/tsconfig.spec.json +16 -0
- package/engine/chaos_monkey.js +0 -17
- package/engine/memory_state_store.js +0 -63
- package/spec/engine/session_spec.js +0 -116
- /package/spec/engine/{session_live_spec.js → session_live_spec.ts} +0 -0
- /package/spec/engine/{util_spec.js → util_spec.ts} +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
2
3
|
const debug = require("debug")("engine-stream-switcher");
|
|
3
4
|
const crypto = require("crypto");
|
|
4
5
|
const fetch = require("node-fetch");
|
|
@@ -50,6 +51,38 @@ class StreamSwitcher {
|
|
|
50
51
|
getEventId() {
|
|
51
52
|
return this.eventId;
|
|
52
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Report this channel's event-polling state, used by the global StreamSwitchLoop
|
|
56
|
+
* to pick an adaptive poll cadence without leaking switcher internals.
|
|
57
|
+
*
|
|
58
|
+
* "Mid-event"/ongoing (per #365): the switcher is on a live stream
|
|
59
|
+
* (this.streamTypeLive === true) and the current scheduleObj is within its
|
|
60
|
+
* window, i.e. now >= start_time && now < end_time.
|
|
61
|
+
*
|
|
62
|
+
* "Near end": an ongoing event whose remaining time is within the switcher's
|
|
63
|
+
* existing end-of-event guard window (end_time - now <= 10000; see the 10000ms
|
|
64
|
+
* guard in streamSwitcher()). Derived from that guard rather than a new constant.
|
|
65
|
+
*
|
|
66
|
+
* @returns {{ midEvent: boolean, nearEnd: boolean, msUntilEnd: (number|null) }}
|
|
67
|
+
* msUntilEnd is null when there is no ongoing event.
|
|
68
|
+
*/
|
|
69
|
+
getEventPollingState() {
|
|
70
|
+
const NEAR_END_GUARD_MS = 10000;
|
|
71
|
+
const scheduleObj = this.timeDiff;
|
|
72
|
+
if (!this.streamTypeLive || !scheduleObj || scheduleObj.end_time == null || scheduleObj.start_time == null) {
|
|
73
|
+
return { midEvent: false, nearEnd: false, msUntilEnd: null };
|
|
74
|
+
}
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
if (now < scheduleObj.start_time || now >= scheduleObj.end_time) {
|
|
77
|
+
return { midEvent: false, nearEnd: false, msUntilEnd: null };
|
|
78
|
+
}
|
|
79
|
+
const msUntilEnd = scheduleObj.end_time - now;
|
|
80
|
+
return {
|
|
81
|
+
midEvent: true,
|
|
82
|
+
nearEnd: msUntilEnd <= NEAR_END_GUARD_MS,
|
|
83
|
+
msUntilEnd,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
53
86
|
async abortLiveFeed(session, sessionLive, message) {
|
|
54
87
|
if (this.streamTypeLive) {
|
|
55
88
|
let status = null;
|
|
@@ -61,7 +94,7 @@ class StreamSwitcher {
|
|
|
61
94
|
}
|
|
62
95
|
catch (err) {
|
|
63
96
|
debug(`Failed to force a switch off live feed: ${err}`);
|
|
64
|
-
throw new Error(err);
|
|
97
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
65
98
|
}
|
|
66
99
|
}
|
|
67
100
|
}
|
|
@@ -212,7 +245,7 @@ class StreamSwitcher {
|
|
|
212
245
|
catch (err) {
|
|
213
246
|
debug(`[${this.sessionId}]: Unexpected failure in Stream Switcher...`);
|
|
214
247
|
console.error(err);
|
|
215
|
-
throw new Error(err);
|
|
248
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
216
249
|
}
|
|
217
250
|
}
|
|
218
251
|
async _initSwitching(state, session, sessionLive, scheduleObj) {
|
|
@@ -273,7 +306,7 @@ class StreamSwitcher {
|
|
|
273
306
|
this.eventId = null;
|
|
274
307
|
debug(`[${this.sessionId}]: [ ERROR Switching from V2L->LIVE ]`);
|
|
275
308
|
console.error(err);
|
|
276
|
-
throw new Error(err);
|
|
309
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
277
310
|
}
|
|
278
311
|
case SwitcherState.V2L_TO_VOD:
|
|
279
312
|
try {
|
|
@@ -282,7 +315,10 @@ class StreamSwitcher {
|
|
|
282
315
|
currVodCounts = await session.getCurrentMediaAndDiscSequenceCount();
|
|
283
316
|
eventSegments = await session.getTruncatedVodSegments(scheduleObj.uri, scheduleObj.duration / 1000);
|
|
284
317
|
eventAudioSegments = await session.getTruncatedVodAudioSegments(scheduleObj.uri, scheduleObj.duration / 1000);
|
|
285
|
-
|
|
318
|
+
// #382: derive per-source whether THIS VOD actually provided demuxed
|
|
319
|
+
// audio, instead of assuming it from the channel-level output flag.
|
|
320
|
+
const vodHasDemuxedAudio = this._sourceHasDemuxedAudio(eventAudioSegments);
|
|
321
|
+
if (!eventSegments) {
|
|
286
322
|
debug(`[${this.sessionId}]: [ ERROR Switching from V2L->VOD ]`);
|
|
287
323
|
this.working = false;
|
|
288
324
|
this.eventId = null;
|
|
@@ -292,13 +328,22 @@ class StreamSwitcher {
|
|
|
292
328
|
if (this.prerollsCache[this.sessionId] && this.prerollsCache[this.sessionId].isValid) {
|
|
293
329
|
const prerollSegments = this.prerollsCache[this.sessionId].segments;
|
|
294
330
|
eventSegments = this._mergeSegments(prerollSegments, eventSegments, true);
|
|
295
|
-
if (this.useDemuxedAudio) {
|
|
331
|
+
if (this.useDemuxedAudio && vodHasDemuxedAudio) {
|
|
296
332
|
const prerollAudioSegments = this.prerollsCache[this.sessionId].audioSegments;
|
|
297
333
|
eventAudioSegments = this._mergeAudioSegments(prerollAudioSegments, eventAudioSegments, true);
|
|
298
334
|
}
|
|
299
335
|
}
|
|
300
336
|
await session.setCurrentMediaAndDiscSequenceCount(currVodCounts.mediaSeq, currVodCounts.discSeq, currVodCounts.audioSeq, currVodCounts.audioDiscSeq);
|
|
301
|
-
|
|
337
|
+
// #382: only carry audio segments into the write when THIS VOD actually
|
|
338
|
+
// provided demuxed audio. A muxed VOD returns {} from
|
|
339
|
+
// getTruncatedVodAudioSegments; passing that through would crash inside
|
|
340
|
+
// setCurrentMediaSequenceSegments (Object.keys on an undefined group).
|
|
341
|
+
if (this.useDemuxedAudio && vodHasDemuxedAudio) {
|
|
342
|
+
await session.setCurrentMediaSequenceSegments(eventSegments, 0, true, eventAudioSegments, 0);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
await session.setCurrentMediaSequenceSegments(eventSegments, 0, true);
|
|
346
|
+
}
|
|
302
347
|
this.working = false;
|
|
303
348
|
debug(`[${this.sessionId}]: [ Switched from V2L->VOD ]`);
|
|
304
349
|
return false;
|
|
@@ -308,7 +353,7 @@ class StreamSwitcher {
|
|
|
308
353
|
this.working = false;
|
|
309
354
|
this.eventId = null;
|
|
310
355
|
debug(`[${this.sessionId}]: [ ERROR Switching from V2L->VOD ]`);
|
|
311
|
-
throw new Error(err);
|
|
356
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
312
357
|
}
|
|
313
358
|
case SwitcherState.LIVE_TO_V2L:
|
|
314
359
|
try {
|
|
@@ -319,10 +364,13 @@ class StreamSwitcher {
|
|
|
319
364
|
liveAudioSegments = await sessionLive.getCurrentAudioSequenceSegments();
|
|
320
365
|
}
|
|
321
366
|
liveCounts = await sessionLive.getCurrentMediaAndDiscSequenceCount();
|
|
367
|
+
// #382: derive per-source whether THIS live source actually produced
|
|
368
|
+
// demuxed audio, instead of asserting it purely from the channel flag.
|
|
369
|
+
const liveHasDemuxedAudio = this.useDemuxedAudio && liveAudioSegments && !this._isEmpty(liveAudioSegments.currMseqSegs);
|
|
322
370
|
if (scheduleObj && !scheduleObj.duration) {
|
|
323
371
|
debug(`[${this.sessionId}]: Cannot switch VOD. No duration specified for schedule item: [${scheduleObj.assetId}]`);
|
|
324
372
|
}
|
|
325
|
-
if (this._isEmpty(liveSegments.currMseqSegs)
|
|
373
|
+
if (this._isEmpty(liveSegments.currMseqSegs)) {
|
|
326
374
|
this.working = false;
|
|
327
375
|
this.streamTypeLive = false;
|
|
328
376
|
debug(`[${this.sessionId}]: [ Switched from LIVE->V2L ]`);
|
|
@@ -333,14 +381,14 @@ class StreamSwitcher {
|
|
|
333
381
|
const prerollSegments = this.prerollsCache[this.sessionId].segments;
|
|
334
382
|
liveSegments.currMseqSegs = this._mergeSegments(prerollSegments, liveSegments.currMseqSegs, false);
|
|
335
383
|
liveSegments.segCount += prerollSegments.length;
|
|
336
|
-
if (
|
|
384
|
+
if (liveHasDemuxedAudio) {
|
|
337
385
|
const prerollAudioSegments = this.prerollsCache[this.sessionId].audioSegments;
|
|
338
386
|
liveAudioSegments.currMseqSegs = this._mergeAudioSegments(prerollAudioSegments, liveAudioSegments.currMseqSegs, false);
|
|
339
387
|
liveAudioSegments.segCount += prerollAudioSegments.length;
|
|
340
388
|
}
|
|
341
389
|
}
|
|
342
390
|
await session.setCurrentMediaAndDiscSequenceCount(liveCounts.mediaSeq, liveCounts.discSeq, liveCounts.audioSeq, liveCounts.audioDiscSeq);
|
|
343
|
-
if (
|
|
391
|
+
if (liveHasDemuxedAudio) {
|
|
344
392
|
await session.setCurrentMediaSequenceSegments(liveSegments.currMseqSegs, liveSegments.segCount, false, liveAudioSegments.currMseqSegs, liveAudioSegments.segCount);
|
|
345
393
|
}
|
|
346
394
|
else {
|
|
@@ -358,7 +406,7 @@ class StreamSwitcher {
|
|
|
358
406
|
this.working = false;
|
|
359
407
|
this.eventId = null;
|
|
360
408
|
debug(`[${this.sessionId}]: [ ERROR Switching from LIVE->V2L ] ${err}`);
|
|
361
|
-
throw new Error(err);
|
|
409
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
362
410
|
}
|
|
363
411
|
case SwitcherState.LIVE_TO_VOD:
|
|
364
412
|
try {
|
|
@@ -370,6 +418,15 @@ class StreamSwitcher {
|
|
|
370
418
|
liveCounts = await sessionLive.getCurrentMediaAndDiscSequenceCount();
|
|
371
419
|
eventSegments = await session.getTruncatedVodSegments(scheduleObj.uri, scheduleObj.duration / 1000);
|
|
372
420
|
eventAudioSegments = await session.getTruncatedVodAudioSegments(scheduleObj.uri, scheduleObj.duration / 1000);
|
|
421
|
+
// #383: reconcile across a possible muxed<->demuxed boundary at this
|
|
422
|
+
// LIVE->VOD switch point. The channel OUTPUT mode is `useDemuxedAudio`;
|
|
423
|
+
// each side of the transition may or may not actually carry demuxed
|
|
424
|
+
// audio. We only ever write audio into the (demuxed-output) channel when
|
|
425
|
+
// the SPECIFIC source being written actually provided demuxed audio, so a
|
|
426
|
+
// muxed source crossing into a demuxed channel does not corrupt the audio
|
|
427
|
+
// group / audio discontinuity-sequence with empty or mismatched data.
|
|
428
|
+
const liveHasDemuxedAudioLTV = this.useDemuxedAudio && liveAudioSegments && !this._isEmpty(liveAudioSegments.currMseqSegs);
|
|
429
|
+
const vodHasDemuxedAudioLTV = this.useDemuxedAudio && this._sourceHasDemuxedAudio(eventAudioSegments);
|
|
373
430
|
if (!eventSegments) {
|
|
374
431
|
debug(`[${this.sessionId}]: [ ERROR Switching from LIVE->VOD ]`);
|
|
375
432
|
this.streamTypeLive = false;
|
|
@@ -378,7 +435,7 @@ class StreamSwitcher {
|
|
|
378
435
|
return false;
|
|
379
436
|
}
|
|
380
437
|
await session.setCurrentMediaAndDiscSequenceCount(liveCounts.mediaSeq - 1, liveCounts.discSeq - 1, liveCounts.audioSeq - 1, liveCounts.audioDiscSeq - 1);
|
|
381
|
-
if (
|
|
438
|
+
if (liveHasDemuxedAudioLTV) {
|
|
382
439
|
await session.setCurrentMediaSequenceSegments(liveSegments.currMseqSegs, liveSegments.segCount, false, liveAudioSegments.currMseqSegs, liveAudioSegments.segCount);
|
|
383
440
|
}
|
|
384
441
|
else {
|
|
@@ -388,12 +445,17 @@ class StreamSwitcher {
|
|
|
388
445
|
if (this.prerollsCache[this.sessionId] && this.prerollsCache[this.sessionId].isValid) {
|
|
389
446
|
const prerollSegments = this.prerollsCache[this.sessionId].segments;
|
|
390
447
|
eventSegments = this._mergeSegments(prerollSegments, eventSegments, true);
|
|
391
|
-
if (
|
|
448
|
+
if (vodHasDemuxedAudioLTV) {
|
|
392
449
|
const prerollAudioSegments = this.prerollsCache[this.sessionId].audioSegments;
|
|
393
450
|
eventAudioSegments = this._mergeAudioSegments(prerollAudioSegments, eventAudioSegments, true);
|
|
394
451
|
}
|
|
395
452
|
}
|
|
396
|
-
|
|
453
|
+
if (vodHasDemuxedAudioLTV) {
|
|
454
|
+
await session.setCurrentMediaSequenceSegments(eventSegments, 0, true, eventAudioSegments, 0);
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
await session.setCurrentMediaSequenceSegments(eventSegments, 0, true);
|
|
458
|
+
}
|
|
397
459
|
await sessionLive.resetSession();
|
|
398
460
|
sessionLive.resetLiveStoreAsync(RESET_DELAY); // In parallel
|
|
399
461
|
this.working = false;
|
|
@@ -406,7 +468,7 @@ class StreamSwitcher {
|
|
|
406
468
|
this.working = false;
|
|
407
469
|
this.eventId = null;
|
|
408
470
|
debug(`[${this.sessionId}]: [ ERROR Switching from LIVE->VOD ]`);
|
|
409
|
-
throw new Error(err);
|
|
471
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
410
472
|
}
|
|
411
473
|
case SwitcherState.LIVE_TO_LIVE:
|
|
412
474
|
try {
|
|
@@ -416,6 +478,12 @@ class StreamSwitcher {
|
|
|
416
478
|
eventSegments = await sessionLive.getCurrentMediaSequenceSegments();
|
|
417
479
|
eventAudioSegments = await sessionLive.getCurrentAudioSequenceSegments();
|
|
418
480
|
currLiveCounts = await sessionLive.getCurrentMediaAndDiscSequenceCount();
|
|
481
|
+
// #383: reconcile a possible muxed<->demuxed boundary across a LIVE->LIVE
|
|
482
|
+
// switch. Only carry the demuxed audio rendition when the outgoing live
|
|
483
|
+
// source actually produced one; if it didn't (a muxed source on a demuxed
|
|
484
|
+
// channel), skip the audio write so we don't push an empty audio group /
|
|
485
|
+
// desync the audio discontinuity-sequence into the next live source.
|
|
486
|
+
const liveHasDemuxedAudioLTL = this.useDemuxedAudio && eventAudioSegments && !this._isEmpty(eventAudioSegments.currMseqSegs);
|
|
419
487
|
await sessionLive.resetSession();
|
|
420
488
|
await sessionLive.resetLiveStoreAsync(0);
|
|
421
489
|
// Insert preroll, if available, for current channel
|
|
@@ -423,10 +491,10 @@ class StreamSwitcher {
|
|
|
423
491
|
const prerollSegments = this.prerollsCache[this.sessionId].segments;
|
|
424
492
|
this._insertTimedMetadata(prerollSegments, scheduleObj.timedMetadata || {});
|
|
425
493
|
eventSegments.currMseqSegs = this._mergeSegments(prerollSegments, eventSegments.currMseqSegs, false);
|
|
426
|
-
if (
|
|
494
|
+
if (liveHasDemuxedAudioLTL) {
|
|
427
495
|
const prerollSegmentsAudio = this.prerollsCache[this.sessionId].audioSegments;
|
|
428
496
|
this._insertTimedMetadataAudio(prerollSegmentsAudio, scheduleObj.timedMetadata || {});
|
|
429
|
-
|
|
497
|
+
eventAudioSegments.currMseqSegs = this._mergeAudioSegments(prerollSegmentsAudio, eventAudioSegments.currMseqSegs, false);
|
|
430
498
|
}
|
|
431
499
|
}
|
|
432
500
|
const faild = await sessionLive.setCurrentMediaAndDiscSequenceCount(currLiveCounts.mediaSeq, currLiveCounts.discSeq, currLiveCounts.audioSeq, currLiveCounts.audioDiscSeq);
|
|
@@ -434,7 +502,9 @@ class StreamSwitcher {
|
|
|
434
502
|
console.error("cound not set switch live-> live", currVodCounts.mediaSeq, currVodCounts.discSeq, currVodCounts.audioSeq, currVodCounts.audioDiscSeq);
|
|
435
503
|
}
|
|
436
504
|
await sessionLive.setCurrentMediaSequenceSegments(eventSegments.currMseqSegs);
|
|
437
|
-
|
|
505
|
+
if (liveHasDemuxedAudioLTL) {
|
|
506
|
+
await sessionLive.setCurrentAudioSequenceSegments(eventAudioSegments.currMseqSegs);
|
|
507
|
+
}
|
|
438
508
|
liveUri = await sessionLive.setLiveUri(scheduleObj.uri);
|
|
439
509
|
if (!liveUri) {
|
|
440
510
|
debug(`[${this.sessionId}]: [ ERROR Switching from LIVE->LIVE ]`);
|
|
@@ -452,7 +522,7 @@ class StreamSwitcher {
|
|
|
452
522
|
this.working = false;
|
|
453
523
|
this.eventId = null;
|
|
454
524
|
debug(`[${this.sessionId}]: [ ERROR Switching from LIVE->LIVE ]`);
|
|
455
|
-
throw new Error(err);
|
|
525
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
456
526
|
}
|
|
457
527
|
default:
|
|
458
528
|
debug(`[${this.sessionId}]: SwitcherState [${state}] not implemented`);
|
|
@@ -473,6 +543,19 @@ class StreamSwitcher {
|
|
|
473
543
|
}
|
|
474
544
|
return true;
|
|
475
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* Per-source (#382): does THIS loaded VOD/live source actually provide demuxed
|
|
548
|
+
* audio? Derived from the presence of a non-empty audioSegments structure at
|
|
549
|
+
* load time, rather than assumed from the channel-level `useDemuxedAudio` flag.
|
|
550
|
+
*
|
|
551
|
+
* `useDemuxedAudio` stays the channel-level OUTPUT mode; this signal is the
|
|
552
|
+
* per-source INPUT fact used for validity/transition checks so a demuxed-output
|
|
553
|
+
* channel can still ingest a source that lacks demuxed audio (e.g. a muxed VOD)
|
|
554
|
+
* without flagging it invalid.
|
|
555
|
+
*/
|
|
556
|
+
_sourceHasDemuxedAudio(audioSegments) {
|
|
557
|
+
return !ItemIsEmpty(audioSegments);
|
|
558
|
+
}
|
|
476
559
|
async _validURI(uri) {
|
|
477
560
|
if (!uri) {
|
|
478
561
|
return false;
|
|
@@ -630,7 +713,7 @@ class StreamSwitcher {
|
|
|
630
713
|
return { mediaSegments: prerollSegments, audioSegments: prerollSegmentsAudio };
|
|
631
714
|
}
|
|
632
715
|
catch (err) {
|
|
633
|
-
throw new Error(err);
|
|
716
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
634
717
|
}
|
|
635
718
|
}
|
|
636
719
|
_createCustomSimpleSegmentList(segmentList, manifestURI) {
|
|
@@ -764,9 +847,14 @@ class StreamSwitcher {
|
|
|
764
847
|
}
|
|
765
848
|
else {
|
|
766
849
|
storedLength = langs.length;
|
|
850
|
+
// @ts-ignore -- pre-existing behavior: `startIdx` is `const`; this path
|
|
851
|
+
// throws at runtime if ever reached. Preserved byte-for-byte in the TS
|
|
852
|
+
// port (type-only migration, #374) rather than "fixed" here.
|
|
767
853
|
startIdx++;
|
|
768
854
|
}
|
|
769
855
|
}
|
|
856
|
+
// @ts-ignore -- pre-existing behavior: `groupIds`/`langs` are block-scoped to
|
|
857
|
+
// the while body above and are not in scope here. Preserved as-is.
|
|
770
858
|
return { groupId: groupIds[startIdx], lang: langs[idx - storedLength] };
|
|
771
859
|
}
|
|
772
860
|
// Input: hls vod uri. Output: an M3U object.
|
|
@@ -1,8 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
declare class SubtitleSlicer {
|
|
3
|
-
vttFiles: {};
|
|
4
|
-
getVttFile(url: any): Promise<any>;
|
|
5
|
-
checkTimeStamp(line: any, startTime: any, endTime: any, elapsedtime: any): boolean;
|
|
6
|
-
streamToString(stream: any): Promise<any>;
|
|
7
|
-
generateVtt(params: any, _injectedVttFile: any, _injectedPreviousVttFile: any): Promise<string>;
|
|
8
|
-
}
|
|
1
|
+
export {};
|
package/dist/engine/util.d.ts
CHANGED
|
@@ -1,29 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
export function applyFilter(profiles: any, filter: any): any;
|
|
3
|
-
export function cloudWatchLog(silent: any, type: any, logEntry: any): void;
|
|
4
|
-
export function m3u8Header(instanceId: any): string;
|
|
5
|
-
export function toHHMMSS(secs: any): string;
|
|
6
|
-
export function logerror(sessionId: any, err: any): void;
|
|
7
|
-
export function timer(ms: any): Promise<any>;
|
|
8
|
-
export class WaitTimeGenerator {
|
|
9
|
-
constructor(defaultIntervalMs: any, minValue: any);
|
|
10
|
-
timestamp: Date;
|
|
11
|
-
prevWaitTime: any;
|
|
12
|
-
defaultIntervalMs: any;
|
|
13
|
-
minValue: any;
|
|
14
|
-
_getWaitTimeFromTimestamp(): number;
|
|
15
|
-
getWaitTime(plannedTime: any): Promise<any>;
|
|
16
|
-
}
|
|
17
|
-
export function ItemIsEmpty(obj: any): boolean;
|
|
18
|
-
export function findNearestValue(val: any, array: any): any;
|
|
19
|
-
export function isValidUrl(url: any): boolean;
|
|
20
|
-
export function fetchWithRetry(uri: any, opts: any, maxRetries: any, retryDelayMs: any, timeoutMs: any, debug: any): Promise<any>;
|
|
21
|
-
export function codecsFromString(codecs: any): any[];
|
|
22
|
-
export function timeLeft(endTimestamp: any, currentTimestamp: any): string;
|
|
23
|
-
export function findAudioGroupOrLang(val: any, array: any): any;
|
|
24
|
-
export function roundToThreeDecimals(number: any): any;
|
|
25
|
-
export const COMMON_HEADERS: {
|
|
26
|
-
"Content-Type": string;
|
|
27
|
-
"Access-Control-Allow-Origin": string;
|
|
28
|
-
"Cache-Control": string;
|
|
29
|
-
};
|
|
1
|
+
export {};
|
package/dist/engine/util.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
2
3
|
const { version } = require("../package.json");
|
|
3
4
|
const fetch = require("node-fetch");
|
|
4
5
|
const { AbortController } = require("abort-controller");
|
|
@@ -129,7 +130,7 @@ class WaitTimeGenerator {
|
|
|
129
130
|
}
|
|
130
131
|
const sec = this.timestamp.getSeconds();
|
|
131
132
|
const defaultSec = this.defaultIntervalMs / 1000;
|
|
132
|
-
const d = parseInt(sec / defaultSec);
|
|
133
|
+
const d = parseInt((sec / defaultSec));
|
|
133
134
|
const waitSec = defaultSec * (d + 1) - sec;
|
|
134
135
|
return waitSec * 1000;
|
|
135
136
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Reference implementation showing a channel configured for
|
|
4
|
+
* HLS-interstitial (SGAI) ad breaks.
|
|
5
|
+
*
|
|
6
|
+
* When `adBreak.enabled` is true and an `adServerUri` is configured, the
|
|
7
|
+
* engine annotates the served manifest with an EXT-X-DATERANGE HLS
|
|
8
|
+
* interstitial (class `com.apple.hls.interstitial`) at the VOD boundary and
|
|
9
|
+
* resolves the ad asset from the ad-serving endpoint. If the endpoint cannot
|
|
10
|
+
* be reached in time (2s), the break falls back to the configured slate.
|
|
11
|
+
* See `docs/reference.md` for the full field reference.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
const index_1 = require("../index");
|
|
15
|
+
class RefAssetManager {
|
|
16
|
+
assets;
|
|
17
|
+
pos;
|
|
18
|
+
constructor(opts) {
|
|
19
|
+
this.assets = {
|
|
20
|
+
"1": [
|
|
21
|
+
{
|
|
22
|
+
id: 1,
|
|
23
|
+
title: "Tears of Steel",
|
|
24
|
+
uri: "https://maitv-vod.lab.eyevinn.technology/tearsofsteel_4k.mov/master.m3u8",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 2,
|
|
28
|
+
title: "VINN",
|
|
29
|
+
uri: "https://maitv-vod.lab.eyevinn.technology/VINN.mp4/master.m3u8",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
this.pos = { "1": 0 };
|
|
34
|
+
}
|
|
35
|
+
getNextVod(vodRequest) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const channelId = vodRequest.playlistId;
|
|
38
|
+
if (this.assets[channelId]) {
|
|
39
|
+
const vod = this.assets[channelId][this.pos[channelId]++];
|
|
40
|
+
if (this.pos[channelId] > this.assets[channelId].length - 1) {
|
|
41
|
+
this.pos[channelId] = 0;
|
|
42
|
+
}
|
|
43
|
+
resolve({ id: vod.id, title: vod.title, uri: vod.uri });
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
reject("Invalid channelId provided");
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
class RefChannelManager {
|
|
52
|
+
channels;
|
|
53
|
+
constructor() {
|
|
54
|
+
this.channels = [
|
|
55
|
+
{
|
|
56
|
+
id: "1",
|
|
57
|
+
profile: this._getProfile(),
|
|
58
|
+
// Per-channel ad-break configuration. Overrides the engine-level
|
|
59
|
+
// `adBreak` default when present.
|
|
60
|
+
adBreak: {
|
|
61
|
+
// Master enable flag. When false (the default) no interstitial
|
|
62
|
+
// metadata is emitted and the manifest is unchanged.
|
|
63
|
+
enabled: true,
|
|
64
|
+
// Ad-serving endpoint queried to fill the break. Must be an
|
|
65
|
+
// absolute http(s) URL when enabled. It should answer with JSON
|
|
66
|
+
// shaped as { assetUri } | { assetList } | { assets: [...] }.
|
|
67
|
+
adServerUri: "https://ad-endpoint.example.com/vast",
|
|
68
|
+
// Slate shown while the break is being resolved, and used as the
|
|
69
|
+
// fallback if the ad-serving endpoint times out or errors.
|
|
70
|
+
slate: {
|
|
71
|
+
uri: "https://maitv-vod.lab.eyevinn.technology/slate-consuo.mp4/master.m3u8",
|
|
72
|
+
repetitions: 10,
|
|
73
|
+
duration: 4000,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
getChannels() {
|
|
80
|
+
return this.channels;
|
|
81
|
+
}
|
|
82
|
+
_getProfile() {
|
|
83
|
+
return [
|
|
84
|
+
{ bw: 6134000, codecs: "avc1.4d001f,mp4a.40.2", resolution: [1024, 458] },
|
|
85
|
+
{ bw: 2323000, codecs: "avc1.4d001f,mp4a.40.2", resolution: [640, 286] },
|
|
86
|
+
{ bw: 1313000, codecs: "avc1.4d001f,mp4a.40.2", resolution: [480, 214] },
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const refAssetManager = new RefAssetManager();
|
|
91
|
+
const refChannelManager = new RefChannelManager();
|
|
92
|
+
const engineOptions = {
|
|
93
|
+
heartbeat: "/",
|
|
94
|
+
averageSegmentDuration: 2000,
|
|
95
|
+
channelManager: refChannelManager,
|
|
96
|
+
defaultSlateUri: "https://maitv-vod.lab.eyevinn.technology/slate-consuo.mp4/master.m3u8",
|
|
97
|
+
slateRepetitions: 10,
|
|
98
|
+
// Engine-level default applied to channels that do not carry their own
|
|
99
|
+
// `adBreak`. Left disabled here so only channel "1" opens ad breaks.
|
|
100
|
+
adBreak: { enabled: false },
|
|
101
|
+
};
|
|
102
|
+
const engine = new index_1.ChannelEngine(refAssetManager, engineOptions);
|
|
103
|
+
engine.start();
|
|
104
|
+
engine.listen(process.env.PORT || 8000);
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { ChannelEngine, ChannelEngineOpts, IAssetManager, IChannelManager, IStreamSwitchManager, VodResponse, VodRequest, Channel, ChannelProfile, AudioTracks, SubtitleTracks, Schedule, } from "./engine/server";
|
|
1
|
+
export { ChannelEngine, ChannelEngineOpts, AdBreakOpts, SlateOpts, IAssetManager, IChannelManager, IStreamSwitchManager, VodResponse, VodRequest, Channel, ChannelProfile, AudioTracks, SubtitleTracks, Schedule, } from "./engine/server";
|
package/dist/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eyevinn-channel-engine",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"description": "OTT TV Channel Engine",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "tsc --project ./ && cp ./package.json dist/",
|
|
9
9
|
"debug-server": "DEBUG=engine-*,vod-to-live node dist/server.js",
|
|
10
|
+
"typecheck:spec": "tsc --project ./tsconfig.spec.json",
|
|
11
|
+
"pretest": "npm run typecheck:spec",
|
|
10
12
|
"test": "jasmine",
|
|
11
13
|
"version:rc": "npm version prerelease --preid=rc",
|
|
12
14
|
"postversion": "git push && git push --tags",
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
"start-livemix-demux": "node dist/examples/livemix-demux.js"
|
|
17
19
|
},
|
|
18
20
|
"engines": {
|
|
19
|
-
"node": ">=
|
|
21
|
+
"node": "^22.23.4 || >=24.22.1"
|
|
20
22
|
},
|
|
21
23
|
"author": "Eyevinn Technology AB <work@eyevinn.se>",
|
|
22
24
|
"contributors": [
|
|
@@ -36,18 +38,32 @@
|
|
|
36
38
|
"@eyevinn/hls-truncate": "^0.4.0",
|
|
37
39
|
"@eyevinn/hls-vodtolive": "^4.1.10",
|
|
38
40
|
"@eyevinn/m3u8": "^0.5.6",
|
|
39
|
-
"@fastify/static": "^
|
|
41
|
+
"@fastify/static": "^10.1.2",
|
|
40
42
|
"abort-controller": "^3.0.0",
|
|
41
43
|
"debug": "^3.2.7",
|
|
42
|
-
"fastify": "^5.
|
|
44
|
+
"fastify": "^5.12.1",
|
|
43
45
|
"ioredis": "^5.3.2",
|
|
44
46
|
"memcache-client": "^0.10.1",
|
|
45
47
|
"node-fetch": "^2.6.1",
|
|
46
48
|
"promise.allsettled": "^1.0.4",
|
|
47
49
|
"uuid": "^14.0.0"
|
|
48
50
|
},
|
|
51
|
+
"overrides": {
|
|
52
|
+
"fast-uri": "^3.1.6",
|
|
53
|
+
"@eyevinn/hls-truncate": {
|
|
54
|
+
"node-fetch": "^2.6.7"
|
|
55
|
+
},
|
|
56
|
+
"@fastify/static": {
|
|
57
|
+
"brace-expansion": "^5.0.12"
|
|
58
|
+
},
|
|
59
|
+
"jasmine": {
|
|
60
|
+
"brace-expansion": "^1.1.21"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
49
63
|
"devDependencies": {
|
|
64
|
+
"@types/jasmine": "^3.10.19",
|
|
50
65
|
"@types/node": "^22.0.0",
|
|
66
|
+
"@types/uuid": "^10.0.0",
|
|
51
67
|
"jasmine": "^3.1.0",
|
|
52
68
|
"nock": "^13.1.1",
|
|
53
69
|
"typescript": "^5.0.0",
|