eyevinn-channel-engine 5.1.2 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/nodejs.yml +1 -1
- package/.github/workflows/publish.yml +1 -1
- package/.github/workflows/rc.yml +1 -1
- package/CONTRIBUTING.md +40 -0
- package/Dockerfile +1 -1
- package/README.md +70 -8
- package/dist/engine/chaos_monkey.d.ts +1 -1
- package/dist/engine/chaos_monkey.js +1 -0
- package/dist/engine/event_stream.d.ts +1 -6
- package/dist/engine/event_stream.js +2 -0
- package/dist/engine/memcached_state_store.d.ts +1 -13
- package/dist/engine/memcached_state_store.js +4 -0
- package/dist/engine/memory_state_store.d.ts +1 -13
- package/dist/engine/memory_state_store.js +5 -0
- package/dist/engine/playhead_state.d.ts +0 -26
- package/dist/engine/playhead_state.js +9 -0
- package/dist/engine/preflight.d.ts +1 -1
- package/dist/engine/preflight.js +1 -0
- package/dist/engine/redis_state_store.d.ts +1 -19
- package/dist/engine/redis_state_store.js +5 -0
- package/dist/engine/server.d.ts +27 -0
- package/dist/engine/server.js +140 -6
- package/dist/engine/session.d.ts +1 -148
- package/dist/engine/session.js +398 -11
- package/dist/engine/session_live.d.ts +1 -181
- package/dist/engine/session_live.js +19 -4
- package/dist/engine/session_live_state.d.ts +0 -23
- package/dist/engine/session_live_state.js +5 -0
- package/dist/engine/session_state.d.ts +0 -45
- package/dist/engine/session_state.js +10 -0
- package/dist/engine/shared_state_store.d.ts +1 -25
- package/dist/engine/shared_state_store.js +10 -0
- package/dist/engine/state_store_types.d.ts +32 -0
- package/dist/engine/state_store_types.js +5 -0
- package/dist/engine/stream_switcher.d.ts +1 -40
- package/dist/engine/stream_switcher.js +108 -20
- package/dist/engine/subtitle_slicer.d.ts +1 -8
- package/dist/engine/subtitle_slicer.js +1 -0
- package/dist/engine/util.d.ts +1 -29
- package/dist/engine/util.js +2 -1
- package/dist/examples/adbreak.d.ts +1 -0
- package/dist/examples/adbreak.js +104 -0
- package/dist/index.d.ts +1 -1
- package/dist/package.json +20 -4
- package/docs/design/mixing-muxed-demuxed-audio.md +153 -0
- package/docs/design/subtitles-live-mix-demux.md +135 -0
- package/docs/reference.md +67 -0
- package/engine/chaos_monkey.ts +17 -0
- package/engine/{event_stream.js → event_stream.ts} +5 -3
- package/engine/{memcached_state_store.js → memcached_state_store.ts} +19 -12
- package/engine/memory_state_store.ts +71 -0
- package/engine/{playhead_state.js → playhead_state.ts} +92 -82
- package/engine/{preflight.js → preflight.ts} +1 -1
- package/engine/{redis_state_store.js → redis_state_store.ts} +216 -208
- package/engine/server.ts +210 -8
- package/engine/{session.js → session.ts} +421 -22
- package/engine/{session_live.js → session_live.ts} +54 -31
- package/engine/{session_live_state.js → session_live_state.ts} +129 -120
- package/engine/{session_state.js → session_state.ts} +269 -248
- package/engine/{shared_state_store.js → shared_state_store.ts} +31 -19
- package/engine/state_store_types.ts +35 -0
- package/engine/{stream_switcher.js → stream_switcher.ts} +133 -34
- package/engine/{subtitle_slicer.js → subtitle_slicer.ts} +10 -8
- package/engine/{util.js → util.ts} +28 -24
- package/examples/adbreak.ts +115 -0
- package/index.ts +2 -0
- package/package.json +20 -4
- package/spec/engine/custom_vod_request_params_spec.ts +99 -0
- package/spec/engine/drm_spec.ts +128 -0
- package/spec/engine/{engine_spec.js → engine_spec.ts} +44 -1
- package/spec/engine/{ha_spec.js → ha_spec.ts} +7 -4
- package/spec/engine/{init_switching_spec.js → init_switching_spec.ts} +10 -6
- package/spec/engine/{interface_spec.js → interface_spec.ts} +16 -4
- package/spec/engine/{playhead_live_spec.js → playhead_live_spec.ts} +2 -1
- package/spec/engine/{playhead_spec.js → playhead_spec.ts} +15 -8
- package/spec/engine/session_spec.ts +800 -0
- package/spec/engine/stream_switcher_audio_reconcile_spec.ts +158 -0
- package/spec/engine/{stream_switcher_spec.js → stream_switcher_spec.ts} +111 -13
- package/spec/engine/subtitle_missing_language_spec.ts +196 -0
- package/spec/engine/{subtitle_spec.js → subtitle_spec.ts} +28 -27
- package/spec/helpers/ts-resolve.js +65 -0
- package/spec/support/jasmine.json +1 -1
- package/spec/testvectors/hls_subs_no_language/config.subtitleTracks.json +12 -0
- package/spec/testvectors/hls_subs_no_language/evidence_dummy_segments_name_mismatch.m3u8 +24 -0
- package/spec/testvectors/hls_subs_no_language/evidence_real_cues_name_match.m3u8 +24 -0
- package/spec/testvectors/hls_subs_no_language/french.m3u8 +9 -0
- package/spec/testvectors/hls_subs_no_language/master.m3u8 +11 -0
- package/spec/testvectors/hls_subs_no_language/video.m3u8 +25 -0
- package/spec/testvectors/slate/master.m3u8 +3 -0
- package/spec/testvectors/slate/media.m3u8 +9 -0
- package/tsconfig.json +21 -4
- package/tsconfig.spec.json +16 -0
- package/engine/chaos_monkey.js +0 -17
- package/engine/memory_state_store.js +0 -63
- package/spec/engine/session_spec.js +0 -116
- /package/spec/engine/{session_live_spec.js → session_live_spec.ts} +0 -0
- /package/spec/engine/{util_spec.js → util_spec.ts} +0 -0
package/dist/engine/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (${
|
|
1985
|
-
return playheadPositions[
|
|
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) {
|