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,6 +1,8 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
2
3
  const crypto = require('crypto');
3
4
  const debug = require('debug')('engine-session');
5
+ const fetch = require('node-fetch');
4
6
  const HLSVod = require('@eyevinn/hls-vodtolive');
5
7
  const m3u8 = require('@eyevinn/m3u8');
6
8
  const HLSRepeatVod = require('@eyevinn/hls-repeat');
@@ -11,9 +13,16 @@ const { PlayheadState } = require('./playhead_state.js');
11
13
  const { applyFilter, cloudWatchLog, m3u8Header, logerror, codecsFromString, roundToThreeDecimals } = require('./util.js');
12
14
  const ChaosMonkey = require('./chaos_monkey.js');
13
15
  const EVENT_LIST_LIMIT = 100;
16
+ // Rejection marker used by `_getNextVod()` when, in `event` mode, the asset
17
+ // manager signals that there are no more VODs to play (see issue #362).
18
+ const END_OF_SCHEDULE = "END_OF_SCHEDULE";
14
19
  const DEFAULT_PLAYHEAD_DIFF_THRESHOLD = 1000;
15
20
  const DEFAULT_MAX_TICK_INTERVAL = 10000;
16
21
  const DEFAULT_DIFF_COMPENSATION_RATE = 0.5;
22
+ // Max time (ms) to wait for the configured ad-serving endpoint (#370) before
23
+ // giving up and falling back to the slate-only break. Kept short so a slow or
24
+ // unreachable endpoint never stalls a session tick.
25
+ const AD_SERVER_TIMEOUT_MS = 2000;
17
26
  const timer = ms => new Promise(res => setTimeout(res, ms));
18
27
  class Session {
19
28
  /**
@@ -75,6 +84,22 @@ class Session {
75
84
  this.currentPlayheadRef = null;
76
85
  this.disableLegacyMasterManifestFormat = null;
77
86
  this.rollingPDT = null;
87
+ this.event = false;
88
+ // End-of-schedule detection (issue #362). Only meaningful when `event` mode is
89
+ // enabled. Set to true once the asset manager has signalled that there are no
90
+ // more VODs to play (see `_isEndOfScheduleSignal`). ENDLIST emission based on
91
+ // this state is out of scope here and handled by #363.
92
+ this.isEndOfSchedule = false;
93
+ // Ad-break / interstitial config surface (issue #367). Disabled by default;
94
+ // no interstitial tags are emitted from this yet (follow-up #368).
95
+ this.adBreak = { enabled: false };
96
+ // Custom pass-through parameters forwarded to the asset manager's
97
+ // getNextVod() (issue #378). Populated per-session by the master-manifest
98
+ // request handler (wired up in #379) after filtering against the
99
+ // customVodRequestParams allowlist (#377). Defaults to an empty object so
100
+ // that, until a setter populates it, existing IAssetManager implementations
101
+ // are unaffected.
102
+ this.customParams = {};
78
103
  if (config) {
79
104
  if (config.alwaysNewSegments) {
80
105
  this.alwaysNewSegments = config.alwaysNewSegments;
@@ -88,6 +113,9 @@ class Session {
88
113
  if (config.rollingPDT) {
89
114
  this.rollingPDT = config.rollingPDT;
90
115
  }
116
+ if (config.event) {
117
+ this.event = config.event;
118
+ }
91
119
  if (config.alwaysMapBandwidthByNearest) {
92
120
  this.alwaysMapBandwidthByNearest = config.alwaysMapBandwidthByNearest;
93
121
  }
@@ -122,7 +150,23 @@ class Session {
122
150
  this._audioTracks = config.audioTracks;
123
151
  }
124
152
  if (config.subtitleTracks) {
125
- this._subtitleTracks = config.subtitleTracks;
153
+ // Normalize expected subtitle tracks before forwarding them to
154
+ // @eyevinn/hls-vodtolive as expectedSubtitleTracks. The dependency's
155
+ // matcher (see node_modules/@eyevinn/hls-vodtolive/index.js) compares a
156
+ // source SUBTITLES rendition against BOTH element.language and
157
+ // element.name and unconditionally calls element.name.toLowerCase().
158
+ // When a source manifest omits LANGUAGE it falls back to matching on the
159
+ // manifest NAME. If an operator configures a track with only a
160
+ // `language` (no `name`), the matcher throws on the missing name and no
161
+ // track is ever matched (issue #385, broken out from #325). Defaulting
162
+ // `name` to `language` keeps the contract well-formed so a NAME-only
163
+ // source whose NAME equals the configured language/name still resolves
164
+ // to real cues instead of dummy vtt. This is not a fuzzy match: it only
165
+ // fills in an omitted field with the operator's own value.
166
+ this._subtitleTracks = config.subtitleTracks.map((track) => ({
167
+ ...track,
168
+ name: track.name != null ? track.name : track.language,
169
+ }));
126
170
  }
127
171
  if (config.closedCaptions) {
128
172
  this._closedCaptions = config.closedCaptions;
@@ -139,6 +183,10 @@ class Session {
139
183
  this.slateDuration = config.slateDuration || 4000;
140
184
  debug(`Will use slate URI ${this.slateUri} (${this.slateRepetitions} ${this.slateDuration}ms)`);
141
185
  }
186
+ if (config.adBreak && config.adBreak.enabled) {
187
+ this.adBreak = config.adBreak;
188
+ debug(`Ad breaks enabled, ad server URI ${this.adBreak.adServerUri}`);
189
+ }
142
190
  if (config.cloudWatchMetrics) {
143
191
  this.cloudWatchLogging = true;
144
192
  }
@@ -174,6 +222,13 @@ class Session {
174
222
  get sessionId() {
175
223
  return this._sessionId;
176
224
  }
225
+ // Store the custom pass-through parameters for this session (issue #378).
226
+ // Intended to be called by the master-manifest request handler (#379) with the
227
+ // already-allowlist-filtered params. Passing a falsy value clears them back to
228
+ // an empty object so nothing extra is forwarded to the asset manager.
229
+ setCustomParams(customParams) {
230
+ this.customParams = customParams || {};
231
+ }
177
232
  async startPlayheadAsync() {
178
233
  debug(`[${this._sessionId}]: Playhead consumer started:`);
179
234
  debug(`[${this._sessionId}]: diffThreshold=${this.playheadDiffThreshold}`);
@@ -292,7 +347,7 @@ class Session {
292
347
  }
293
348
  catch (err) {
294
349
  debug(`[${this._sessionId}]: Playhead consumer crashed (1)`);
295
- console.error(`[${this._sessionId}]: ${err.message}`);
350
+ console.error(`[${this._sessionId}]: ${err instanceof Error ? err.message : err}`);
296
351
  cloudWatchLog(!this.cloudWatchLogging, 'engine-session', { event: 'error', on: 'playhead', channel: this._sessionId, err: err });
297
352
  debug(err);
298
353
  state = await this._playheadState.setState(PlayheadState.CRASHED);
@@ -332,6 +387,10 @@ class Session {
332
387
  },
333
388
  slateInserted: sessionState.slateCount,
334
389
  };
390
+ if (this.event) {
391
+ // Surface the end-of-schedule state only in event mode (issue #362).
392
+ status.endOfSchedule = this.isEndOfSchedule;
393
+ }
335
394
  return status;
336
395
  }
337
396
  }
@@ -386,7 +445,7 @@ class Session {
386
445
  let waitTimeMs = 2000;
387
446
  for (let i = segments[Object.keys(segments)[0]].length - 1; 0 < i; i--) {
388
447
  if (segments[Object.keys(segments)[0]][i].duration) {
389
- waitTimeMs = parseInt(1000 * (segments[Object.keys(segments)[0]][i].duration / 3), 10);
448
+ waitTimeMs = parseInt((1000 * (segments[Object.keys(segments)[0]][i].duration / 3)), 10);
390
449
  break;
391
450
  }
392
451
  }
@@ -397,7 +456,7 @@ class Session {
397
456
  for (let i = audioSegments[groupId][lang].length - 1; 0 < i; i--) {
398
457
  const segment = audioSegments[groupId][lang][i];
399
458
  if (segment.duration) {
400
- waitTimeMsAudio = parseInt(1000 * (segment.duration / 3), 10);
459
+ waitTimeMsAudio = parseInt((1000 * (segment.duration / 3)), 10);
401
460
  break;
402
461
  }
403
462
  }
@@ -714,7 +773,11 @@ class Session {
714
773
  let playheadPosVideoMs;
715
774
  let playheadAudio;
716
775
  let playheadSubtitle;
717
- playheadPosVideoMs = (await this._getCurrentPlayheadPosition()) * 1000;
776
+ // Pass the locally-updated vodMediaSeqVideo (leader incremented above) so
777
+ // playhead position reflects this tick's video segment, not the stale
778
+ // value still in Redis. Without this, audio/subtitle stay 1 segment
779
+ // behind video for the entire VOD lifetime.
780
+ playheadPosVideoMs = (await this._getCurrentPlayheadPosition(sessionState.vodMediaSeqVideo)) * 1000;
718
781
  if (this.use_demuxed_audio) {
719
782
  const audioSeqLastIdx = currentVod.getLiveMediaSequencesCount("audio") - 1;
720
783
  playheadAudio = await this._determineExtraMediaIncrement("audio", playheadPosVideoMs, audioSeqLastIdx, sessionState.vodMediaSeqAudio, this._getAudioPlayheadPosition.bind(this));
@@ -1208,6 +1271,8 @@ class Session {
1208
1271
  newVod.addMetadata(k, vodResponse.timedMetadata[k]);
1209
1272
  });
1210
1273
  }
1274
+ const firstVodAdAsset = await this._resolveAdBreakAsset();
1275
+ this._addInterstitialMetadata(newVod, vodResponse.unixTs, firstVodAdAsset);
1211
1276
  currentVod = newVod;
1212
1277
  if (vodResponse.desiredDuration) {
1213
1278
  try {
@@ -1400,6 +1465,8 @@ class Session {
1400
1465
  newVod.addMetadata(k, vodResponse.timedMetadata[k]);
1401
1466
  });
1402
1467
  }
1468
+ const nextVodAdAsset = await this._resolveAdBreakAsset();
1469
+ this._addInterstitialMetadata(newVod, vodResponse.unixTs, nextVodAdAsset);
1403
1470
  this.produceEvent({
1404
1471
  type: 'NEXT_VOD_SELECTED',
1405
1472
  data: {
@@ -1506,6 +1573,14 @@ class Session {
1506
1573
  }
1507
1574
  cloudWatchLog(!this.cloudWatchLogging, 'engine-session', { event: 'error', on: 'nextvod', channel: this._sessionId, err: err, vod: vodResponse });
1508
1575
  await this._sessionState.remove("nextVod");
1576
+ if (this.event && err === END_OF_SCHEDULE) {
1577
+ // Event mode + end of schedule (issue #362): the session has already
1578
+ // recorded its `ended` state in `_getNextVod()`. Do NOT fall through to
1579
+ // slate insertion (that is the non-event retry behaviour). ENDLIST
1580
+ // manifest handling is out of scope here and belongs to #363/#364.
1581
+ debug(`[${this._sessionId}]: end of schedule reached, keeping current VOD (no slate in event mode)`);
1582
+ break;
1583
+ }
1509
1584
  currentVod = await this._insertSlate(currentVod);
1510
1585
  if (!currentVod) {
1511
1586
  debug("No slate to load");
@@ -1675,14 +1750,295 @@ class Session {
1675
1750
  this._lastTickState = { sessionState, isLeader, currentVod };
1676
1751
  }
1677
1752
  }
1753
+ /**
1754
+ * The "no more VODs" contract (issue #362).
1755
+ *
1756
+ * `AssetManager.getNextVod()` is normally expected to resolve with a `vodResponse`
1757
+ * that either carries a playable `uri`, or is a `{ type: 'gap' }` marker. The
1758
+ * end-of-schedule signal is therefore the natural absence of both: the asset
1759
+ * manager resolving with a falsy value (`null`/`undefined`) or with an object
1760
+ * that has neither a `uri` nor `type === 'gap'` (e.g. an empty `{}`).
1761
+ *
1762
+ * This is detection only — deciding what the session does with the signal is
1763
+ * handled by the caller (and, later, ENDLIST emission by #363).
1764
+ */
1765
+ /**
1766
+ * Append #EXT-X-ENDLIST to a served media playlist when, in event mode, the
1767
+ * schedule has ended (issue #363). Returns the manifest unchanged otherwise, so
1768
+ * the tag never appears while content is still playing or when event mode is off.
1769
+ * Idempotent: never adds a second ENDLIST if one is already present.
1770
+ */
1771
+ _appendEndlistIfEnded(m3u8) {
1772
+ if (!m3u8 || !this.event || !this.isEndOfSchedule) {
1773
+ return m3u8;
1774
+ }
1775
+ if (/#EXT-X-ENDLIST/.test(m3u8)) {
1776
+ return m3u8;
1777
+ }
1778
+ const separator = m3u8.endsWith("\n") ? "" : "\n";
1779
+ return m3u8 + separator + "#EXT-X-ENDLIST\n";
1780
+ }
1781
+ // Attach the HLS-interstitial EXT-X-DATERANGE metadata for a configured ad
1782
+ // break to a freshly-created content VOD (issue #368). Reuses the existing
1783
+ // per-VOD `addMetadata`/`rangeMetadata` daterange pathway (the same one the
1784
+ // slate VODs use for id/start-date/planned-duration), so the tag is rendered
1785
+ // by @eyevinn/hls-vodtolive at the VOD's start segment. The `class` and
1786
+ // `x-asset-uri`/`x-asset-list` keys are uppercased and rendered as standard
1787
+ // DATERANGE attributes by the vod lib's daterange formatter.
1788
+ //
1789
+ // Strictly gated on `this.adBreak.enabled`: when ad breaks are disabled (the
1790
+ // default) this is a no-op and the served manifest is byte-identical to the
1791
+ // pre-#368 output.
1792
+ //
1793
+ // Asset-source precedence (#370): when the caller has resolved a real ad
1794
+ // asset from the configured ad-serving endpoint (`_resolveAdBreakAsset()`),
1795
+ // it is passed in as `resolvedAsset` and its `assetUri`/`assetList` becomes
1796
+ // the interstitial `X-ASSET-URI`/`X-ASSET-LIST`. When no endpoint asset was
1797
+ // resolved (endpoint unconfigured, or a timeout/HTTP-error/malformed response
1798
+ // that made the resolver fall back), we degrade to the #367 config reference
1799
+ // (slate.uri preferred, then adServerUri) so the break still renders.
1800
+ _addInterstitialMetadata(vod, timestamp, resolvedAsset) {
1801
+ if (!vod || !this.adBreak || !this.adBreak.enabled) {
1802
+ return;
1803
+ }
1804
+ const fallbackUri = (this.adBreak.slate && this.adBreak.slate.uri) || this.adBreak.adServerUri;
1805
+ const assetList = resolvedAsset && resolvedAsset.assetList;
1806
+ const assetUri = (resolvedAsset && resolvedAsset.assetUri) || fallbackUri;
1807
+ if (!assetList && !assetUri) {
1808
+ return;
1809
+ }
1810
+ const startDate = new Date(typeof timestamp === "number" ? timestamp : Date.now()).toISOString();
1811
+ let plannedDuration = 0;
1812
+ if (this.adBreak.slate && this.adBreak.slate.duration) {
1813
+ const reps = this.adBreak.slate.repetitions || 1;
1814
+ plannedDuration = (reps * this.adBreak.slate.duration) / 1000;
1815
+ }
1816
+ vod.addMetadata("class", "com.apple.hls.interstitial");
1817
+ vod.addMetadata("id", `adbreak-${this._sessionId}-${startDate}`);
1818
+ vod.addMetadata("start-date", startDate);
1819
+ if (plannedDuration > 0) {
1820
+ vod.addMetadata("planned-duration", plannedDuration);
1821
+ }
1822
+ // A resolved asset-list (multiple ads) takes precedence over a single URI
1823
+ // and maps to X-ASSET-LIST; otherwise a single X-ASSET-URI is emitted.
1824
+ if (assetList) {
1825
+ vod.addMetadata("x-asset-list", assetList);
1826
+ }
1827
+ else {
1828
+ vod.addMetadata("x-asset-uri", assetUri);
1829
+ }
1830
+ }
1831
+ // Ad-serving endpoint client (issue #370).
1832
+ //
1833
+ // When an ad break is enabled and an `adBreak.adServerUri` is configured,
1834
+ // query that endpoint and map its JSON response to the interstitial asset
1835
+ // reference consumed by `_addInterstitialMetadata`.
1836
+ //
1837
+ // Response contract (kept deliberately generic — no ad-product specifics):
1838
+ // { "assetUri": "<uri>" } -> single X-ASSET-URI
1839
+ // { "assetList": "<uri>" } -> an X-ASSET-LIST endpoint
1840
+ // { "assets": ["<uri>", ...] } -> first entry -> X-ASSET-URI
1841
+ // Returns `{ assetUri }` or `{ assetList }` on success, or `null` when there
1842
+ // is nothing to resolve or on ANY failure.
1843
+ //
1844
+ // Log-and-continue (per CLAUDE.md): a timeout, non-2xx HTTP status, network
1845
+ // error, or malformed/empty JSON body all resolve to `null` (never throw), so
1846
+ // the caller falls back to the slate-only break and the session tick is never
1847
+ // interrupted. Returns `null` immediately (no fetch) when the feature is
1848
+ // disabled or no `adServerUri` is configured.
1849
+ async _resolveAdBreakAsset(fetchImpl) {
1850
+ if (!this.adBreak || !this.adBreak.enabled || !this.adBreak.adServerUri) {
1851
+ return null;
1852
+ }
1853
+ const doFetch = fetchImpl || fetch;
1854
+ const uri = this.adBreak.adServerUri;
1855
+ const controller = new AbortController();
1856
+ const timeout = setTimeout(() => controller.abort(), AD_SERVER_TIMEOUT_MS);
1857
+ try {
1858
+ const response = await doFetch(uri, {
1859
+ signal: controller.signal,
1860
+ headers: { accept: "application/json" }
1861
+ });
1862
+ if (!response || !response.ok) {
1863
+ debug(`[${this._sessionId}]: ad-server endpoint ${uri} returned ` +
1864
+ `${response ? response.status : "no response"}, falling back to slate`);
1865
+ return null;
1866
+ }
1867
+ const body = await response.json();
1868
+ if (!body || typeof body !== "object") {
1869
+ debug(`[${this._sessionId}]: ad-server endpoint ${uri} returned a non-object body, falling back to slate`);
1870
+ return null;
1871
+ }
1872
+ if (typeof body.assetList === "string" && body.assetList) {
1873
+ return { assetList: body.assetList };
1874
+ }
1875
+ if (typeof body.assetUri === "string" && body.assetUri) {
1876
+ return { assetUri: body.assetUri };
1877
+ }
1878
+ if (Array.isArray(body.assets) && typeof body.assets[0] === "string" && body.assets[0]) {
1879
+ return { assetUri: body.assets[0] };
1880
+ }
1881
+ debug(`[${this._sessionId}]: ad-server endpoint ${uri} response had no usable asset, falling back to slate`);
1882
+ return null;
1883
+ }
1884
+ catch (err) {
1885
+ // Covers AbortError (timeout), network errors, and JSON parse errors.
1886
+ debug(`[${this._sessionId}]: ad-server endpoint ${uri} request failed (${err instanceof Error ? err.message : err}), falling back to slate`);
1887
+ return null;
1888
+ }
1889
+ finally {
1890
+ clearTimeout(timeout);
1891
+ }
1892
+ }
1893
+ // Ad-break slate coordination (issue #369).
1894
+ //
1895
+ // Builds the slate VOD that plays as the ad-break filler and brackets it with
1896
+ // the #368 interstitial EXT-X-DATERANGE. The slate is driven by the #367
1897
+ // `adBreak.slate` config (uri/repetitions/duration): a `HLSRepeatVod` repeats
1898
+ // the slate asset `repetitions` times, so the slate segment run spans exactly
1899
+ // `repetitions * duration / 1000` seconds — the very same value
1900
+ // `_addInterstitialMetadata` computes for PLANNED-DURATION. Attaching that
1901
+ // interstitial metadata to this slate VOD (at `unixTs`, its first segment)
1902
+ // makes the DATERANGE START-DATE fall on the break boundary and its
1903
+ // PLANNED-DURATION span exactly the slate run. Downstream (SSAI or the
1904
+ // client-side interstitial) the slate is what gets replaced with real ads.
1905
+ //
1906
+ // Strictly gated on `this.adBreak.enabled` AND a configured `adBreak.slate`:
1907
+ // when either is absent this returns null and every existing (non-ad-break)
1908
+ // slate path — the error/gap filler slate via `_loadSlate`/`_truncateSlate` —
1909
+ // is left completely untouched.
1910
+ _buildAdBreakSlate(afterVod, unixTs, injectSlateLoaders) {
1911
+ if (!this.adBreak ||
1912
+ !this.adBreak.enabled ||
1913
+ !this.adBreak.slate ||
1914
+ !this.adBreak.slate.uri) {
1915
+ return Promise.resolve(null);
1916
+ }
1917
+ const slate = this.adBreak.slate;
1918
+ const reps = slate.repetitions || 1;
1919
+ const timestamp = typeof unixTs === "number" ? unixTs : Date.now();
1920
+ // `injectSlateLoaders` lets callers (tests) feed the repeated slate asset
1921
+ // from fixtures instead of the network; production passes nothing so the
1922
+ // slate is fetched from `slate.uri` as usual. It may also carry a `fetch`
1923
+ // stub used to resolve the ad-break asset (#370) without real network.
1924
+ const { master, media, fetch: fetchImpl } = injectSlateLoaders || {};
1925
+ // Resolve the real ad asset from the configured endpoint before rendering
1926
+ // the slate so the interstitial DATERANGE can reference it; on any failure
1927
+ // this yields null and the tag degrades to the slate reference.
1928
+ return this._resolveAdBreakAsset(fetchImpl).then((resolvedAsset) => new Promise((resolve, reject) => {
1929
+ try {
1930
+ const slateVod = new HLSRepeatVod(slate.uri, reps);
1931
+ let hlsVod;
1932
+ slateVod
1933
+ .load(master, media)
1934
+ .then(() => {
1935
+ const hlsOpts = {
1936
+ sequenceAlwaysContainNewSegments: this.alwaysNewSegments,
1937
+ forcedDemuxMode: this.use_demuxed_audio,
1938
+ dummySubtitleEndpoint: this.dummySubtitleEndpoint,
1939
+ subtitleSliceEndpoint: this.subtitleSliceEndpoint,
1940
+ shouldContainSubtitles: this.use_vtt_subtitles,
1941
+ expectedSubtitleTracks: this._subtitleTracks,
1942
+ alwaysMapBandwidthByNearest: this.alwaysMapBandwidthByNearest,
1943
+ skipSerializeMediaSequences: this.partialStoreHLSVod,
1944
+ calculatePDT: this.rollingPDT
1945
+ };
1946
+ hlsVod = new HLSVod(slate.uri, null, timestamp, null, m3u8Header(this._instanceId), hlsOpts);
1947
+ // Bracket exactly the slate: the interstitial DATERANGE lands on the
1948
+ // slate's first segment (START-DATE = break boundary) and its
1949
+ // PLANNED-DURATION equals the slate run length. The asset reference
1950
+ // is the endpoint-resolved ad (or the slate fallback) from #370.
1951
+ this._addInterstitialMetadata(hlsVod, timestamp, resolvedAsset);
1952
+ const slateMediaManifestLoader = (bw) => {
1953
+ let mediaManifestStream = new Readable();
1954
+ // `HLSRepeatVod` prepends an EXT-X-DISCONTINUITY before the first
1955
+ // repeated segment. That leading marker would occupy the VOD's
1956
+ // first-segment slot and displace the interstitial DATERANGE
1957
+ // (which the vod lib attaches only to the true first segment),
1958
+ // breaking the "tag brackets exactly the slate" alignment. Drop
1959
+ // that single leading discontinuity so the slate run begins
1960
+ // cleanly at the break boundary and the DATERANGE lands on it.
1961
+ const slateManifest = slateVod
1962
+ .getMediaManifest(bw)
1963
+ .replace(/(#EXT-X-MEDIA-SEQUENCE:\d+\r?\n)#EXT-X-DISCONTINUITY\r?\n/, "$1");
1964
+ mediaManifestStream.push(slateManifest);
1965
+ mediaManifestStream.push(null);
1966
+ return mediaManifestStream;
1967
+ };
1968
+ if (this.use_demuxed_audio) {
1969
+ const slateAudioManifestLoader = (audioGroupId, audioLanguage) => {
1970
+ let mediaManifestStream = new Readable();
1971
+ mediaManifestStream.push(slateVod.getAudioManifest(audioGroupId, audioLanguage));
1972
+ mediaManifestStream.push(null);
1973
+ return mediaManifestStream;
1974
+ };
1975
+ let subtitleManifestLoader;
1976
+ if (this.use_vtt_subtitles && this._subtitleTracks) {
1977
+ subtitleManifestLoader = (groupId, language) => {
1978
+ let mediaManifestStream = new Readable();
1979
+ mediaManifestStream.push(slateVod.getSubtitleManifest(groupId, language));
1980
+ mediaManifestStream.push(null);
1981
+ return mediaManifestStream;
1982
+ };
1983
+ }
1984
+ if (afterVod) {
1985
+ return hlsVod.loadAfter(afterVod, master || null, slateMediaManifestLoader, slateAudioManifestLoader, subtitleManifestLoader);
1986
+ }
1987
+ return hlsVod.load(master || null, slateMediaManifestLoader, slateAudioManifestLoader, subtitleManifestLoader);
1988
+ }
1989
+ if (afterVod) {
1990
+ return hlsVod.loadAfter(afterVod, master || null, slateMediaManifestLoader);
1991
+ }
1992
+ return hlsVod.load(master || null, slateMediaManifestLoader);
1993
+ })
1994
+ .then(() => {
1995
+ resolve(hlsVod);
1996
+ })
1997
+ .catch((err) => {
1998
+ debug(err);
1999
+ reject(err);
2000
+ });
2001
+ }
2002
+ catch (err) {
2003
+ reject(err);
2004
+ }
2005
+ }));
2006
+ }
2007
+ _isEndOfScheduleSignal(nextVod) {
2008
+ if (!nextVod) {
2009
+ return true;
2010
+ }
2011
+ if (nextVod.uri) {
2012
+ return false;
2013
+ }
2014
+ if (nextVod.type === 'gap') {
2015
+ return false;
2016
+ }
2017
+ return true;
2018
+ }
1678
2019
  _getNextVod() {
1679
2020
  return new Promise((resolve, reject) => {
2021
+ // Post-end lock-down (issue #364): once the schedule has ended in event
2022
+ // mode, never ask the asset manager for another VOD. The session keeps
2023
+ // serving its final, ENDLIST-terminated playlist, so re-querying would be
2024
+ // wasteful and could reset the ended state on a late/looping asset manager.
2025
+ if (this.event && this.isEndOfSchedule) {
2026
+ debug(`[${this._sessionId}]: schedule already ended (event mode), not requesting a new VOD`);
2027
+ return reject(END_OF_SCHEDULE);
2028
+ }
1680
2029
  let nextVodPromise;
1681
- nextVodPromise = this._assetManager.getNextVod({
2030
+ // Forward per-session custom pass-through params (issue #378) when present.
2031
+ // Only include the field if at least one param has been set, so existing
2032
+ // IAssetManager implementations that don't expect it are unaffected.
2033
+ const vodRequest = {
1682
2034
  sessionId: this._sessionId,
1683
2035
  category: this._category,
1684
2036
  playlistId: this._sessionId
1685
- });
2037
+ };
2038
+ if (this.customParams && Object.keys(this.customParams).length > 0) {
2039
+ vodRequest.customParams = this.customParams;
2040
+ }
2041
+ nextVodPromise = this._assetManager.getNextVod(vodRequest);
1686
2042
  nextVodPromise.then(nextVod => {
1687
2043
  if (nextVod && nextVod.uri) {
1688
2044
  this.currentMetadata = {
@@ -1698,6 +2054,23 @@ class Session {
1698
2054
  };
1699
2055
  resolve(nextVod);
1700
2056
  }
2057
+ else if (this.event && this._isEndOfScheduleSignal(nextVod)) {
2058
+ // Event mode: no new VOD was received from the asset manager, so the
2059
+ // schedule has ended. Record an internal end-of-schedule state and log
2060
+ // it, then reject with a distinguishable marker. No slate/retry here —
2061
+ // that behaviour is deliberately reserved for non-event mode. ENDLIST
2062
+ // manifest emission is out of scope for this issue (#363).
2063
+ this.isEndOfSchedule = true;
2064
+ debug(`[${this._sessionId}]: end of schedule detected (event mode), no new VOD received from asset manager`);
2065
+ cloudWatchLog(!this.cloudWatchLogging, 'engine-session', { event: 'endOfSchedule', channel: this._sessionId });
2066
+ this.produceEvent({
2067
+ type: 'END_OF_SCHEDULE',
2068
+ data: {
2069
+ sessionId: this._sessionId,
2070
+ }
2071
+ });
2072
+ reject(END_OF_SCHEDULE);
2073
+ }
1701
2074
  else {
1702
2075
  console.error("Invalid VOD:", nextVod);
1703
2076
  reject("Invalid VOD from asset manager");
@@ -1977,12 +2350,21 @@ class Session {
1977
2350
  }
1978
2351
  return 0;
1979
2352
  }
1980
- async _getCurrentPlayheadPosition() {
1981
- const sessionState = await this._sessionState.getValues(["vodMediaSeqVideo"]);
2353
+ async _getCurrentPlayheadPosition(overrideVodMediaSeqVideo) {
2354
+ // Caller can pass the in-progress local vodMediaSeqVideo to avoid the Redis
2355
+ // round-trip and the race where the leader has incremented its local value
2356
+ // but hasn't written it to Redis yet (incrementAsync writes happen later via
2357
+ // a parallel Promise.all). Falling back to Redis preserves the old behavior
2358
+ // for callers like startPlayheadAsync that run after incrementAsync.
2359
+ let vodMediaSeqVideo = overrideVodMediaSeqVideo;
2360
+ if (vodMediaSeqVideo === undefined) {
2361
+ const sessionState = await this._sessionState.getValues(["vodMediaSeqVideo"]);
2362
+ vodMediaSeqVideo = sessionState.vodMediaSeqVideo;
2363
+ }
1982
2364
  const currentVod = await this._sessionState.getCurrentVod();
1983
2365
  const playheadPositions = currentVod.getPlayheadPositions();
1984
- debug(`[${this._sessionId}]: Current playhead position (${sessionState.vodMediaSeqVideo}): ${roundToThreeDecimals(playheadPositions[sessionState.vodMediaSeqVideo])}`);
1985
- return playheadPositions[sessionState.vodMediaSeqVideo];
2366
+ debug(`[${this._sessionId}]: Current playhead position (${vodMediaSeqVideo}): ${roundToThreeDecimals(playheadPositions[vodMediaSeqVideo])}`);
2367
+ return playheadPositions[vodMediaSeqVideo];
1986
2368
  }
1987
2369
  async _getAudioPlayheadPosition(seqIdx) {
1988
2370
  const currentVod = await this._sessionState.getCurrentVod();
@@ -2215,6 +2597,11 @@ class Session {
2215
2597
  debug(`[${this._sessionId}]:${playbackSessionId ? `[${playbackSessionId}]:` : ""} [${manifestMseq}][${manifestDseq}][+${this.targetDurationPadding || 0}] Current ${variantType} manifest for ${variantType === "video" ? variantKey : `${parsedGroupId}_${parsedLang}`} requested`);
2216
2598
  this.prevVodMediaSeq[variantType] = playheadState[MSDKeys.vodMediaSeq];
2217
2599
  this.prevMediaSeqOffset[variantType] = playheadState[MSDKeys.mediaSeq];
2600
+ // In event mode, once the schedule has ended (issue #363) terminate the
2601
+ // served media playlist with #EXT-X-ENDLIST so players stop reloading.
2602
+ // Applied uniformly to video, demuxed-audio and subtitle variants. The tag
2603
+ // is never appended while content is still playing (isEndOfSchedule=false).
2604
+ m3u8 = this._appendEndlistIfEnded(m3u8);
2218
2605
  return m3u8;
2219
2606
  }
2220
2607
  catch (err) {