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.
Files changed (97) hide show
  1. package/.github/workflows/nodejs.yml +1 -1
  2. package/.github/workflows/publish.yml +1 -1
  3. package/.github/workflows/rc.yml +1 -1
  4. package/CONTRIBUTING.md +40 -0
  5. package/Dockerfile +1 -1
  6. package/README.md +70 -8
  7. package/dist/engine/chaos_monkey.d.ts +1 -1
  8. package/dist/engine/chaos_monkey.js +1 -0
  9. package/dist/engine/event_stream.d.ts +1 -6
  10. package/dist/engine/event_stream.js +2 -0
  11. package/dist/engine/memcached_state_store.d.ts +1 -13
  12. package/dist/engine/memcached_state_store.js +4 -0
  13. package/dist/engine/memory_state_store.d.ts +1 -13
  14. package/dist/engine/memory_state_store.js +5 -0
  15. package/dist/engine/playhead_state.d.ts +0 -26
  16. package/dist/engine/playhead_state.js +9 -0
  17. package/dist/engine/preflight.d.ts +1 -1
  18. package/dist/engine/preflight.js +1 -0
  19. package/dist/engine/redis_state_store.d.ts +1 -19
  20. package/dist/engine/redis_state_store.js +5 -0
  21. package/dist/engine/server.d.ts +27 -0
  22. package/dist/engine/server.js +140 -6
  23. package/dist/engine/session.d.ts +1 -148
  24. package/dist/engine/session.js +398 -11
  25. package/dist/engine/session_live.d.ts +1 -181
  26. package/dist/engine/session_live.js +19 -4
  27. package/dist/engine/session_live_state.d.ts +0 -23
  28. package/dist/engine/session_live_state.js +5 -0
  29. package/dist/engine/session_state.d.ts +0 -45
  30. package/dist/engine/session_state.js +10 -0
  31. package/dist/engine/shared_state_store.d.ts +1 -25
  32. package/dist/engine/shared_state_store.js +10 -0
  33. package/dist/engine/state_store_types.d.ts +32 -0
  34. package/dist/engine/state_store_types.js +5 -0
  35. package/dist/engine/stream_switcher.d.ts +1 -40
  36. package/dist/engine/stream_switcher.js +108 -20
  37. package/dist/engine/subtitle_slicer.d.ts +1 -8
  38. package/dist/engine/subtitle_slicer.js +1 -0
  39. package/dist/engine/util.d.ts +1 -29
  40. package/dist/engine/util.js +2 -1
  41. package/dist/examples/adbreak.d.ts +1 -0
  42. package/dist/examples/adbreak.js +104 -0
  43. package/dist/index.d.ts +1 -1
  44. package/dist/package.json +20 -4
  45. package/docs/design/mixing-muxed-demuxed-audio.md +153 -0
  46. package/docs/design/subtitles-live-mix-demux.md +135 -0
  47. package/docs/reference.md +67 -0
  48. package/engine/chaos_monkey.ts +17 -0
  49. package/engine/{event_stream.js → event_stream.ts} +5 -3
  50. package/engine/{memcached_state_store.js → memcached_state_store.ts} +19 -12
  51. package/engine/memory_state_store.ts +71 -0
  52. package/engine/{playhead_state.js → playhead_state.ts} +92 -82
  53. package/engine/{preflight.js → preflight.ts} +1 -1
  54. package/engine/{redis_state_store.js → redis_state_store.ts} +216 -208
  55. package/engine/server.ts +210 -8
  56. package/engine/{session.js → session.ts} +421 -22
  57. package/engine/{session_live.js → session_live.ts} +54 -31
  58. package/engine/{session_live_state.js → session_live_state.ts} +129 -120
  59. package/engine/{session_state.js → session_state.ts} +269 -248
  60. package/engine/{shared_state_store.js → shared_state_store.ts} +31 -19
  61. package/engine/state_store_types.ts +35 -0
  62. package/engine/{stream_switcher.js → stream_switcher.ts} +133 -34
  63. package/engine/{subtitle_slicer.js → subtitle_slicer.ts} +10 -8
  64. package/engine/{util.js → util.ts} +28 -24
  65. package/examples/adbreak.ts +115 -0
  66. package/index.ts +2 -0
  67. package/package.json +20 -4
  68. package/spec/engine/custom_vod_request_params_spec.ts +99 -0
  69. package/spec/engine/drm_spec.ts +128 -0
  70. package/spec/engine/{engine_spec.js → engine_spec.ts} +44 -1
  71. package/spec/engine/{ha_spec.js → ha_spec.ts} +7 -4
  72. package/spec/engine/{init_switching_spec.js → init_switching_spec.ts} +10 -6
  73. package/spec/engine/{interface_spec.js → interface_spec.ts} +16 -4
  74. package/spec/engine/{playhead_live_spec.js → playhead_live_spec.ts} +2 -1
  75. package/spec/engine/{playhead_spec.js → playhead_spec.ts} +15 -8
  76. package/spec/engine/session_spec.ts +800 -0
  77. package/spec/engine/stream_switcher_audio_reconcile_spec.ts +158 -0
  78. package/spec/engine/{stream_switcher_spec.js → stream_switcher_spec.ts} +111 -13
  79. package/spec/engine/subtitle_missing_language_spec.ts +196 -0
  80. package/spec/engine/{subtitle_spec.js → subtitle_spec.ts} +28 -27
  81. package/spec/helpers/ts-resolve.js +65 -0
  82. package/spec/support/jasmine.json +1 -1
  83. package/spec/testvectors/hls_subs_no_language/config.subtitleTracks.json +12 -0
  84. package/spec/testvectors/hls_subs_no_language/evidence_dummy_segments_name_mismatch.m3u8 +24 -0
  85. package/spec/testvectors/hls_subs_no_language/evidence_real_cues_name_match.m3u8 +24 -0
  86. package/spec/testvectors/hls_subs_no_language/french.m3u8 +9 -0
  87. package/spec/testvectors/hls_subs_no_language/master.m3u8 +11 -0
  88. package/spec/testvectors/hls_subs_no_language/video.m3u8 +25 -0
  89. package/spec/testvectors/slate/master.m3u8 +3 -0
  90. package/spec/testvectors/slate/media.m3u8 +9 -0
  91. package/tsconfig.json +21 -4
  92. package/tsconfig.spec.json +16 -0
  93. package/engine/chaos_monkey.js +0 -17
  94. package/engine/memory_state_store.js +0 -63
  95. package/spec/engine/session_spec.js +0 -116
  96. /package/spec/engine/{session_live_spec.js → session_live_spec.ts} +0 -0
  97. /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
- if (!eventSegments || (this.useDemuxedAudio && !eventAudioSegments)) {
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
- await session.setCurrentMediaSequenceSegments(eventSegments, 0, true, eventAudioSegments, 0);
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) || (this.useDemuxedAudio && this._isEmpty(liveAudioSegments.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 (this.useDemuxedAudio) {
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 (this.useDemuxedAudio) {
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 (this.useDemuxedAudio) {
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 (this.useDemuxedAudio) {
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
- await session.setCurrentMediaSequenceSegments(eventSegments, 0, true);
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 (this.useDemuxedAudio) {
494
+ if (liveHasDemuxedAudioLTL) {
427
495
  const prerollSegmentsAudio = this.prerollsCache[this.sessionId].audioSegments;
428
496
  this._insertTimedMetadataAudio(prerollSegmentsAudio, scheduleObj.timedMetadata || {});
429
- eventSegments.currMseqSegs = this._mergeAudioSegments(prerollSegmentsAudio, eventAudioSegments.currMseqSegs, false);
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
- await sessionLive.setCurrentAudioSequenceSegments(eventAudioSegments.currMseqSegs);
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 = SubtitleSlicer;
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 {};
@@ -1,4 +1,5 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
2
3
  const fetch = require("node-fetch");
3
4
  const fs = require("fs");
4
5
  class SubtitleSlicer {
@@ -1,29 +1 @@
1
- export function filterQueryParser(filterQuery: any): {};
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 {};
@@ -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.1.2",
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": ">=20"
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": "^9.1.1",
41
+ "@fastify/static": "^10.1.2",
40
42
  "abort-controller": "^3.0.0",
41
43
  "debug": "^3.2.7",
42
- "fastify": "^5.0.0",
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",