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,5 +1,6 @@
1
1
  const crypto = require('crypto');
2
2
  const debug = require('debug')('engine-session');
3
+ const fetch = require('node-fetch');
3
4
  const HLSVod = require('@eyevinn/hls-vodtolive');
4
5
  const m3u8 = require('@eyevinn/m3u8');
5
6
  const HLSRepeatVod = require('@eyevinn/hls-repeat');
@@ -20,13 +21,22 @@ const {
20
21
  const ChaosMonkey = require('./chaos_monkey.js');
21
22
 
22
23
  const EVENT_LIST_LIMIT = 100;
24
+ // Rejection marker used by `_getNextVod()` when, in `event` mode, the asset
25
+ // manager signals that there are no more VODs to play (see issue #362).
26
+ const END_OF_SCHEDULE = "END_OF_SCHEDULE";
23
27
  const DEFAULT_PLAYHEAD_DIFF_THRESHOLD = 1000;
24
28
  const DEFAULT_MAX_TICK_INTERVAL = 10000;
25
29
  const DEFAULT_DIFF_COMPENSATION_RATE = 0.5;
30
+ // Max time (ms) to wait for the configured ad-serving endpoint (#370) before
31
+ // giving up and falling back to the slate-only break. Kept short so a slow or
32
+ // unreachable endpoint never stalls a session tick.
33
+ const AD_SERVER_TIMEOUT_MS = 2000;
26
34
 
27
35
  const timer = ms => new Promise(res => setTimeout(res, ms));
28
36
 
29
37
  class Session {
38
+ [key: string]: any;
39
+
30
40
  /**
31
41
  *
32
42
  * config: {
@@ -34,7 +44,7 @@ class Session {
34
44
  * }
35
45
  *
36
46
  */
37
- constructor(assetManager, config, sessionStore) {
47
+ constructor(assetManager: any, config: any, sessionStore: any) {
38
48
  this._assetManager = assetManager;
39
49
  this._sessionId = crypto.randomBytes(20).toString('hex');
40
50
 
@@ -88,6 +98,22 @@ class Session {
88
98
  this.currentPlayheadRef = null;
89
99
  this.disableLegacyMasterManifestFormat = null;
90
100
  this.rollingPDT = null;
101
+ this.event = false;
102
+ // End-of-schedule detection (issue #362). Only meaningful when `event` mode is
103
+ // enabled. Set to true once the asset manager has signalled that there are no
104
+ // more VODs to play (see `_isEndOfScheduleSignal`). ENDLIST emission based on
105
+ // this state is out of scope here and handled by #363.
106
+ this.isEndOfSchedule = false;
107
+ // Ad-break / interstitial config surface (issue #367). Disabled by default;
108
+ // no interstitial tags are emitted from this yet (follow-up #368).
109
+ this.adBreak = { enabled: false };
110
+ // Custom pass-through parameters forwarded to the asset manager's
111
+ // getNextVod() (issue #378). Populated per-session by the master-manifest
112
+ // request handler (wired up in #379) after filtering against the
113
+ // customVodRequestParams allowlist (#377). Defaults to an empty object so
114
+ // that, until a setter populates it, existing IAssetManager implementations
115
+ // are unaffected.
116
+ this.customParams = {};
91
117
  if (config) {
92
118
  if (config.alwaysNewSegments) {
93
119
  this.alwaysNewSegments = config.alwaysNewSegments;
@@ -101,6 +127,9 @@ class Session {
101
127
  if (config.rollingPDT) {
102
128
  this.rollingPDT = config.rollingPDT;
103
129
  }
130
+ if (config.event) {
131
+ this.event = config.event;
132
+ }
104
133
  if (config.alwaysMapBandwidthByNearest) {
105
134
  this.alwaysMapBandwidthByNearest = config.alwaysMapBandwidthByNearest;
106
135
  }
@@ -137,7 +166,23 @@ class Session {
137
166
  this._audioTracks = config.audioTracks;
138
167
  }
139
168
  if (config.subtitleTracks) {
140
- this._subtitleTracks = config.subtitleTracks;
169
+ // Normalize expected subtitle tracks before forwarding them to
170
+ // @eyevinn/hls-vodtolive as expectedSubtitleTracks. The dependency's
171
+ // matcher (see node_modules/@eyevinn/hls-vodtolive/index.js) compares a
172
+ // source SUBTITLES rendition against BOTH element.language and
173
+ // element.name and unconditionally calls element.name.toLowerCase().
174
+ // When a source manifest omits LANGUAGE it falls back to matching on the
175
+ // manifest NAME. If an operator configures a track with only a
176
+ // `language` (no `name`), the matcher throws on the missing name and no
177
+ // track is ever matched (issue #385, broken out from #325). Defaulting
178
+ // `name` to `language` keeps the contract well-formed so a NAME-only
179
+ // source whose NAME equals the configured language/name still resolves
180
+ // to real cues instead of dummy vtt. This is not a fuzzy match: it only
181
+ // fills in an omitted field with the operator's own value.
182
+ this._subtitleTracks = config.subtitleTracks.map((track) => ({
183
+ ...track,
184
+ name: track.name != null ? track.name : track.language,
185
+ }));
141
186
  }
142
187
  if (config.closedCaptions) {
143
188
  this._closedCaptions = config.closedCaptions;
@@ -154,6 +199,10 @@ class Session {
154
199
  this.slateDuration = config.slateDuration || 4000;
155
200
  debug(`Will use slate URI ${this.slateUri} (${this.slateRepetitions} ${this.slateDuration}ms)`);
156
201
  }
202
+ if (config.adBreak && config.adBreak.enabled) {
203
+ this.adBreak = config.adBreak;
204
+ debug(`Ad breaks enabled, ad server URI ${this.adBreak.adServerUri}`);
205
+ }
157
206
  if (config.cloudWatchMetrics) {
158
207
  this.cloudWatchLogging = true;
159
208
  }
@@ -193,6 +242,14 @@ class Session {
193
242
  return this._sessionId;
194
243
  }
195
244
 
245
+ // Store the custom pass-through parameters for this session (issue #378).
246
+ // Intended to be called by the master-manifest request handler (#379) with the
247
+ // already-allowlist-filtered params. Passing a falsy value clears them back to
248
+ // an empty object so nothing extra is forwarded to the asset manager.
249
+ setCustomParams(customParams) {
250
+ this.customParams = customParams || {};
251
+ }
252
+
196
253
  async startPlayheadAsync() {
197
254
  debug(`[${this._sessionId}]: Playhead consumer started:`);
198
255
  debug(`[${this._sessionId}]: diffThreshold=${this.playheadDiffThreshold}`);
@@ -272,7 +329,7 @@ class Session {
272
329
  }
273
330
  // Apply external diff compensation if available.
274
331
  if (this.diffCompensation && this.diffCompensation > 0) {
275
- const DIFF_COMPENSATION = (reqTickInterval * this.diffCompensationRate).toFixed(2) * 1000;
332
+ const DIFF_COMPENSATION = ((reqTickInterval * this.diffCompensationRate).toFixed(2) as any) * 1000;
276
333
  debug(`[${this._sessionId}]: Adding ${DIFF_COMPENSATION}msec to tickInterval to compensate for schedule diff (current=${this.diffCompensation}msec)`);
277
334
  tickInterval += (DIFF_COMPENSATION / 1000);
278
335
  this.diffCompensation -= DIFF_COMPENSATION;
@@ -312,7 +369,7 @@ class Session {
312
369
  }
313
370
  } catch (err) {
314
371
  debug(`[${this._sessionId}]: Playhead consumer crashed (1)`);
315
- console.error(`[${this._sessionId}]: ${err.message}`);
372
+ console.error(`[${this._sessionId}]: ${err instanceof Error ? err.message : err}`);
316
373
  cloudWatchLog(!this.cloudWatchLogging, 'engine-session',
317
374
  { event: 'error', on: 'playhead', channel: this._sessionId, err: err });
318
375
  debug(err);
@@ -347,7 +404,7 @@ class Session {
347
404
  playheadStateMap[PlayheadState.CRASHED] = 'crashed';
348
405
  playheadStateMap[PlayheadState.STOPPED] = 'stopped';
349
406
 
350
- const status = {
407
+ const status: any = {
351
408
  sessionId: this._sessionId,
352
409
  playhead: {
353
410
  state: playheadStateMap[state],
@@ -356,6 +413,10 @@ class Session {
356
413
  },
357
414
  slateInserted: sessionState.slateCount,
358
415
  };
416
+ if (this.event) {
417
+ // Surface the end-of-schedule state only in event mode (issue #362).
418
+ status.endOfSchedule = this.isEndOfSchedule;
419
+ }
359
420
  return status;
360
421
  }
361
422
  }
@@ -413,7 +474,7 @@ class Session {
413
474
  let waitTimeMs = 2000;
414
475
  for (let i = segments[Object.keys(segments)[0]].length - 1; 0 < i; i--) {
415
476
  if (segments[Object.keys(segments)[0]][i].duration) {
416
- waitTimeMs = parseInt(1000 * (segments[Object.keys(segments)[0]][i].duration / 3), 10);
477
+ waitTimeMs = parseInt((1000 * (segments[Object.keys(segments)[0]][i].duration / 3)) as any, 10);
417
478
  break;
418
479
  }
419
480
  }
@@ -425,7 +486,7 @@ class Session {
425
486
  for (let i = audioSegments[groupId][lang].length - 1; 0 < i; i--) {
426
487
  const segment = audioSegments[groupId][lang][i];
427
488
  if (segment.duration) {
428
- waitTimeMsAudio = parseInt(1000 * (segment.duration / 3), 10);
489
+ waitTimeMsAudio = parseInt((1000 * (segment.duration / 3)) as any, 10);
429
490
  break;
430
491
  }
431
492
  }
@@ -724,7 +785,7 @@ class Session {
724
785
  }
725
786
  return null;
726
787
  }
727
- let newSessionState = {};
788
+ let newSessionState: any = {};
728
789
  if (sessionState.state === SessionState.VOD_NEXT_INITIATING || sessionState.state === SessionState.VOD_RELOAD_INITIATING) {
729
790
  if (isLeader) {
730
791
  const isOldVod = this._isOldVod(playheadState.playheadRef, currentVod.getDuration());
@@ -756,7 +817,11 @@ class Session {
756
817
  let playheadAudio;
757
818
  let playheadSubtitle;
758
819
 
759
- playheadPosVideoMs = (await this._getCurrentPlayheadPosition()) * 1000;
820
+ // Pass the locally-updated vodMediaSeqVideo (leader incremented above) so
821
+ // playhead position reflects this tick's video segment, not the stale
822
+ // value still in Redis. Without this, audio/subtitle stay 1 segment
823
+ // behind video for the entire VOD lifetime.
824
+ playheadPosVideoMs = (await this._getCurrentPlayheadPosition(sessionState.vodMediaSeqVideo)) * 1000;
760
825
 
761
826
  if (this.use_demuxed_audio) {
762
827
  const audioSeqLastIdx = currentVod.getLiveMediaSequencesCount("audio") - 1;
@@ -1300,6 +1365,8 @@ class Session {
1300
1365
  newVod.addMetadata(k, vodResponse.timedMetadata[k]);
1301
1366
  })
1302
1367
  }
1368
+ const firstVodAdAsset = await this._resolveAdBreakAsset();
1369
+ this._addInterstitialMetadata(newVod, vodResponse.unixTs, firstVodAdAsset);
1303
1370
  currentVod = newVod;
1304
1371
  if (vodResponse.desiredDuration) {
1305
1372
  try {
@@ -1414,7 +1481,7 @@ class Session {
1414
1481
  }
1415
1482
  } else {
1416
1483
  // Handle edge case where Leader loaded next vod but Follower remained in state=VOD_PLAYING
1417
- if ((this.prevMediaSeqOffset.video !== null) & (sessionState.mediaSeq !== this.prevMediaSeqOffset.video)) {
1484
+ if (((this.prevMediaSeqOffset.video !== null) as any) & ((sessionState.mediaSeq !== this.prevMediaSeqOffset.video) as any)) {
1418
1485
  debug(`[${this._sessionId}]: state=VOD_PLAYING, current[${sessionState.vodMediaSeqVideo}], prev[${this.prevVodMediaSeq.video}], total[${currentVod.getLiveMediaSequencesCount()}]`);
1419
1486
  debug(`[${this._sessionId}]: mediaSeq offsets -> current[${sessionState.vodMediaSeqVideo}], prev[${this.prevVodMediaSeq.video}]`);
1420
1487
  // Allow Follower to clear VodCache...
@@ -1489,6 +1556,8 @@ class Session {
1489
1556
  newVod.addMetadata(k, vodResponse.timedMetadata[k]);
1490
1557
  })
1491
1558
  }
1559
+ const nextVodAdAsset = await this._resolveAdBreakAsset();
1560
+ this._addInterstitialMetadata(newVod, vodResponse.unixTs, nextVodAdAsset);
1492
1561
  this.produceEvent({
1493
1562
  type: 'NEXT_VOD_SELECTED',
1494
1563
  data: {
@@ -1592,6 +1661,14 @@ class Session {
1592
1661
  cloudWatchLog(!this.cloudWatchLogging, 'engine-session',
1593
1662
  { event: 'error', on: 'nextvod', channel: this._sessionId, err: err, vod: vodResponse });
1594
1663
  await this._sessionState.remove("nextVod");
1664
+ if (this.event && err === END_OF_SCHEDULE) {
1665
+ // Event mode + end of schedule (issue #362): the session has already
1666
+ // recorded its `ended` state in `_getNextVod()`. Do NOT fall through to
1667
+ // slate insertion (that is the non-event retry behaviour). ENDLIST
1668
+ // manifest handling is out of scope here and belongs to #363/#364.
1669
+ debug(`[${this._sessionId}]: end of schedule reached, keeping current VOD (no slate in event mode)`);
1670
+ break;
1671
+ }
1595
1672
  currentVod = await this._insertSlate(currentVod);
1596
1673
  if (!currentVod) {
1597
1674
  debug("No slate to load");
@@ -1767,15 +1844,306 @@ class Session {
1767
1844
  }
1768
1845
  }
1769
1846
 
1847
+ /**
1848
+ * The "no more VODs" contract (issue #362).
1849
+ *
1850
+ * `AssetManager.getNextVod()` is normally expected to resolve with a `vodResponse`
1851
+ * that either carries a playable `uri`, or is a `{ type: 'gap' }` marker. The
1852
+ * end-of-schedule signal is therefore the natural absence of both: the asset
1853
+ * manager resolving with a falsy value (`null`/`undefined`) or with an object
1854
+ * that has neither a `uri` nor `type === 'gap'` (e.g. an empty `{}`).
1855
+ *
1856
+ * This is detection only — deciding what the session does with the signal is
1857
+ * handled by the caller (and, later, ENDLIST emission by #363).
1858
+ */
1859
+ /**
1860
+ * Append #EXT-X-ENDLIST to a served media playlist when, in event mode, the
1861
+ * schedule has ended (issue #363). Returns the manifest unchanged otherwise, so
1862
+ * the tag never appears while content is still playing or when event mode is off.
1863
+ * Idempotent: never adds a second ENDLIST if one is already present.
1864
+ */
1865
+ _appendEndlistIfEnded(m3u8) {
1866
+ if (!m3u8 || !this.event || !this.isEndOfSchedule) {
1867
+ return m3u8;
1868
+ }
1869
+ if (/#EXT-X-ENDLIST/.test(m3u8)) {
1870
+ return m3u8;
1871
+ }
1872
+ const separator = m3u8.endsWith("\n") ? "" : "\n";
1873
+ return m3u8 + separator + "#EXT-X-ENDLIST\n";
1874
+ }
1875
+
1876
+ // Attach the HLS-interstitial EXT-X-DATERANGE metadata for a configured ad
1877
+ // break to a freshly-created content VOD (issue #368). Reuses the existing
1878
+ // per-VOD `addMetadata`/`rangeMetadata` daterange pathway (the same one the
1879
+ // slate VODs use for id/start-date/planned-duration), so the tag is rendered
1880
+ // by @eyevinn/hls-vodtolive at the VOD's start segment. The `class` and
1881
+ // `x-asset-uri`/`x-asset-list` keys are uppercased and rendered as standard
1882
+ // DATERANGE attributes by the vod lib's daterange formatter.
1883
+ //
1884
+ // Strictly gated on `this.adBreak.enabled`: when ad breaks are disabled (the
1885
+ // default) this is a no-op and the served manifest is byte-identical to the
1886
+ // pre-#368 output.
1887
+ //
1888
+ // Asset-source precedence (#370): when the caller has resolved a real ad
1889
+ // asset from the configured ad-serving endpoint (`_resolveAdBreakAsset()`),
1890
+ // it is passed in as `resolvedAsset` and its `assetUri`/`assetList` becomes
1891
+ // the interstitial `X-ASSET-URI`/`X-ASSET-LIST`. When no endpoint asset was
1892
+ // resolved (endpoint unconfigured, or a timeout/HTTP-error/malformed response
1893
+ // that made the resolver fall back), we degrade to the #367 config reference
1894
+ // (slate.uri preferred, then adServerUri) so the break still renders.
1895
+ _addInterstitialMetadata(vod, timestamp, resolvedAsset) {
1896
+ if (!vod || !this.adBreak || !this.adBreak.enabled) {
1897
+ return;
1898
+ }
1899
+ const fallbackUri =
1900
+ (this.adBreak.slate && this.adBreak.slate.uri) || this.adBreak.adServerUri;
1901
+ const assetList = resolvedAsset && resolvedAsset.assetList;
1902
+ const assetUri = (resolvedAsset && resolvedAsset.assetUri) || fallbackUri;
1903
+ if (!assetList && !assetUri) {
1904
+ return;
1905
+ }
1906
+ const startDate = new Date(
1907
+ typeof timestamp === "number" ? timestamp : Date.now()
1908
+ ).toISOString();
1909
+ let plannedDuration = 0;
1910
+ if (this.adBreak.slate && this.adBreak.slate.duration) {
1911
+ const reps = this.adBreak.slate.repetitions || 1;
1912
+ plannedDuration = (reps * this.adBreak.slate.duration) / 1000;
1913
+ }
1914
+ vod.addMetadata("class", "com.apple.hls.interstitial");
1915
+ vod.addMetadata("id", `adbreak-${this._sessionId}-${startDate}`);
1916
+ vod.addMetadata("start-date", startDate);
1917
+ if (plannedDuration > 0) {
1918
+ vod.addMetadata("planned-duration", plannedDuration);
1919
+ }
1920
+ // A resolved asset-list (multiple ads) takes precedence over a single URI
1921
+ // and maps to X-ASSET-LIST; otherwise a single X-ASSET-URI is emitted.
1922
+ if (assetList) {
1923
+ vod.addMetadata("x-asset-list", assetList);
1924
+ } else {
1925
+ vod.addMetadata("x-asset-uri", assetUri);
1926
+ }
1927
+ }
1928
+
1929
+ // Ad-serving endpoint client (issue #370).
1930
+ //
1931
+ // When an ad break is enabled and an `adBreak.adServerUri` is configured,
1932
+ // query that endpoint and map its JSON response to the interstitial asset
1933
+ // reference consumed by `_addInterstitialMetadata`.
1934
+ //
1935
+ // Response contract (kept deliberately generic — no ad-product specifics):
1936
+ // { "assetUri": "<uri>" } -> single X-ASSET-URI
1937
+ // { "assetList": "<uri>" } -> an X-ASSET-LIST endpoint
1938
+ // { "assets": ["<uri>", ...] } -> first entry -> X-ASSET-URI
1939
+ // Returns `{ assetUri }` or `{ assetList }` on success, or `null` when there
1940
+ // is nothing to resolve or on ANY failure.
1941
+ //
1942
+ // Log-and-continue (per CLAUDE.md): a timeout, non-2xx HTTP status, network
1943
+ // error, or malformed/empty JSON body all resolve to `null` (never throw), so
1944
+ // the caller falls back to the slate-only break and the session tick is never
1945
+ // interrupted. Returns `null` immediately (no fetch) when the feature is
1946
+ // disabled or no `adServerUri` is configured.
1947
+ async _resolveAdBreakAsset(fetchImpl?: any) {
1948
+ if (!this.adBreak || !this.adBreak.enabled || !this.adBreak.adServerUri) {
1949
+ return null;
1950
+ }
1951
+ const doFetch = fetchImpl || fetch;
1952
+ const uri = this.adBreak.adServerUri;
1953
+ const controller = new AbortController();
1954
+ const timeout = setTimeout(() => controller.abort(), AD_SERVER_TIMEOUT_MS);
1955
+ try {
1956
+ const response = await doFetch(uri, {
1957
+ signal: controller.signal,
1958
+ headers: { accept: "application/json" }
1959
+ });
1960
+ if (!response || !response.ok) {
1961
+ debug(
1962
+ `[${this._sessionId}]: ad-server endpoint ${uri} returned ` +
1963
+ `${response ? response.status : "no response"}, falling back to slate`
1964
+ );
1965
+ return null;
1966
+ }
1967
+ const body = await response.json();
1968
+ if (!body || typeof body !== "object") {
1969
+ debug(`[${this._sessionId}]: ad-server endpoint ${uri} returned a non-object body, falling back to slate`);
1970
+ return null;
1971
+ }
1972
+ if (typeof body.assetList === "string" && body.assetList) {
1973
+ return { assetList: body.assetList };
1974
+ }
1975
+ if (typeof body.assetUri === "string" && body.assetUri) {
1976
+ return { assetUri: body.assetUri };
1977
+ }
1978
+ if (Array.isArray(body.assets) && typeof body.assets[0] === "string" && body.assets[0]) {
1979
+ return { assetUri: body.assets[0] };
1980
+ }
1981
+ debug(`[${this._sessionId}]: ad-server endpoint ${uri} response had no usable asset, falling back to slate`);
1982
+ return null;
1983
+ } catch (err) {
1984
+ // Covers AbortError (timeout), network errors, and JSON parse errors.
1985
+ debug(`[${this._sessionId}]: ad-server endpoint ${uri} request failed (${err instanceof Error ? err.message : err}), falling back to slate`);
1986
+ return null;
1987
+ } finally {
1988
+ clearTimeout(timeout);
1989
+ }
1990
+ }
1991
+
1992
+ // Ad-break slate coordination (issue #369).
1993
+ //
1994
+ // Builds the slate VOD that plays as the ad-break filler and brackets it with
1995
+ // the #368 interstitial EXT-X-DATERANGE. The slate is driven by the #367
1996
+ // `adBreak.slate` config (uri/repetitions/duration): a `HLSRepeatVod` repeats
1997
+ // the slate asset `repetitions` times, so the slate segment run spans exactly
1998
+ // `repetitions * duration / 1000` seconds — the very same value
1999
+ // `_addInterstitialMetadata` computes for PLANNED-DURATION. Attaching that
2000
+ // interstitial metadata to this slate VOD (at `unixTs`, its first segment)
2001
+ // makes the DATERANGE START-DATE fall on the break boundary and its
2002
+ // PLANNED-DURATION span exactly the slate run. Downstream (SSAI or the
2003
+ // client-side interstitial) the slate is what gets replaced with real ads.
2004
+ //
2005
+ // Strictly gated on `this.adBreak.enabled` AND a configured `adBreak.slate`:
2006
+ // when either is absent this returns null and every existing (non-ad-break)
2007
+ // slate path — the error/gap filler slate via `_loadSlate`/`_truncateSlate` —
2008
+ // is left completely untouched.
2009
+ _buildAdBreakSlate(afterVod, unixTs, injectSlateLoaders) {
2010
+ if (
2011
+ !this.adBreak ||
2012
+ !this.adBreak.enabled ||
2013
+ !this.adBreak.slate ||
2014
+ !this.adBreak.slate.uri
2015
+ ) {
2016
+ return Promise.resolve(null);
2017
+ }
2018
+ const slate = this.adBreak.slate;
2019
+ const reps = slate.repetitions || 1;
2020
+ const timestamp = typeof unixTs === "number" ? unixTs : Date.now();
2021
+ // `injectSlateLoaders` lets callers (tests) feed the repeated slate asset
2022
+ // from fixtures instead of the network; production passes nothing so the
2023
+ // slate is fetched from `slate.uri` as usual. It may also carry a `fetch`
2024
+ // stub used to resolve the ad-break asset (#370) without real network.
2025
+ const { master, media, fetch: fetchImpl } = injectSlateLoaders || {};
2026
+ // Resolve the real ad asset from the configured endpoint before rendering
2027
+ // the slate so the interstitial DATERANGE can reference it; on any failure
2028
+ // this yields null and the tag degrades to the slate reference.
2029
+ return this._resolveAdBreakAsset(fetchImpl).then((resolvedAsset) => new Promise((resolve, reject) => {
2030
+ try {
2031
+ const slateVod = new HLSRepeatVod(slate.uri, reps);
2032
+ let hlsVod;
2033
+ slateVod
2034
+ .load(master, media)
2035
+ .then(() => {
2036
+ const hlsOpts = {
2037
+ sequenceAlwaysContainNewSegments: this.alwaysNewSegments,
2038
+ forcedDemuxMode: this.use_demuxed_audio,
2039
+ dummySubtitleEndpoint: this.dummySubtitleEndpoint,
2040
+ subtitleSliceEndpoint: this.subtitleSliceEndpoint,
2041
+ shouldContainSubtitles: this.use_vtt_subtitles,
2042
+ expectedSubtitleTracks: this._subtitleTracks,
2043
+ alwaysMapBandwidthByNearest: this.alwaysMapBandwidthByNearest,
2044
+ skipSerializeMediaSequences: this.partialStoreHLSVod,
2045
+ calculatePDT: this.rollingPDT
2046
+ };
2047
+ hlsVod = new HLSVod(slate.uri, null, timestamp, null, m3u8Header(this._instanceId), hlsOpts);
2048
+ // Bracket exactly the slate: the interstitial DATERANGE lands on the
2049
+ // slate's first segment (START-DATE = break boundary) and its
2050
+ // PLANNED-DURATION equals the slate run length. The asset reference
2051
+ // is the endpoint-resolved ad (or the slate fallback) from #370.
2052
+ this._addInterstitialMetadata(hlsVod, timestamp, resolvedAsset);
2053
+ const slateMediaManifestLoader = (bw) => {
2054
+ let mediaManifestStream = new Readable();
2055
+ // `HLSRepeatVod` prepends an EXT-X-DISCONTINUITY before the first
2056
+ // repeated segment. That leading marker would occupy the VOD's
2057
+ // first-segment slot and displace the interstitial DATERANGE
2058
+ // (which the vod lib attaches only to the true first segment),
2059
+ // breaking the "tag brackets exactly the slate" alignment. Drop
2060
+ // that single leading discontinuity so the slate run begins
2061
+ // cleanly at the break boundary and the DATERANGE lands on it.
2062
+ const slateManifest = slateVod
2063
+ .getMediaManifest(bw)
2064
+ .replace(/(#EXT-X-MEDIA-SEQUENCE:\d+\r?\n)#EXT-X-DISCONTINUITY\r?\n/, "$1");
2065
+ mediaManifestStream.push(slateManifest);
2066
+ mediaManifestStream.push(null);
2067
+ return mediaManifestStream;
2068
+ };
2069
+ if (this.use_demuxed_audio) {
2070
+ const slateAudioManifestLoader = (audioGroupId, audioLanguage) => {
2071
+ let mediaManifestStream = new Readable();
2072
+ mediaManifestStream.push(slateVod.getAudioManifest(audioGroupId, audioLanguage));
2073
+ mediaManifestStream.push(null);
2074
+ return mediaManifestStream;
2075
+ };
2076
+ let subtitleManifestLoader;
2077
+ if (this.use_vtt_subtitles && this._subtitleTracks) {
2078
+ subtitleManifestLoader = (groupId, language) => {
2079
+ let mediaManifestStream = new Readable();
2080
+ mediaManifestStream.push(slateVod.getSubtitleManifest(groupId, language));
2081
+ mediaManifestStream.push(null);
2082
+ return mediaManifestStream;
2083
+ };
2084
+ }
2085
+ if (afterVod) {
2086
+ return hlsVod.loadAfter(afterVod, master || null, slateMediaManifestLoader, slateAudioManifestLoader, subtitleManifestLoader);
2087
+ }
2088
+ return hlsVod.load(master || null, slateMediaManifestLoader, slateAudioManifestLoader, subtitleManifestLoader);
2089
+ }
2090
+ if (afterVod) {
2091
+ return hlsVod.loadAfter(afterVod, master || null, slateMediaManifestLoader);
2092
+ }
2093
+ return hlsVod.load(master || null, slateMediaManifestLoader);
2094
+ })
2095
+ .then(() => {
2096
+ resolve(hlsVod);
2097
+ })
2098
+ .catch((err) => {
2099
+ debug(err);
2100
+ reject(err);
2101
+ });
2102
+ } catch (err) {
2103
+ reject(err);
2104
+ }
2105
+ }));
2106
+ }
2107
+
2108
+ _isEndOfScheduleSignal(nextVod) {
2109
+ if (!nextVod) {
2110
+ return true;
2111
+ }
2112
+ if (nextVod.uri) {
2113
+ return false;
2114
+ }
2115
+ if (nextVod.type === 'gap') {
2116
+ return false;
2117
+ }
2118
+ return true;
2119
+ }
2120
+
1770
2121
  _getNextVod() {
1771
2122
  return new Promise((resolve, reject) => {
2123
+ // Post-end lock-down (issue #364): once the schedule has ended in event
2124
+ // mode, never ask the asset manager for another VOD. The session keeps
2125
+ // serving its final, ENDLIST-terminated playlist, so re-querying would be
2126
+ // wasteful and could reset the ended state on a late/looping asset manager.
2127
+ if (this.event && this.isEndOfSchedule) {
2128
+ debug(`[${this._sessionId}]: schedule already ended (event mode), not requesting a new VOD`);
2129
+ return reject(END_OF_SCHEDULE);
2130
+ }
2131
+
1772
2132
  let nextVodPromise;
1773
2133
 
1774
- nextVodPromise = this._assetManager.getNextVod({
2134
+ // Forward per-session custom pass-through params (issue #378) when present.
2135
+ // Only include the field if at least one param has been set, so existing
2136
+ // IAssetManager implementations that don't expect it are unaffected.
2137
+ const vodRequest: any = {
1775
2138
  sessionId: this._sessionId,
1776
2139
  category: this._category,
1777
2140
  playlistId: this._sessionId
1778
- });
2141
+ };
2142
+ if (this.customParams && Object.keys(this.customParams).length > 0) {
2143
+ vodRequest.customParams = this.customParams;
2144
+ }
2145
+
2146
+ nextVodPromise = this._assetManager.getNextVod(vodRequest);
1779
2147
 
1780
2148
  nextVodPromise.then(nextVod => {
1781
2149
  if (nextVod && nextVod.uri) {
@@ -1790,6 +2158,23 @@ class Session {
1790
2158
  title: 'GAP of ' + Math.floor(nextVod.desiredDuration) + ' sec',
1791
2159
  };
1792
2160
  resolve(nextVod);
2161
+ } else if (this.event && this._isEndOfScheduleSignal(nextVod)) {
2162
+ // Event mode: no new VOD was received from the asset manager, so the
2163
+ // schedule has ended. Record an internal end-of-schedule state and log
2164
+ // it, then reject with a distinguishable marker. No slate/retry here —
2165
+ // that behaviour is deliberately reserved for non-event mode. ENDLIST
2166
+ // manifest emission is out of scope for this issue (#363).
2167
+ this.isEndOfSchedule = true;
2168
+ debug(`[${this._sessionId}]: end of schedule detected (event mode), no new VOD received from asset manager`);
2169
+ cloudWatchLog(!this.cloudWatchLogging, 'engine-session',
2170
+ { event: 'endOfSchedule', channel: this._sessionId });
2171
+ this.produceEvent({
2172
+ type: 'END_OF_SCHEDULE',
2173
+ data: {
2174
+ sessionId: this._sessionId,
2175
+ }
2176
+ });
2177
+ reject(END_OF_SCHEDULE);
1793
2178
  } else {
1794
2179
  console.error("Invalid VOD:", nextVod);
1795
2180
  reject("Invalid VOD from asset manager")
@@ -1799,7 +2184,7 @@ class Session {
1799
2184
  });
1800
2185
  }
1801
2186
 
1802
- _loadSlate(afterVod, reps, unixTs) {
2187
+ _loadSlate(afterVod: any, reps?: any, unixTs?: any) {
1803
2188
  return new Promise((resolve, reject) => {
1804
2189
  try {
1805
2190
  const slateVod = new HLSRepeatVod(this.slateUri, reps || this.slateRepetitions);
@@ -1870,10 +2255,10 @@ class Session {
1870
2255
  });
1871
2256
  }
1872
2257
 
1873
- _truncateVod(vodResponse) {
2258
+ _truncateVod(vodResponse: any): Promise<any> {
1874
2259
  return new Promise((resolve, reject) => {
1875
2260
  try {
1876
- const options = {};
2261
+ const options: any = {};
1877
2262
  if (vodResponse.startOffset) {
1878
2263
  options.offset = vodResponse.startOffset / 1000;
1879
2264
  }
@@ -1915,7 +2300,7 @@ class Session {
1915
2300
  });
1916
2301
  }
1917
2302
 
1918
- _truncateSlate(afterVod, requestedDuration, vodUri, unixTs) {
2303
+ _truncateSlate(afterVod: any, requestedDuration: any, vodUri: any, unixTs?: any): Promise<any> {
1919
2304
  return new Promise((resolve, reject) => {
1920
2305
  let nexVodUri = null;
1921
2306
  try {
@@ -2032,7 +2417,7 @@ class Session {
2032
2417
  });
2033
2418
  }
2034
2419
 
2035
- _getFirstDuration(manifest) {
2420
+ _getFirstDuration(manifest: any): Promise<any> {
2036
2421
  return new Promise((resolve, reject) => {
2037
2422
  try {
2038
2423
  const parser = m3u8.createStream();
@@ -2068,12 +2453,21 @@ class Session {
2068
2453
  return 0;
2069
2454
  }
2070
2455
 
2071
- async _getCurrentPlayheadPosition() {
2072
- const sessionState = await this._sessionState.getValues(["vodMediaSeqVideo"]);
2456
+ async _getCurrentPlayheadPosition(overrideVodMediaSeqVideo?: any) {
2457
+ // Caller can pass the in-progress local vodMediaSeqVideo to avoid the Redis
2458
+ // round-trip and the race where the leader has incremented its local value
2459
+ // but hasn't written it to Redis yet (incrementAsync writes happen later via
2460
+ // a parallel Promise.all). Falling back to Redis preserves the old behavior
2461
+ // for callers like startPlayheadAsync that run after incrementAsync.
2462
+ let vodMediaSeqVideo = overrideVodMediaSeqVideo;
2463
+ if (vodMediaSeqVideo === undefined) {
2464
+ const sessionState = await this._sessionState.getValues(["vodMediaSeqVideo"]);
2465
+ vodMediaSeqVideo = sessionState.vodMediaSeqVideo;
2466
+ }
2073
2467
  const currentVod = await this._sessionState.getCurrentVod();
2074
2468
  const playheadPositions = currentVod.getPlayheadPositions();
2075
- debug(`[${this._sessionId}]: Current playhead position (${sessionState.vodMediaSeqVideo}): ${roundToThreeDecimals(playheadPositions[sessionState.vodMediaSeqVideo])}`);
2076
- return playheadPositions[sessionState.vodMediaSeqVideo];
2469
+ debug(`[${this._sessionId}]: Current playhead position (${vodMediaSeqVideo}): ${roundToThreeDecimals(playheadPositions[vodMediaSeqVideo])}`);
2470
+ return playheadPositions[vodMediaSeqVideo];
2077
2471
  }
2078
2472
 
2079
2473
  async _getAudioPlayheadPosition(seqIdx) {
@@ -2096,7 +2490,7 @@ class Session {
2096
2490
  return playheadPositions[seqIdx];
2097
2491
  }
2098
2492
 
2099
- _getLastDuration(manifest) {
2493
+ _getLastDuration(manifest: any): Promise<any> {
2100
2494
  return new Promise((resolve, reject) => {
2101
2495
  try {
2102
2496
  const parser = m3u8.createStream();
@@ -2345,6 +2739,11 @@ class Session {
2345
2739
  );
2346
2740
  this.prevVodMediaSeq[variantType] = playheadState[MSDKeys.vodMediaSeq];
2347
2741
  this.prevMediaSeqOffset[variantType] = playheadState[MSDKeys.mediaSeq];
2742
+ // In event mode, once the schedule has ended (issue #363) terminate the
2743
+ // served media playlist with #EXT-X-ENDLIST so players stop reloading.
2744
+ // Applied uniformly to video, demuxed-audio and subtitle variants. The tag
2745
+ // is never appended while content is still playing (isEndOfSchedule=false).
2746
+ m3u8 = this._appendEndlistIfEnded(m3u8);
2348
2747
  return m3u8;
2349
2748
  } catch (err) {
2350
2749
  logerror(this._sessionId, err);