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