diff --git a/engine/playhead_state.js b/engine/playhead_state.js index a69d40a..18f9f0a 100644 --- a/engine/playhead_state.js +++ b/engine/playhead_state.js @@ -62,7 +62,7 @@ class PlayheadStateStore extends SharedStateStore { constructor(opts) { super("playhead", opts, { state: PlayheadState.IDLE, - tickInterval: 3, + tickInterval: (opts.averageSegmentDuration/1000) || 3, mediaSeq: 0, vodMediaSeqVideo: 0, vodMediaSeqAudio: 0, diff --git a/engine/server.ts b/engine/server.ts index 2c1a786..bb88361 100644 --- a/engine/server.ts +++ b/engine/server.ts @@ -282,7 +282,8 @@ export class ChannelEngine { cacheTTL: options.sharedStoreCacheTTL, volatileKeyTTL: options.volatileKeyTTL, }), - playheadStateStore: new PlayheadStateStore({ + playheadStateStore: new PlayheadStateStore({ + averageSegmentDuration: options.averageSegmentDuration, redisUrl: options.redisUrl, memcachedUrl: options.memcachedUrl, cacheTTL: options.sharedStoreCacheTTL, @@ -452,7 +453,7 @@ export class ChannelEngine { try { await Promise.all(channels.map(channel => getSwitchStatusAndPerformSwitch(channel))); } catch (err) { - debug('Problem occured when updating streamSwitchers'); + debug('Problem occurred when updating streamSwitchers'); throw new Error (err); } @@ -497,13 +498,15 @@ export class ChannelEngine { useDemuxedAudio: options.useDemuxedAudio, dummySubtitleEndpoint: this.dummySubtitleEndpoint, subtitleSliceEndpoint: this.subtitleSliceEndpoint, - useVTTSubtitles: this.useVTTSubtitles, + useVTTSubtitles: false, cloudWatchMetrics: this.logCloudWatchMetrics, profile: channel.profile, + audioTracks: channel.audioTracks }, this.sessionLiveStore); sessionSwitchers[channel.id] = new StreamSwitcher({ sessionId: channel.id, + useDemuxedAudio: options.useDemuxedAudio, streamSwitchManager: this.streamSwitchManager ? this.streamSwitchManager : null }); @@ -772,17 +775,80 @@ export class ChannelEngine { next(this._gracefulErrorHandler("Could not find a valid session")); } } + + async _handleMediaManifest(req, res, next) { + debug(`x-playback-session-id=${req.headers["x-playback-session-id"]} req.url=${req.url}`); + debug(req.params); + const session = sessions[req.params[1]]; + const sessionLive = sessionsLive[req.params[1]]; + if (session && sessionLive) { + try { + let ts1 = Date.now(); + let body = null; + if (!this.streamSwitchManager) { + debug(`[${req.params[1]}]: Responding with VOD2Live manifest`); + body = await session.getCurrentMediaManifestAsync(req.params[0], req.headers["x-playback-session-id"]); + } else { + while (switcherStatus[req.params[1]] === null || switcherStatus[req.params[1]] === undefined) { + debug(`[${req.params[1]}]: (${switcherStatus[req.params[1]]}) Waiting for streamSwitcher to respond`); + await timer(500); + } + debug(`switcherStatus[${req.params[1]}]=[${switcherStatus[req.params[1]]}]`); + if (switcherStatus[req.params[1]]) { + debug(`[${req.params[1]}]: Responding with Live-stream manifest`); + body = await sessionLive.getCurrentMediaManifestAsync(req.params[0]); + } else { + debug(`[${req.params[1]}]: Responding with VOD2Live manifest`); + body = await session.getCurrentMediaManifestAsync(req.params[0], req.headers["x-playback-session-id"]); + } + debug(`[${req.params[1]}]: Media Manifest Request Took (${Date.now() - ts1})ms`); + } + //verbose(`[${session.sessionId}] body=`); + //verbose(body); + res.sendRaw(200, Buffer.from(body, 'utf8'), { + "Content-Type": "application/vnd.apple.mpegurl", + "Access-Control-Allow-Origin": "*", + "Cache-Control": `max-age=${this.streamerOpts.cacheTTL || '4'}`, + "X-Instance-Id": this.instanceId + `<${version}>`, + }); + next(); + } catch (err) { + next(this._gracefulErrorHandler(err)); + } + } else { + const err = new errs.NotFoundError('Invalid session(s)'); + next(err); + } + } async _handleAudioManifest(req, res, next) { - debug(`req.url=${req.url}`); + debug(`x-playback-session-id=${req.headers["x-playback-session-id"]} req.url=${req.url}`); + debug(req.params); const session = sessions[req.params[2]]; - if (session) { + const sessionLive = sessionsLive[req.params[2]]; + if (session && sessionLive) { try { - const body = await session.getCurrentAudioManifestAsync( - req.params[0], - req.params[1], - req.headers["x-playback-session-id"] - ); + let body = null; + let ts1: number = Date.now(); + if (!this.streamSwitchManager) { + debug(`[${req.params[2]}]: Responding with VOD2Live manifest`); + body = await session.getCurrentAudioManifestAsync(req.params[0], req.params[1], req.headers["x-playback-session-id"]); + } else { + while (switcherStatus[req.params[2]] === null || switcherStatus[req.params[2]] === undefined) { + debug(`[${req.params[2]}]: (${switcherStatus[req.params[1]]}) Waiting for streamSwitcher to respond`); + await timer(500); + } + debug(`switcherStatus[${req.params[1]}]=[${switcherStatus[req.params[2]]}]`); + if (switcherStatus[req.params[2]]) { + debug(`[${req.params[2]}]: Responding with Live-stream manifest`); + body = await sessionLive.getCurrentAudioManifestAsync(req.params[0], req.params[1]); + } else { + debug(`[${req.params[2]}]: Responding with VOD2Live manifest`); + body = await session.getCurrentAudioManifestAsync(req.params[0], req.params[1], req.headers["x-playback-session-id"]); + } + debug(`[${req.params[2]}]: Audio Manifest Request Took (${Date.now() - ts1})ms`); + } + res.sendRaw(200, Buffer.from(body, 'utf8'), { "Content-Type": "application/vnd.apple.mpegurl", "Access-Control-Allow-Origin": "*", @@ -794,7 +860,7 @@ export class ChannelEngine { next(this._gracefulErrorHandler(err)); } } else { - const err = new errs.NotFoundError('Invalid session'); + const err = new errs.NotFoundError('Invalid session(s)'); next(err); } } @@ -858,50 +924,6 @@ export class ChannelEngine { } } - async _handleMediaManifest(req, res, next) { - debug(`x-playback-session-id=${req.headers["x-playback-session-id"]} req.url=${req.url}`); - debug(req.params); - const session = sessions[req.params[1]]; - const sessionLive = sessionsLive[req.params[1]]; - if (session && sessionLive) { - try { - let body = null; - if (!this.streamSwitchManager) { - debug(`[${req.params[1]}]: Responding with VOD2Live manifest`); - body = await session.getCurrentMediaManifestAsync(req.params[0], req.headers["x-playback-session-id"]); - } else { - while (switcherStatus[req.params[1]] === null || switcherStatus[req.params[1]] === undefined) { - debug(`[${req.params[1]}]: (${switcherStatus[req.params[1]]}) Waiting for streamSwitcher to respond`); - await timer(500); - } - debug(`switcherStatus[${req.params[1]}]=[${switcherStatus[req.params[1]]}]`); - if (switcherStatus[req.params[1]]) { - debug(`[${req.params[1]}]: Responding with Live-stream manifest`); - body = await sessionLive.getCurrentMediaManifestAsync(req.params[0]); - } else { - debug(`[${req.params[1]}]: Responding with VOD2Live manifest`); - body = await session.getCurrentMediaManifestAsync(req.params[0], req.headers["x-playback-session-id"]); - } - } - - //verbose(`[${session.sessionId}] body=`); - //verbose(body); - res.sendRaw(200, Buffer.from(body, 'utf8'), { - "Content-Type": "application/vnd.apple.mpegurl", - "Access-Control-Allow-Origin": "*", - "Cache-Control": `max-age=${this.streamerOpts.cacheTTL || '4'}`, - "X-Instance-Id": this.instanceId + `<${version}>`, - }); - next(); - } catch (err) { - next(this._gracefulErrorHandler(err)); - } - } else { - const err = new errs.NotFoundError('Invalid session(s)'); - next(err); - } - } - _handleEventStream(req, res, next) { debug(`req.url=${req.url}`); const eventStream = eventStreams[req.params.sessionId]; diff --git a/engine/session.js b/engine/session.js index ecb4e20..febc780 100644 --- a/engine/session.js +++ b/engine/session.js @@ -13,7 +13,6 @@ const { applyFilter, cloudWatchLog, m3u8Header, logerror, codecsFromString } = r const ChaosMonkey = require('./chaos_monkey.js'); const EVENT_LIST_LIMIT = 100; -const AVERAGE_SEGMENT_DURATION = 3000; const DEFAULT_PLAYHEAD_DIFF_THRESHOLD = 1000; const DEFAULT_MAX_TICK_INTERVAL = 10000; const DEFAULT_DIFF_COMPENSATION_RATE = 0.5; @@ -39,7 +38,7 @@ class Session { //this.currentVod; this.currentMetadata = {}; this._events = []; - this.averageSegmentDuration = AVERAGE_SEGMENT_DURATION; + this.averageSegmentDuration = config.averageSegmentDuration; this.use_demuxed_audio = false; this.use_vtt_subtitles = false; this.dummySubtitleEndpoint = ""; @@ -69,6 +68,10 @@ class Session { discSeq: null, mediaSeqOffset: null, transitionSegments: null, + audioSeq: null, + discAudioSeq: null, + audioSeqOffset: null, + transitionAudioSegments: null, reloadBehind: null, } this.isAllowedToClearVodCache = null; @@ -369,8 +372,19 @@ class Session { return null; } } + async getTruncatedVodAudioSegments(vodUri, duration) { + try { + const hlsVod = await this._truncateSlate(null, duration, vodUri); + let vodSegments = hlsVod.getAudioSegments(); + Object.keys(vodSegments).forEach((bw) => vodSegments[bw].unshift({ discontinuity: true, cue: { in: true } })); + return vodSegments; + } catch (exc) { + debug(`[${this._sessionId}]: Failed to generate truncated VOD!`); + return null; + } + } - async setCurrentMediaSequenceSegments(segments, mSeqOffset, reloadBehind) { + async setCurrentMediaSequenceSegments(segments, mSeqOffset, reloadBehind, audioSegments, aSeqOffset) { if (!this._sessionState) { throw new Error("Session not ready"); } @@ -379,6 +393,8 @@ class Session { this.switchDataForSession.reloadBehind = reloadBehind; this.switchDataForSession.transitionSegments = segments; this.switchDataForSession.mediaSeqOffset = mSeqOffset; + this.switchDataForSession.transitionAudioSegments = audioSegments; + this.switchDataForSession.audioSeqOffset = aSeqOffset; let waitTimeMs = 2000; for (let i = segments[Object.keys(segments)[0]].length - 1; 0 < i; i--) { @@ -388,6 +404,20 @@ class Session { } } + if (this.use_demuxed_audio && audioSegments) { + let waitTimeMsAudio = 2000; + let groupId = Object.keys(audioSegments)[0]; + let lang = Object.keys(audioSegments[groupId])[0] + for (let i = audioSegments[groupId][lang].length - 1; 0 < i; i--) { + const segment = audioSegments[groupId][lang][i]; + if (segment.duration) { + waitTimeMsAudio = parseInt(1000 * (segment.duration / 3), 10); + break; + } + } + waitTimeMs = waitTimeMs > waitTimeMsAudio ? waitTimeMs : waitTimeMsAudio; + } + let isLeader = await this._sessionStateStore.isLeader(this._instanceId); if (!isLeader) { debug(`[${this._sessionId}]: FOLLOWER: Invalidate cache to ensure having the correct VOD!`); @@ -488,7 +518,61 @@ class Session { } } - async setCurrentMediaAndDiscSequenceCount(_mediaSeq, _discSeq) { + async getCurrentAudioSequenceSegments(opts) { + if (!this._sessionState) { + throw new Error('Session not ready'); + } + const isLeader = await this._sessionStateStore.isLeader(this._instanceId); + if (isLeader) { + await this._sessionState.set("vodReloaded", 0); + } + + // Only read data from store if state is VOD_PLAYING + let state = await this.getSessionState(); + let tries = 12; + while (state !== SessionState.VOD_PLAYING && tries > 0) { + const waitTimeMs = 500; + debug(`[${this._sessionId}]: state=${state} - Waiting ${waitTimeMs}ms_${tries} until Leader has finished loading next vod.`); + await timer(waitTimeMs); + tries--; + state = await this.getSessionState(); + } + + const playheadState = { + vodMediaSeqAudio: null + } + if (opts && opts.targetMseq !== undefined) { + playheadState.vodMediaSeqAudio = opts.targetMseq; + } else { + playheadState.vodMediaSeqAudio = await this._playheadState.get("vodMediaSeqAudio"); + } + + // NOTE: Assume that VOD cache was already cleared in 'getCurrentMediaAndDiscSequenceCount()' + // and that we now have access to the correct vod cache + const currentVod = await this._sessionState.getCurrentVod(); + if (currentVod) { + try { + const audioSegments = currentVod.getLiveAudioSequenceSegments(playheadState.vodMediaSeqAudio); + let audioSequenceValue = 0; + if (currentVod.sequenceAlwaysContainNewSegments) { + audioSequenceValue = currentVod.mediaSequenceValuesAudio[playheadState.vodMediaSeqAudio]; + debug(`[${this._sessionId}]: {${audioSequenceValue}}_{${currentVod.getLastSequenceMediaSequenceValueAudio()}}`); + } else { + audioSequenceValue = playheadState.vodMediaSeqAudio; + } + debug(`[${this._sessionId}]: Requesting all audio segments from Media Sequence: ${playheadState.vodMediaSeqAudio}(${audioSequenceValue})_${currentVod.getLiveMediaSequencesCount("audio")}`); + return audioSegments; + } catch (err) { + logerror(this._sessionId, err); + await this._sessionState.clearCurrentVodCache(); // force reading up from shared store + throw new Error("Failed to get all current audio segments: " + JSON.stringify(playheadState)); + } + } else { + throw new Error("Engine not ready"); + } + } + + async setCurrentMediaAndDiscSequenceCount(_mediaSeq, _discSeq, _audioSeq, _discAudioSeq) { if (!this._sessionState) { throw new Error("Session not ready"); } @@ -497,6 +581,8 @@ class Session { this.switchDataForSession.mediaSeq = _mediaSeq; this.switchDataForSession.discSeq = _discSeq; + this.switchDataForSession.audioSeq = _audioSeq; + this.switchDataForSession.discAudioSeq = _discAudioSeq; } async getCurrentMediaAndDiscSequenceCount() { @@ -515,9 +601,10 @@ class Session { state = await this.getSessionState(); } - const playheadState = await this._playheadState.getValues(["mediaSeq", "vodMediaSeqVideo"]); + const playheadState = await this._playheadState.getValues(["mediaSeq", "vodMediaSeqVideo", "mediaSeqAudio", "vodMediaSeqAudio"]); const discSeqOffset = await this._sessionState.get("discSeq"); - // TODO: support Audio too ^ + const discAudioSeqOffset = await this._sessionState.get("discSeqAudio"); + // Clear Vod Cache here when Switching to Live just to be safe... if (playheadState.vodMediaSeqVideo === 0) { @@ -538,12 +625,26 @@ class Session { mediaSequenceValue = playheadState.vodMediaSeqVideo; } const discSeqCount = discSeqOffset + currentVod.discontinuities[playheadState.vodMediaSeqVideo]; - + let discSeqCountAudio; + let audioSequenceValue; + if (this.use_demuxed_audio) { + discSeqCountAudio = discAudioSeqOffset + currentVod.discontinuitiesAudio[playheadState.vodMediaSeqAudio]; + if (currentVod.sequenceAlwaysContainNewSegments) { + audioSequenceValue = currentVod.mediaSequenceValuesAudio[playheadState.vodMediaSeqAudio]; + debug(`[${this._sessionId}]: seqIndex=${playheadState.vodMediaSeqAudio}_seqValue=${audioSequenceValue}`) + } else { + audioSequenceValue = playheadState.vodMediaSeqAudio; + } + } debug(`[${this._sessionId}]: MediaSeq: (${playheadState.mediaSeq}+${mediaSequenceValue}=${(playheadState.mediaSeq + mediaSequenceValue)}) and DiscSeq: (${discSeqCount}) requested `); + debug(`[${this._sessionId}]: AudioSeq: (${playheadState.mediaSeqAudio}+${audioSequenceValue}=${(playheadState.mediaSeqAudio + audioSequenceValue)}) and DiscSeq: (${discSeqCountAudio}) requested `); return { 'mediaSeq': (playheadState.mediaSeq + mediaSequenceValue), 'discSeq': discSeqCount, 'vodMediaSeqVideo': playheadState.vodMediaSeqVideo, + 'vodMediaSeqAudio': playheadState.vodMediaSeqAudio, + 'audioSeq': playheadState.mediaSeqAudio + audioSequenceValue, + 'discSeqAudio': discSeqCountAudio, }; } catch (err) { logerror(this._sessionId, err); @@ -969,7 +1070,7 @@ class Session { const profileChannels = profile.channels ? profile.channels : "2"; audioGroupIdToUse = currentVod.getAudioGroupIdForCodecs(audioCodec, profileChannels); if (!audioGroupIds.includes(audioGroupIdToUse)) { - audioGroupIdToUse = defaultAudioGroupId; + audioGroupIdToUse = defaultAudioGroupId; } } @@ -977,30 +1078,30 @@ class Session { // skip stream if no corresponding audio group can be found if (audioGroupIdToUse) { - m3u8 += '#EXT-X-STREAM-INF:BANDWIDTH=' + profile.bw + - ',RESOLUTION=' + profile.resolution[0] + 'x' + profile.resolution[1] + - ',CODECS="' + profile.codecs + '"' + - `,AUDIO="${audioGroupIdToUse}"` + - (defaultSubtitleGroupId ? `,SUBTITLES="${defaultSubtitleGroupId}"` : '') + + m3u8 += '#EXT-X-STREAM-INF:BANDWIDTH=' + profile.bw + + ',RESOLUTION=' + profile.resolution[0] + 'x' + profile.resolution[1] + + ',CODECS="' + profile.codecs + '"' + + `,AUDIO="${audioGroupIdToUse}"` + + (defaultSubtitleGroupId ? `,SUBTITLES="${defaultSubtitleGroupId}"` : '') + (hasClosedCaptions ? ',CLOSED-CAPTIONS="cc"' : '') + '\n'; m3u8 += "master" + profile.bw + ".m3u8%3Bsession=" + this._sessionId + "\n"; } } else { - m3u8 += '#EXT-X-STREAM-INF:BANDWIDTH=' + profile.bw + - ',RESOLUTION=' + profile.resolution[0] + 'x' + profile.resolution[1] + - ',CODECS="' + profile.codecs + '"' + - (defaultAudioGroupId ? `,AUDIO="${defaultAudioGroupId}"` : '') + - (defaultSubtitleGroupId ? `,SUBTITLES="${defaultSubtitleGroupId}"` : '') + + m3u8 += '#EXT-X-STREAM-INF:BANDWIDTH=' + profile.bw + + ',RESOLUTION=' + profile.resolution[0] + 'x' + profile.resolution[1] + + ',CODECS="' + profile.codecs + '"' + + (defaultAudioGroupId ? `,AUDIO="${defaultAudioGroupId}"` : '') + + (defaultSubtitleGroupId ? `,SUBTITLES="${defaultSubtitleGroupId}"` : '') + (hasClosedCaptions ? ',CLOSED-CAPTIONS="cc"' : '') + '\n'; m3u8 += "master" + profile.bw + ".m3u8%3Bsession=" + this._sessionId + "\n"; } }); } else { currentVod.getUsageProfiles().forEach(profile => { - m3u8 += '#EXT-X-STREAM-INF:BANDWIDTH=' + profile.bw + - ',RESOLUTION=' + profile.resolution + - ',CODECS="' + profile.codecs + '"' + - (defaultAudioGroupId ? `,AUDIO="${defaultAudioGroupId}"` : '') + + m3u8 += '#EXT-X-STREAM-INF:BANDWIDTH=' + profile.bw + + ',RESOLUTION=' + profile.resolution + + ',CODECS="' + profile.codecs + '"' + + (defaultAudioGroupId ? `,AUDIO="${defaultAudioGroupId}"` : '') + (defaultSubtitleGroupId ? `,SUBTITLES="${defaultSubtitleGroupId}"` : '') + (hasClosedCaptions ? ',CLOSED-CAPTIONS="cc"' : '') + '\n'; m3u8 += "master" + profile.bw + ".m3u8%3Bsession=" + this._sessionId + "\n"; @@ -1158,8 +1259,9 @@ class Session { let loadPromise; if (!vodResponse.type) { debug(`[${this._sessionId}]: got first VOD uri=${vodResponse.uri}:${vodResponse.offset || 0}`); - const hlsOpts = { sequenceAlwaysContainNewSegments: this.alwaysNewSegments, - forcedDemuxMode: this.use_demuxed_audio, + const hlsOpts = { + sequenceAlwaysContainNewSegments: this.alwaysNewSegments, + forcedDemuxMode: this.use_demuxed_audio, dummySubtitleEndpoint: this.dummySubtitleEndpoint, subtitleSliceEndpoint: this.subtitleSliceEndpoint, shouldContainSubtitles: this.use_vtt_subtitles, @@ -1175,7 +1277,7 @@ class Session { } currentVod = newVod; if (vodResponse.desiredDuration) { - const { mediaManifestLoader, audioManifestLoader} = await this._truncateVod(vodResponse); + const { mediaManifestLoader, audioManifestLoader } = await this._truncateVod(vodResponse); loadPromise = currentVod.load(null, mediaManifestLoader, audioManifestLoader); } else { loadPromise = currentVod.load(); @@ -1340,8 +1442,9 @@ class Session { let loadPromise; if (!vodResponse.type) { debug(`[${this._sessionId}]: got next VOD uri=${vodResponse.uri}:${vodResponse.offset}`); - const hlsOpts = { sequenceAlwaysContainNewSegments: this.alwaysNewSegments, - forcedDemuxMode: this.use_demuxed_audio, + const hlsOpts = { + sequenceAlwaysContainNewSegments: this.alwaysNewSegments, + forcedDemuxMode: this.use_demuxed_audio, dummySubtitleEndpoint: this.dummySubtitleEndpoint, subtitleSliceEndpoint: this.subtitleSliceEndpoint, shouldContainSubtitles: this.use_vtt_subtitles, @@ -1364,7 +1467,7 @@ class Session { } }); if (vodResponse.desiredDuration) { - const { mediaManifestLoader, audioManifestLoader} = await this._truncateVod(vodResponse); + const { mediaManifestLoader, audioManifestLoader } = await this._truncateVod(vodResponse); loadPromise = newVod.loadAfter(currentVod, null, mediaManifestLoader, audioManifestLoader); } else { loadPromise = newVod.loadAfter(currentVod); @@ -1472,31 +1575,75 @@ class Session { // 1) To tell Follower that, Leader is working on it! sessionState.state = await this._sessionState.set("state", SessionState.VOD_RELOAD_INITIATING); // 2) Set new 'offset' sequences, to carry on the continuity from session-live - let mSeq = this.switchDataForSession.mediaSeq; - // TODO: support demux^ + let mseqV = this.switchDataForSession.mediaSeq; + let mseqA = this.switchDataForSession.audioSeq; + let discSeqV = this.switchDataForSession.discSeq; + let discSeqA = this.switchDataForSession.discAudioSeq; let currentVod = await this._sessionState.getCurrentVod(); if (currentVod.sequenceAlwaysContainNewSegments) { // (!) will need to compensate if using this setting on HLSVod Object. - Object.keys(this.switchDataForSession.transitionSegments).forEach(bw => { + Object.keys(this.switchDataForSession.transitionSegments).forEach((bw, i) => { let shiftedSeg = this.switchDataForSession.transitionSegments[bw].shift(); if (shiftedSeg && shiftedSeg.discontinuity) { shiftedSeg = this.switchDataForSession.transitionSegments[bw].shift(); + if (i == 0) { + discSeqV++; + } + } + if (shiftedSeg && shiftedSeg.duration) { + if (i == 0) { + mseqV++; + } } }); + if (this.use_demuxed_audio) { + const groupIds = Object.keys(this.switchDataForSession.transitionAudioSegments); + for (let i = 0; i < groupIds.length; i++) { + const groupId = groupIds[i]; + const langs = Object.keys(this.switchDataForSession.transitionAudioSegments[groupId]); + for (let j = 0; j < langs.length; j++) { + const lang = langs[j]; + let shiftedSeg = this.switchDataForSession.transitionAudioSegments[groupId][lang].shift(); + if (shiftedSeg && shiftedSeg.discontinuity) { + shiftedSeg = this.switchDataForSession.transitionAudioSegments[groupId][lang].shift(); + if (i == 0 && j == 0) { + discSeqA++; + } + } + if (shiftedSeg && shiftedSeg.duration) { + if (i == 0 && j == 0) { + mseqA++; + } + } + } + } + } } - const dSeq = this.switchDataForSession.discSeq; - const mSeqOffset = this.switchDataForSession.mediaSeqOffset; const reloadBehind = this.switchDataForSession.reloadBehind; + const mseqOffsetV = this.switchDataForSession.mediaSeqOffset; + const mseqOffsetA = this.switchDataForSession.audioSeqOffset; const segments = this.switchDataForSession.transitionSegments; - if ([mSeq, dSeq, mSeqOffset, reloadBehind, segments].includes(null)) { + const audioSegments = this.switchDataForSession.transitionAudioSegments; + + + if ([mseqV, discSeqV, mseqOffsetV, reloadBehind, segments].includes(null)) { debug(`[${this._sessionId}]: LEADER: Cannot Reload VOD, missing switch-back data`); return; } - await this._sessionState.set("mediaSeq", mSeq); - await this._playheadState.set("mediaSeq", mSeq, isLeader); - await this._sessionState.set("discSeq", dSeq); + + if (this.use_demuxed_audio && [mseqA, discSeqA, mseqOffsetA, audioSegments].includes(null)) { + debug(`[${this._sessionId}]: LEADER: Cannot Reload VOD, missing switch-back data`); + return; + } + await this._sessionState.set("mediaSeq", mseqV); + await this._playheadState.set("mediaSeq", mseqV, isLeader); + await this._sessionState.set("discSeq", discSeqV); + await this._sessionState.set("mediaSeqAudio", mseqA); + await this._playheadState.set("mediaSeqAudio", mseqA, isLeader); + await this._sessionState.set("discSeqAudio", discSeqA); // TODO: support demux^ - debug(`[${this._sessionId}]: Setting current media and discontinuity count -> [${mSeq}]:[${dSeq}]`); + debug(`[${this._sessionId}]: Setting current media and discontinuity count -> [${mseqV}]:[${discSeqV}]`); + debug(`[${this._sessionId}]: Setting current audio media and discontinuity count -> [${mseqA}]:[${discSeqA}]`); // 3) Set new media segments/currentVod, to carry on the continuity from session-live debug(`[${this._sessionId}]: LEADER: making changes to current VOD. I will also update currentVod in store.`); const playheadState = await this._playheadState.getValues(["vodMediaSeqVideo"]); @@ -1505,11 +1652,17 @@ class Session { nextMseq = currentVod.getLiveMediaSequencesCount() - 1; } + const playheadStateAudio = await this._playheadState.getValues(["vodMediaSeqAudio"]); + let nextAudioMseq = playheadStateAudio.vodMediaSeqAudio + 1; + if (nextAudioMseq > currentVod.getLiveMediaSequencesCount("audio") - 1) { + nextAudioMseq = currentVod.getLiveMediaSequencesCount("audio") - 1; + } + // ---------------------------------------------------. - // TODO: Support reloading with audioSegments and SubtitleSegments as well | + // TODO: Support reloading with SubtitleSegments as well | // ---------------------------------------------------' - await currentVod.reload(nextMseq, segments, null, reloadBehind); + await currentVod.reload(nextMseq, segments, audioSegments, reloadBehind); await this._sessionState.setCurrentVod(currentVod, { ttl: currentVod.getDuration() * 1000 }); await this._sessionState.set("vodReloaded", 1); await this._sessionState.set("vodMediaSeqVideo", 0); @@ -1524,6 +1677,7 @@ class Session { debug(`[${this._sessionId}]: next VOD Reloaded (${currentVod.getDeltaTimes()})`); debug(`[${this._sessionId}]: ${currentVod.getPlayheadPositions()}`); debug(`[${this._sessionId}]: msequences=${currentVod.getLiveMediaSequencesCount()}`); + debug(`[${this._sessionId}]: audio msequences=${currentVod.getLiveMediaSequencesCount("audio")}`); cloudWatchLog(!this.cloudWatchLogging, "engine-session", { event: "switchback", channel: this._sessionId, reqTimeMs: Date.now() - startTS }); return; } else { @@ -1593,8 +1747,9 @@ class Session { slateVod.load() .then(() => { - const hlsOpts = { sequenceAlwaysContainNewSegments: this.alwaysNewSegments, - forcedDemuxMode: this.use_demuxed_audio, + const hlsOpts = { + sequenceAlwaysContainNewSegments: this.alwaysNewSegments, + forcedDemuxMode: this.use_demuxed_audio, dummySubtitleEndpoint: this.dummySubtitleEndpoint, subtitleSliceEndpoint: this.subtitleSliceEndpoint, shouldContainSubtitles: this.use_vtt_subtitles, @@ -1697,10 +1852,10 @@ class Session { slateVod.load() .then(() => { - const hlsOpts = { - sequenceAlwaysContainNewSegments: this.alwaysNewSegments, - forcedDemuxMode: this.use_demuxed_audio, - dummySubtitleEndpoint: this.dummySubtitleEndpoint, + const hlsOpts = { + sequenceAlwaysContainNewSegments: this.alwaysNewSegments, + forcedDemuxMode: this.use_demuxed_audio, + dummySubtitleEndpoint: this.dummySubtitleEndpoint, subtitleSliceEndpoint: this.subtitleSliceEndpoint, shouldContainSubtitles: this.use_vtt_subtitles, expectedSubtitleTracks: this._subtitleTracks, diff --git a/engine/session_live.js b/engine/session_live.js index dd783a7..2c1369d 100644 --- a/engine/session_live.js +++ b/engine/session_live.js @@ -2,6 +2,7 @@ const debug = require("debug")("engine-session-live"); const allSettled = require("promise.allsettled"); const crypto = require("crypto"); const m3u8 = require("@eyevinn/m3u8"); +const { segToM3u8, urlResolve } = require("@eyevinn/hls-vodtolive/utils.js"); const url = require("url"); const fetch = require("node-fetch"); const { m3u8Header } = require("./util.js"); @@ -15,6 +16,7 @@ const daterangeAttribute = (key, attr) => { return key.toUpperCase() + "=" + `"${attr}"`; } }; +const HIGHEST_MEDIA_SEQUENCE_COUNT = 0; const TARGET_PLAYLIST_DURATION_SEC = 60; const RESET_DELAY = 5000; const FAIL_TIMEOUT = 4000; @@ -25,6 +27,16 @@ const PlayheadState = Object.freeze({ CRASHED: 3, IDLE: 4, }); +const PlaylistTypes = Object.freeze({ + VIDEO: 1, + AUDIO: 2, + SUBTITLE: 3, +}); + +/** + * When we implement subtitle support in live-mix we should place it in its own file/or share it with audio + * we should also remove audio implementation when we implement subtitles from this file so we don't get at 4000 line long file. + */ class SessionLive { constructor(config, sessionLiveStore) { @@ -35,20 +47,35 @@ class SessionLive { this.prevMediaSeqCount = 0; this.discSeqCount = 0; this.prevDiscSeqCount = 0; + this.audioSeqCount = 0; + this.prevAudioSeqCount = 0; + this.audioDiscSeqCount = 0; + this.prevAudioDiscSeqCount = 0; this.targetDuration = 0; this.masterManifestUri = null; this.vodSegments = {}; + this.vodSegmentsAudio = {}; this.mediaManifestURIs = {}; + this.audioManifestURIs = {}; this.liveSegQueue = {}; this.lastRequestedMediaSeqRaw = null; + this.prevSeqBottomVideoSegUri = null; + this.prevSeqBottomAudioSegUri = null; this.liveSourceM3Us = {}; + this.liveSegQueueAudio = {}; + this.lastRequestedAudioSeqRaw = null; + this.liveAudioSourceM3Us = {}; this.playheadState = PlayheadState.IDLE; this.liveSegsForFollowers = {}; + this.liveSegsForFollowersAudio = {}; this.timerCompensation = null; this.firstTime = true; + this.firstTimeAudio = true; this.allowedToSet = false; this.pushAmount = 0; this.restAmount = 0; + this.pushAmountAudio = 0; + this.restAmountAudio = 0; this.waitForPlayhead = true; this.blockGenerateManifest = false; @@ -65,6 +92,9 @@ class SessionLive { if (config.profile) { this.sessionLiveProfile = config.profile; } + if (config.audioTracks) { + this.sessionAudioTracks = config.audioTracks; + } } } @@ -85,15 +115,25 @@ class SessionLive { if (resetDelay === null || resetDelay < 0) { resetDelay = RESET_DELAY; } - debug(`[${this.instanceId}][${this.sessionId}]: LEADER: Resetting SessionLive values in Store ${resetDelay === 0 ? "Immediately" : `after a delay=(${resetDelay}ms)`}`); + debug( + `[${this.instanceId}][${this.sessionId}]: LEADER: Resetting SessionLive values in Store ${ + resetDelay === 0 ? "Immediately" : `after a delay=(${resetDelay}ms)` + }` + ); await timer(resetDelay); await this.sessionLiveState.set("liveSegsForFollowers", null); await this.sessionLiveState.set("lastRequestedMediaSeqRaw", null); + await this.sessionLiveState.set("liveSegsForFollowersAudio", null); + await this.sessionLiveState.set("lastRequestedAudioSeqRaw", null); await this.sessionLiveState.set("transitSegs", null); + await this.sessionLiveState.set("transitSegsAudio", null); await this.sessionLiveState.set("firstCounts", { liveSourceMseqCount: null, + liveSourceAudioMseqCount: null, mediaSeqCount: null, + audioSeqCount: null, discSeqCount: null, + audioDiscSeqCount: null, }); debug(`[${this.instanceId}][${this.sessionId}]: LEADER: SessionLive values in Store have now been reset!`); } @@ -112,17 +152,30 @@ class SessionLive { this.mediaSeqCount = 0; this.prevMediaSeqCount = 0; this.discSeqCount = 0; + this.audioSeqCount = 0; + this.prevAudioSeqCount = 0; + this.audioDiscSeqCount = 0; this.targetDuration = 0; this.masterManifestUri = null; this.vodSegments = {}; + this.vodSegmentsAudio = {}; this.mediaManifestURIs = {}; + this.audioManifestURIs = {}; this.liveSegQueue = {}; + this.liveSegQueueAudio = {}; this.lastRequestedMediaSeqRaw = null; + this.lastRequestedAudioSeqRaw = null; + this.prevSeqBottomVideoSegUri = null; + this.prevSeqBottomAudioSegUri = null; this.liveSourceM3Us = {}; + this.liveAudioSourceM3Us = {}; this.liveSegsForFollowers = {}; + this.liveSegsForFollowersAudio = {}; this.timerCompensation = null; this.firstTime = true; + this.firstTimeAudio = true; this.pushAmount = 0; + this.pushAmountAudio = 0; this.allowedToSet = false; this.waitForPlayhead = true; this.blockGenerateManifest = false; @@ -154,7 +207,7 @@ class SessionLive { this.waitForPlayhead = true; const tsIncrementBegin = Date.now(); - await this._loadAllMediaManifests(); + await this._loadAllPlaylistManifests(); const tsIncrementEnd = Date.now(); this.waitForPlayhead = false; @@ -220,7 +273,12 @@ class SessionLive { this._filterLiveProfiles(); debug(`[${this.sessionId}]: Filtered Live profiles! (${Object.keys(this.mediaManifestURIs).length}) profiles left!`); } + if (this.sessionAudioTracks) { + this._filterLiveProfilesAudio(); + debug(`[${this.sessionId}]: Filtered Live audio tracks! (${Object.keys([Object.keys(this.audioManifestURIs)[0]]).length}) profiles left!`); + } } catch (err) { + console.error(err); this.masterManifestUri = null; debug(`[${this.instanceId}][${this.sessionId}]: Failed to fetch Live Master Manifest! ${err}`); debug(`[${this.instanceId}][${this.sessionId}]: Will try again in 1000ms! (tries left=${attempts})`); @@ -228,6 +286,7 @@ class SessionLive { } // To make sure certain operations only occur once. this.firstTime = true; + this.firstTimeAudio = true; } // Return whether job was successful or not. if (!this.masterManifestUri) { @@ -283,7 +342,6 @@ class SessionLive { } else { debug(`[${this.sessionId}]: 'vodSegments' not empty = Using 'transitSegs'`); } - debug(`[${this.sessionId}]: Setting CurrentMediaSequenceSegments. First seg is: [${this.vodSegments[Object.keys(this.vodSegments)[0]][0].uri}]`); const isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); @@ -293,15 +351,88 @@ class SessionLive { debug(`[${this.sessionId}]: LEADER: I am adding 'transitSegs' to Store for future followers`); } } + async setCurrentAudioSequenceSegments(segments) { + if (segments === null) { + debug(`[${this.sessionId}]: No segments provided.`); + return false; + } + // Make it possible to add & share new segments + this.allowedToSet = true; + if (this._isEmpty(this.vodSegmentsAudio)) { + const groupIds = Object.keys(segments); + for (let i = 0; i < groupIds.length; i++) { + const groupId = groupIds[i]; + const langs = Object.keys(segments[groupId]); + for (let j = 0; j < langs.length; j++) { + const lang = langs[j]; + const audiotrack = this._getTrackFromGroupAndLang(groupId, lang); + if (!this.vodSegmentsAudio[audiotrack]) { + this.vodSegmentsAudio[audiotrack] = []; + } + + if (segments[groupId][lang][0].discontinuity) { + segments[groupId][lang].shift(); + } + let cueInExists = null; + for (let segIdx = 0; segIdx < segments[groupId][lang].length; segIdx++) { + const v2lSegment = segments[groupId][lang][segIdx]; + if (v2lSegment.cue) { + if (v2lSegment.cue["in"]) { + cueInExists = true; + } else { + cueInExists = false; + } + } + this.vodSegmentsAudio[audiotrack].push(v2lSegment); + } + + const endIdx = segments[groupId][lang].length - 1; + if (!segments[groupId][lang][endIdx].discontinuity) { + const finalSegItem = { discontinuity: true }; + if (!cueInExists) { + finalSegItem["cue"] = { in: true }; + } + this.vodSegmentsAudio[audiotrack].push(finalSegItem); + } else { + if (!cueInExists) { + segments[groupId][lang][endIdx]["cue"] = { in: true }; + } + } + } + } + } else { + debug(`[${this.sessionId}]: 'vodSegmentsAudio' not empty = Using 'transitSegs'`); + } + debug( + `[${this.sessionId}]: Setting CurrentAudioSequenceSegments. First seg is: [${ + this.vodSegmentsAudio[Object.keys(this.vodSegmentsAudio)[0]][0].uri + }` + ); + + const isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); + if (isLeader) { + //debug(`[${this.sessionId}]: LEADER: I am adding 'transitSegs'=${JSON.stringify(this.vodSegments)} to Store for future followers`); + await this.sessionLiveState.set("transitSegs", this.vodSegmentsAudio); + debug(`[${this.sessionId}]: LEADER: I am adding 'transitSegs' to Store for future followers`); + } + } - async setCurrentMediaAndDiscSequenceCount(mediaSeq, discSeq) { + async setCurrentMediaAndDiscSequenceCount(mediaSeq, discSeq, audioMediaSeq, audioDiscSeq) { if (mediaSeq === null || discSeq === null) { debug(`[${this.sessionId}]: No media or disc sequence provided`); return false; } - debug(`[${this.sessionId}]: Setting mediaSeqCount and discSeqCount to: [${mediaSeq}]:[${discSeq}]`); + if (this.useDemuxedAudio && (audioDiscSeq === null || audioDiscSeq === null)) { + debug(`[${this.sessionId}]: No media or disc sequence for audio provided`); + return false; + } + debug( + `[${this.sessionId}]: Setting mediaSeqCount, discSeqCount, audioSeqCount and audioDiscSeqCount to: [${mediaSeq}]:[${discSeq}], [${audioMediaSeq}]:[${audioDiscSeq}]` + ); this.mediaSeqCount = mediaSeq; this.discSeqCount = discSeq; + this.audioSeqCount = audioMediaSeq; + this.audioDiscSeqCount = audioDiscSeq; // IN CASE: New/Respawned Node Joins the Live Party // Don't use what Session gave you. Use the Leaders number if it's available @@ -312,14 +443,21 @@ class SessionLive { liveSourceMseqCount: null, mediaSeqCount: null, discSeqCount: null, + liveSourceAudioMseqCount: null, + audioSeqCount: null, + audioDiscSeqCount: null, }; } if (isLeader) { liveCounts.discSeqCount = this.discSeqCount; + liveCounts.audioDiscSeqCount = this.audioDiscSeqCount; await this.sessionLiveState.set("firstCounts", liveCounts); } else { const leadersMediaSeqCount = liveCounts.mediaSeqCount; const leadersDiscSeqCount = liveCounts.discSeqCount; + const leadersAudioSeqCount = liveCounts.audioSeqCount; + const leadersAudioDiscSeqCount = liveCounts.audioDiscSeqCount; + if (leadersMediaSeqCount !== null) { this.mediaSeqCount = leadersMediaSeqCount; debug(`[${this.sessionId}]: Setting mediaSeqCount to: [${this.mediaSeqCount}]`); @@ -329,17 +467,35 @@ class SessionLive { this.vodSegments = transitSegs; } } + if (leadersAudioSeqCount !== null) { + this.audioSeqCount = leadersAudioSeqCount; + debug(`[${this.sessionId}]: Setting mediaSeqCount to: [${this.audioSeqCount}]`); + const transitAudioSegs = await this.sessionLiveState.get("transitAudioSegs"); + if (!this._isEmpty(transitAudioSegs)) { + debug(`[${this.sessionId}]: Getting and loading 'transitSegs'`); + this.vodSegmentsAudio = transitAudioSegs; + } + } if (leadersDiscSeqCount !== null) { this.discSeqCount = leadersDiscSeqCount; debug(`[${this.sessionId}]: Setting discSeqCount to: [${this.discSeqCount}]`); } + if (leadersAudioDiscSeqCount !== null) { + this.audioDiscSeqCount = leadersAudioDiscSeqCount; + debug(`[${this.sessionId}]: Setting discSeqCount to: [${this.audioDiscSeqCount}]`); + } } + return true; } async getTransitionalSegments() { return this.vodSegments; } + async getTransitionalAudioSegments() { + return this.vodSegmentsAudio; + } + async getCurrentMediaSequenceSegments() { /** * Might be possible that a follower sends segments to Session @@ -388,10 +544,63 @@ class SessionLive { }; } + async getCurrentAudioSequenceSegments() { + /** + * Might be possible that a follower sends segments to Session + * BEFORE Leader finished fetching new segs and sending segs himself. + * As long as Leader sends same segs to session as Follower even though Leader + * is trying to get new segs, it should be fine! + **/ + this.allowedToSet = false; + const isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); + if (!isLeader) { + const leadersAudioSeqRaw = await this.sessionLiveState.get("lastRequestedAudioSeqRaw"); + if (leadersAudioSeqRaw > this.lastRequestedAudioSeqRaw) { + this.lastRequestedAudioSeqRaw = leadersAudioSeqRaw; + this.liveSegsForFollowersAudio = await this.sessionLiveState.get("liveSegsForFollowersAudio"); + this._updateLiveSegQueueAudio(); + } + } + + let currentAudioSequenceSegments = {}; + let segmentCount = 0; + let increment = 0; + const vodAudiotracks = Object.keys(this.vodSegmentsAudio); + for (let vat of vodAudiotracks) { + const liveTargetTrack = this._findNearestAudiotrack(vat, Object.keys(this.audioManifestURIs)); + const vodTargetTrack = vat; + let vti = this._getGroupAndLangFromTrack(vat); // get the Vod Track Item + if (!currentAudioSequenceSegments[vti.groupId]) { + currentAudioSequenceSegments[vti.groupId] = {}; + } + // Remove segments and disc-tag if they are on top + if (this.vodSegmentsAudio[vodTargetTrack].length > 0 && this.vodSegmentsAudio[vodTargetTrack][0].discontinuity) { + this.vodSegmentsAudio[vodTargetTrack].shift(); + increment = 1; + } + segmentCount = this.vodSegmentsAudio[vodTargetTrack].length; + currentAudioSequenceSegments[vti.groupId][vti.language] = []; + // In case we switch back before we've depleted all transitional segments + currentAudioSequenceSegments[vti.groupId][vti.language] = this.vodSegmentsAudio[vodTargetTrack].concat(this.liveSegQueueAudio[liveTargetTrack]); + currentAudioSequenceSegments[vti.groupId][vti.language].push({ + discontinuity: true, + cue: { in: true }, + }); + debug(`[${this.sessionId}]: Getting current audio segments for ${vodTargetTrack}`); + } + this.audioDiscSeqCount += increment; + return { + currMseqSegs: currentAudioSequenceSegments, + segCount: segmentCount, + }; + } + async getCurrentMediaAndDiscSequenceCount() { return { mediaSeq: this.mediaSeqCount, discSeq: this.discSeqCount, + audioSeq: this.mediaSeqCount, + audioDiscSeq: this.discSeqCount, }; } @@ -440,10 +649,39 @@ class SessionLive { return m3u8; } - // TODO: Implement this later async getCurrentAudioManifestAsync(audioGroupId, audioLanguage) { - debug(`[${this.sessionId}]: getCurrentAudioManifestAsync is NOT Implemented`); - return "Not Implemented"; + if (!this.sessionLiveState) { + throw new Error("SessionLive not ready"); + } + if (audioGroupId === null) { + debug(`[${this.sessionId}]: No audioGroupId provided`); + return null; + } + if (audioLanguage === null) { + debug(`[${this.sessionId}]: No audioLanguage provided`); + return null; + } + debug(`[${this.sessionId}]: ...Loading the selected Live Audio Manifest`); + let attempts = 10; + let m3u8 = null; + while (!m3u8 && attempts > 0) { + attempts--; + try { + m3u8 = await this._GenerateLiveAudioManifest(audioGroupId, audioLanguage); + if (!m3u8) { + debug(`[${this.sessionId}]: No audio manifest available yet, will try again after 1000ms`); + await timer(1000); + } + } catch (exc) { + throw new Error( + `[${this.instanceId}][${this.sessionId}]: Failed to generate audio manifest. Live Session might have ended already. \n${exc}` + ); + } + } + if (!m3u8) { + throw new Error(`[${this.instanceId}][${this.sessionId}]: Failed to generate audio manifest after 10000ms`); + } + return m3u8; } async getCurrentSubtitleManifestAsync(subtitleGroupId, subtitleLanguage) { @@ -495,8 +733,35 @@ class SessionLive { this.mediaManifestURIs[streamItemBW] = ""; } this.mediaManifestURIs[streamItemBW] = mediaManifestUri; + + if (streamItem.get("audio") && this.useDemuxedAudio) { + let audioGroupId = streamItem.get("audio"); + let audioGroupItems = m3u.items.MediaItem.filter((item) => { + return item.get("type") === "AUDIO" && item.get("group-id") === audioGroupId; + }); + // # Find all langs amongst the mediaItems that have this group id. + // # It extracts each mediaItems language attribute value. + // # ALSO initialize in this.audioSegments a lang. property who's value is an array [{seg1}, {seg2}, ...]. + audioGroupItems.map((item) => { + let itemLang; + if (!item.get("language")) { + itemLang = item.get("name"); + } else { + itemLang = item.get("language"); + } + const audiotrack = this._getTrackFromGroupAndLang(audioGroupId, itemLang); + if (!this.audioManifestURIs[audiotrack]) { + this.audioManifestURIs[audiotrack] = ""; + } + const audioManifestUri = url.resolve(baseUrl, item.get("uri")); + this.audioManifestURIs[audiotrack] = audioManifestUri; + }); + } } - debug(`[${this.sessionId}]: All Live Media Manifest URIs have been collected. (${Object.keys(this.mediaManifestURIs).length}) profiles found!`); + debug( + `[${this.sessionId}]: All Live Media Manifest URIs have been collected. (${Object.keys(this.mediaManifestURIs).length}) profiles found!` + ); + debug(`[${this.sessionId}]: All Live Audio Manifest URIs have been collected. (${Object.keys(this.audioManifestURIs).length}) tracks found!`); resolve(); parser.on("error", (exc) => { debug(`Parser Error: ${JSON.stringify(exc)}`); @@ -508,399 +773,642 @@ class SessionLive { // FOLLOWER only function _updateLiveSegQueue() { - if (Object.keys(this.liveSegsForFollowers).length === 0) { - debug(`[${this.sessionId}]: FOLLOWER: Error No Segments found at all.`); - } - const liveBws = Object.keys(this.liveSegsForFollowers); - const size = this.liveSegsForFollowers[liveBws[0]].length; - - // Push the New Live Segments to All Variants - for (let segIdx = 0; segIdx < size; segIdx++) { - for (let i = 0; i < liveBws.length; i++) { - const liveBw = liveBws[i]; - const liveSegFromLeader = this.liveSegsForFollowers[liveBw][segIdx]; - if (!this.liveSegQueue[liveBw]) { - this.liveSegQueue[liveBw] = []; - } - // Do not push duplicates - const liveSegURIs = this.liveSegQueue[liveBw].filter((seg) => seg.uri).map((seg) => seg.uri); - if (liveSegFromLeader.uri && liveSegURIs.includes(liveSegFromLeader.uri)) { - debug(`[${this.sessionId}]: FOLLOWER: Found duplicate live segment. Skip push! (${liveBw})`); - } else { - this.liveSegQueue[liveBw].push(liveSegFromLeader); - debug(`[${this.sessionId}]: FOLLOWER: Pushed segment (${liveSegFromLeader.uri ? liveSegFromLeader.uri : "Disc-tag"}) to 'liveSegQueue' (${liveBw})`); + try { + if (Object.keys(this.liveSegsForFollowers).length === 0) { + debug(`[${this.sessionId}]: FOLLOWER: Error No Segments found at all.`); + } + const liveBws = Object.keys(this.liveSegsForFollowers); + const size = this.liveSegsForFollowers[liveBws[0]].length; + + // Push the New Live Segments to All Variants + for (let segIdx = 0; segIdx < size; segIdx++) { + for (let i = 0; i < liveBws.length; i++) { + const liveBw = liveBws[i]; + const liveSegFromLeader = this.liveSegsForFollowers[liveBw][segIdx]; + if (!this.liveSegQueue[liveBw]) { + this.liveSegQueue[liveBw] = []; + } + // Do not push duplicates + const liveSegURIs = this.liveSegQueue[liveBw].filter((seg) => seg.uri).map((seg) => seg.uri); + if (liveSegFromLeader.uri && liveSegURIs.includes(liveSegFromLeader.uri)) { + debug(`[${this.sessionId}]: FOLLOWER: Found duplicate live segment. Skip push! (${liveBw})`); + } else { + this.liveSegQueue[liveBw].push(liveSegFromLeader); + debug( + `[${this.sessionId}]: FOLLOWER: Pushed Video segment (${ + liveSegFromLeader.uri ? liveSegFromLeader.uri : "Disc-tag" + }) to 'liveSegQueue' (${liveBw})` + ); + } } } - } - // Remove older segments and update counts - const newTotalDuration = this._incrementAndShift("FOLLOWER"); - if (newTotalDuration) { - debug(`[${this.sessionId}]: FOLLOWER: New Adjusted Playlist Duration=${newTotalDuration}s`); + // Remove older segments and update counts + const newTotalDuration = this._incrementAndShift("FOLLOWER"); + if (newTotalDuration) { + debug(`[${this.sessionId}]: FOLLOWER: New Adjusted Playlist Duration=${newTotalDuration}s`); + } + } catch (e) { + console.error(e); + return Promise.reject(e); } } - /** - * This function adds new live segments to the node from which it can - * generate new manifests from. Method for attaining new segments differ - * depending on node Rank. The Leader collects from live source and - * Followers collect from shared storage. - * - * @returns Nothing, but gives data to certain class-variables - */ - async _loadAllMediaManifests() { - debug(`[${this.sessionId}]: Attempting to load all media manifest URIs in=${Object.keys(this.mediaManifestURIs)}`); - let currentMseqRaw = null; - // ------------------------------------- - // If I am a Follower-node then my job - // ends here, where I only read from store. - // ------------------------------------- - let isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); - if (!isLeader && this.lastRequestedMediaSeqRaw !== null) { - debug(`[${this.sessionId}]: FOLLOWER: Reading data from store!`); + _updateLiveSegQueueAudio() { + try { + let followerAudiotracks = Object.keys(this.liveSegsForFollowersAudio); + if (this.liveSegsForFollowersAudio[followerAudiotracks[0]].length === 0) { + debug(`[${this.sessionId}]: FOLLOWER: Error No Audio Segments found at all.`); + } + const size = this.liveSegsForFollowersAudio[followerAudiotracks[0]].length; + // Push the New Live Segments to All Variants + for (let segIdx = 0; segIdx < size; segIdx++) { + for (let i = 0; i < followerAudiotracks.length; i++) { + const fat = followerAudiotracks[i]; + const liveSegFromLeader = this.liveSegsForFollowersAudio[fat][segIdx]; + if (!this.liveSegQueueAudio[fat]) { + this.liveSegQueueAudio[fat] = []; + } + // Do not push duplicates + const liveSegURIs = this.liveSegQueueAudio[fat].filter((seg) => seg.uri).map((seg) => seg.uri); + if (liveSegFromLeader.uri && liveSegURIs.includes(liveSegFromLeader.uri)) { + debug(`[${this.sessionId}]: FOLLOWER: Found duplicate live segment. Skip push! (${liveGroupId})`); + } else { + this.liveSegQueueAudio[fat].push(liveSegFromLeader); + debug( + `[${this.sessionId}]: FOLLOWER: Pushed Audio segment (${ + liveSegFromLeader.uri ? liveSegFromLeader.uri : "Disc-tag" + }) to 'liveSegQueueAudio' (${fat})` + ); + } + } + } + // Remove older segments and update counts + const newTotalDuration = this._incrementAndShiftAudio("FOLLOWER"); + if (newTotalDuration) { + debug(`[${this.sessionId}]: FOLLOWER: New Adjusted Playlist Duration=${newTotalDuration}s`); + } + } catch (e) { + console.error(e); + return Promise.reject(e); + } + } - let leadersMediaSeqRaw = await this.sessionLiveState.get("lastRequestedMediaSeqRaw"); + async _collectSegmentsFromStore() { + try { + // check if audio is enabled + let hasAudio = this.audioManifestURIs.length > 0 ? true : false; + // ------------------------------------- + // If I am a Follower-node then my job + // ends here, where I only read from store. + // ------------------------------------- + let isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); + if (!isLeader && this.lastRequestedMediaSeqRaw !== null) { + debug(`[${this.sessionId}]: FOLLOWER: Reading data from store!`); + + let leadersMediaSeqRaw = await this.sessionLiveState.get("lastRequestedMediaSeqRaw"); + + if (!leadersMediaSeqRaw < this.lastRequestedMediaSeqRaw && this.blockGenerateManifest) { + this.blockGenerateManifest = false; + } - if (!leadersMediaSeqRaw < this.lastRequestedMediaSeqRaw && this.blockGenerateManifest) { - this.blockGenerateManifest = false; - } + let attempts = 10; + // CHECK AGAIN CASE 1: Store Empty + while (!leadersMediaSeqRaw && attempts > 0) { + if (!leadersMediaSeqRaw) { + isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); + if (isLeader) { + debug(`[${this.instanceId}]: I'm the new leader`); + return; + } + } - let attempts = 10; - // CHECK AGAIN CASE 1: Store Empty - while (!leadersMediaSeqRaw && attempts > 0) { - if (!leadersMediaSeqRaw) { - isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); - if (isLeader) { - debug(`[${this.instanceId}]: I'm the new leader`); - return; + if (!this.allowedToSet) { + debug(`[${this.sessionId}]: We are about to switch away from LIVE. Abort fetching from Store`); + break; } + const segDur = this._getAnyFirstSegmentDurationMs() || DEFAULT_PLAYHEAD_INTERVAL_MS; + const waitTimeMs = parseInt(segDur / 3, 10); + debug( + `[${this.sessionId}]: FOLLOWER: Leader has not put anything in store... Will check again in ${waitTimeMs}ms (Tries left=[${attempts}])` + ); + await timer(waitTimeMs); + this.timerCompensation = false; + leadersMediaSeqRaw = await this.sessionLiveState.get("lastRequestedMediaSeqRaw"); + attempts--; } - if (!this.allowedToSet) { - debug(`[${this.sessionId}]: We are about to switch away from LIVE. Abort fetching from Store`); - break; + if (!leadersMediaSeqRaw) { + debug(`[${this.instanceId}]: The leader is still alive`); + return; } - const segDur = this._getAnyFirstSegmentDurationMs() || DEFAULT_PLAYHEAD_INTERVAL_MS; - const waitTimeMs = parseInt(segDur / 3, 10); - debug(`[${this.sessionId}]: FOLLOWER: Leader has not put anything in store... Will check again in ${waitTimeMs}ms (Tries left=[${attempts}])`); - await timer(waitTimeMs); - this.timerCompensation = false; - leadersMediaSeqRaw = await this.sessionLiveState.get("lastRequestedMediaSeqRaw"); - attempts--; - } - - if (!leadersMediaSeqRaw) { - debug(`[${this.instanceId}]: The leader is still alive`); - return; - } - let liveSegsInStore = await this.sessionLiveState.get("liveSegsForFollowers"); - attempts = 10; - // CHECK AGAIN CASE 2: Store Old - while ((leadersMediaSeqRaw <= this.lastRequestedMediaSeqRaw && attempts > 0) || (this._containsSegment(this.liveSegsForFollowers, liveSegsInStore) && attempts > 0)) { - if (!this.allowedToSet) { - debug(`[${this.sessionId}]: We are about to switch away from LIVE. Abort fetching from Store`); - break; + let liveSegsInStore = await this.sessionLiveState.get("liveSegsForFollowers"); + let liveSegsInStoreAudio = hasAudio ? await this.sessionLiveState.get("liveSegsForFollowersAudio") : null; + attempts = 10; + // CHECK AGAIN CASE 2: Store Old + while ( + (leadersMediaSeqRaw <= this.lastRequestedMediaSeqRaw && attempts > 0) || + (this._containsSegment(this.liveSegsForFollowers, liveSegsInStore) && attempts > 0) + ) { + if (!this.allowedToSet) { + debug(`[${this.sessionId}]: We are about to switch away from LIVE. Abort fetching from Store`); + break; + } + if (leadersMediaSeqRaw <= this.lastRequestedMediaSeqRaw) { + isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); + if (isLeader) { + debug(`[${this.instanceId}][${this.sessionId}]: I'm the new leader`); + return; + } + } + if (this._containsSegment(this.liveSegsForFollowers, liveSegsInStore)) { + debug(`[${this.sessionId}]: FOLLOWER: _containsSegment=true,${leadersMediaSeqRaw},${this.lastRequestedMediaSeqRaw}`); + } + const segDur = this._getAnyFirstSegmentDurationMs() || DEFAULT_PLAYHEAD_INTERVAL_MS; + const waitTimeMs = parseInt(segDur / 3, 10); + debug(`[${this.sessionId}]: FOLLOWER: Cannot find anything NEW in store... Will check again in ${waitTimeMs}ms (Tries left=[${attempts}])`); + await timer(waitTimeMs); + this.timerCompensation = false; + leadersMediaSeqRaw = await this.sessionLiveState.get("lastRequestedMediaSeqRaw"); + liveSegsInStore = await this.sessionLiveState.get("liveSegsForFollowers"); + liveSegsInStoreAudio = hasAudio ? await this.sessionLiveState.get("liveSegsForFollowersAudio") : null; + attempts--; } + // FINALLY if (leadersMediaSeqRaw <= this.lastRequestedMediaSeqRaw) { - isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); - if (isLeader) { - debug(`[${this.instanceId}][${this.sessionId}]: I'm the new leader`); - return; - } + debug(`[${this.instanceId}][${this.sessionId}]: The leader is still alive`); + return; + } + // Follower updates its manifest building blocks (segment holders & counts) + this.lastRequestedMediaSeqRaw = leadersMediaSeqRaw; + this.liveSegsForFollowers = liveSegsInStore; + this.liveSegsForFollowersAudio = liveSegsInStoreAudio; + debug( + `[${this.sessionId}]: These are the segments from store:\nV[${JSON.stringify(this.liveSegsForFollowers)}]${ + hasAudio ? `\nA[${JSON.stringify(this.liveSegsForFollowersAudio)}]` : "" + }` + ); + this._updateLiveSegQueue(); + if (hasAudio) { + this._updateLiveSegQueueAudio(); } - if (this._containsSegment(this.liveSegsForFollowers, liveSegsInStore)) { - debug(`[${this.sessionId}]: FOLLOWER: _containsSegment=true,${leadersMediaSeqRaw},${this.lastRequestedMediaSeqRaw}`); - } - const segDur = this._getAnyFirstSegmentDurationMs() || DEFAULT_PLAYHEAD_INTERVAL_MS; - const waitTimeMs = parseInt(segDur / 3, 10); - debug(`[${this.sessionId}]: FOLLOWER: Cannot find anything NEW in store... Will check again in ${waitTimeMs}ms (Tries left=[${attempts}])`); - await timer(waitTimeMs); - this.timerCompensation = false; - leadersMediaSeqRaw = await this.sessionLiveState.get("lastRequestedMediaSeqRaw"); - liveSegsInStore = await this.sessionLiveState.get("liveSegsForFollowers"); - attempts--; - } - // FINALLY - if (leadersMediaSeqRaw <= this.lastRequestedMediaSeqRaw) { - debug(`[${this.instanceId}][${this.sessionId}]: The leader is still alive`); return; } - // Follower updates its manifest building blocks (segment holders & counts) - this.lastRequestedMediaSeqRaw = leadersMediaSeqRaw; - this.liveSegsForFollowers = liveSegsInStore; - debug(`[${this.sessionId}]: These are the segments from store: [${JSON.stringify(this.liveSegsForFollowers)}]`); - this._updateLiveSegQueue(); - return; + } catch (e) { + console.error(e); + return Promise.reject(e); } + } - // --------------------------------- - // FETCHING FROM LIVE-SOURCE - New Followers (once) & Leaders do this. - // --------------------------------- - let FETCH_ATTEMPTS = 10; - this.liveSegsForFollowers = {}; - let bandwidthsToSkipOnRetry = []; - while (FETCH_ATTEMPTS > 0) { - if (isLeader) { - debug(`[${this.sessionId}]: LEADER: Trying to fetch manifests for all bandwidths\n Attempts left=[${FETCH_ATTEMPTS}]`); - } else { - debug(`[${this.sessionId}]: NEW FOLLOWER: Trying to fetch manifests for all bandwidths\n Attempts left=[${FETCH_ATTEMPTS}]`); + async _fetchFromLiveSource() { + try { + let isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); + + let currentMseqRaw = null; + let FETCH_ATTEMPTS = 10; + this.liveSegsForFollowers = {}; + this.liveSegsForFollowersAudio = {}; + let bandwidthsToSkipOnRetry = []; + let audiotracksToSkipOnRetry = []; + const audioTracksExist = Object.keys(this.audioManifestURIs).length > 0 ? true : false; + debug(`[${this.sessionId}]: Attempting to load all MEDIA manifest URIs in=${Object.keys(this.mediaManifestURIs)}`); + if (audioTracksExist) { + debug(`[${this.sessionId}]: Attempting to load all AUDIO manifest URIs in=${Object.keys(this.audioManifestURIs)}`); } + // --------------------------------- + // FETCHING FROM LIVE-SOURCE - New Followers (once) & Leaders do this. + // --------------------------------- + while (FETCH_ATTEMPTS > 0) { + const MSG_1 = (rank, id, count, hasAudio) => { + return `[${id}]: ${rank}: Trying to fetch manifests for all bandwidths${hasAudio ? " and audiotracks" : ""}\n Attempts left=[${count}]`; + }; - if (!this.allowedToSet) { - debug(`[${this.sessionId}]: We are about to switch away from LIVE. Abort fetching from Live-Source`); - break; - } + if (isLeader) { + debug(MSG_1("LEADER", this.sessionId, FETCH_ATTEMPTS, audioTracksExist)); + } else { + debug(MSG_1("NEW FOLLOWER", this.sessionId, FETCH_ATTEMPTS, audioTracksExist)); + } - // Reset Values Each Attempt - let livePromises = []; - let manifestList = []; - this.pushAmount = 0; - try { - if (bandwidthsToSkipOnRetry.length > 0) { - debug(`[${this.sessionId}]: (X) Skipping loadMedia promises for bws ${JSON.stringify(bandwidthsToSkipOnRetry)}`); + if (!this.allowedToSet) { + debug(`[${this.sessionId}]: We are about to switch away from LIVE. Abort fetching from Live-Source`); + break; } - // Collect Live Source Requesting Promises - for (let i = 0; i < Object.keys(this.mediaManifestURIs).length; i++) { - let bw = Object.keys(this.mediaManifestURIs)[i]; - if (bandwidthsToSkipOnRetry.includes(bw)) { - continue; + + // Reset Values Each Attempt + let livePromises = []; + let manifestList = []; + this.pushAmount = 0; + try { + if (bandwidthsToSkipOnRetry.length > 0) { + debug(`[${this.sessionId}]: (X) Skipping loadMedia promises for bws ${JSON.stringify(bandwidthsToSkipOnRetry)}`); + } + if (audiotracksToSkipOnRetry.length > 0) { + debug(`[${this.sessionId}]: (X) Skipping loadMedia promises for audiotracks ${JSON.stringify(audiotracksToSkipOnRetry)}`); + } + // Collect Live Source Requesting Promises + for (let i = 0; i < Object.keys(this.mediaManifestURIs).length; i++) { + let bw = Object.keys(this.mediaManifestURIs)[i]; + if (bandwidthsToSkipOnRetry.includes(bw)) { + continue; + } + livePromises.push(this._loadMediaManifest(bw)); + debug(`[${this.sessionId}]: Pushed loadMedia promise for bw=[${bw}]`); } - livePromises.push(this._loadMediaManifest(bw)); - debug(`[${this.sessionId}]: Pushed loadMedia promise for bw=[${bw}]`); + // Collect Live Source Requesting Promises (audio) + for (let i = 0; i < Object.keys(this.audioManifestURIs).length; i++) { + let atStr = Object.keys(this.audioManifestURIs)[i]; + if (audiotracksToSkipOnRetry.includes(atStr)) { + continue; + } + livePromises.push(this._loadAudioManifest(atStr)); + debug(`[${this.sessionId}]: Pushed loadMedia promise for audiotrack=${atStr}`); + } + // Fetch From Live Source + debug(`[${this.sessionId}]: Executing Promises I: Fetch From Live Source`); + manifestList = await allSettled(livePromises); + livePromises = []; + } catch (err) { + debug(`[${this.sessionId}]: Promises I: FAILURE!\n${err}`); + return; } - // Fetch From Live Source - debug(`[${this.sessionId}]: Executing Promises I: Fetch From Live Source`); - manifestList = await allSettled(livePromises); - livePromises = []; - } catch (err) { - debug(`[${this.sessionId}]: Promises I: FAILURE!\n${err}`); - return; - } - - // Handle if any promise got rejected - if (manifestList.some((result) => result.status === "rejected")) { - FETCH_ATTEMPTS--; - debug(`[${this.sessionId}]: ALERT! Promises I: Failed, Rejection Found! Trying again in 1000ms...`); - await timer(1000); - continue; - } - // Store the results locally - manifestList.forEach((variantItem) => { - const bw = variantItem.value.bandwidth; - if (!this.liveSourceM3Us[bw]) { - this.liveSourceM3Us[bw] = {}; + // Handle if any promise got rejected + if (manifestList.some((result) => result.status === "rejected")) { + FETCH_ATTEMPTS--; + debug(`[${this.sessionId}]: ALERT! Promises I: Failed, Rejection Found! Trying again in 1000ms...`); + await timer(1000); + continue; } - this.liveSourceM3Us[bw] = variantItem.value; - }); - const allStoredMediaSeqCounts = Object.keys(this.liveSourceM3Us).map((variant) => this.liveSourceM3Us[variant].mediaSeq); + // Fill "liveSourceM3Us" and Store the results locally + manifestList.forEach((variantItem) => { + let variantKey = ""; + if (variantItem.value.bandwidth) { + variantKey = variantItem.value.bandwidth; + } else if (variantItem.value.audiotrack) { + variantKey = variantItem.value.audiotrack; + } else { + console.error("NO 'bandwidth' or 'audiotrack' in item:", JSON.stringify(variantItem)); + } + if (!this.liveSourceM3Us[variantKey]) { + this.liveSourceM3Us[variantKey] = {}; + } + this.liveSourceM3Us[variantKey] = variantItem.value; + }); - // Handle if mediaSeqCounts are NOT synced up! - if (!allStoredMediaSeqCounts.every((val, i, arr) => val === arr[0])) { - debug(`[${this.sessionId}]: Live Mseq counts=[${allStoredMediaSeqCounts}]`); - // Figure out what bw's are behind. - const higestMediaSeqCount = Math.max(...allStoredMediaSeqCounts); - bandwidthsToSkipOnRetry = Object.keys(this.liveSourceM3Us).filter((bw) => { - if (this.liveSourceM3Us[bw].mediaSeq === higestMediaSeqCount) { - return true; + const audio_mediaseqcounts = []; + const video_mediaseqcounts = []; + const allStoredMediaSeqCounts = Object.keys(this.liveSourceM3Us).map((variant) => this.liveSourceM3Us[variant].mediaSeq); + Object.keys(this.liveSourceM3Us).map((variantKey) => { + if (!this._isBandwidth(variantKey)) { + audio_mediaseqcounts.push(this.liveSourceM3Us[variantKey].mediaSeq); + } else { + video_mediaseqcounts.push(this.liveSourceM3Us[variantKey].mediaSeq); } - return false; }); - // Decrement fetch counter - FETCH_ATTEMPTS--; - // Calculate retry delay time. Default=1000 - let retryDelayMs = 1000; - if (Object.keys(this.liveSegQueue).length > 0) { - const firstBw = Object.keys(this.liveSegQueue)[0]; - const lastIdx = this.liveSegQueue[firstBw].length - 1; - if (this.liveSegQueue[firstBw][lastIdx].duration) { - retryDelayMs = this.liveSegQueue[firstBw][lastIdx].duration * 1000 * 0.25; + + let MSEQ_COUNTS_ARE_IN_SYNC = false; + if (allStoredMediaSeqCounts.every((val, i, arr) => val === arr[0])) { + MSEQ_COUNTS_ARE_IN_SYNC = true; + } else { + // dont assume the worst + if (video_mediaseqcounts.every((val, i, arr) => val === arr[0]) && audio_mediaseqcounts.every((val, i, arr) => val === arr[0])) { + const MSEQ_DIFF_THRESHOLD = 2; + if (audio_mediaseqcounts.length > 0 && video_mediaseqcounts.length > 0) { + if (audio_mediaseqcounts[0] > video_mediaseqcounts[0] && audio_mediaseqcounts[0] - video_mediaseqcounts[0] > MSEQ_DIFF_THRESHOLD) { + debug( + `[${this.sessionId}]: Audio Mseq counts seem to always be ahead. Will not take them into consideration when syncing Mseq Counts!` + ); + MSEQ_COUNTS_ARE_IN_SYNC = true; + } + } } } - // Wait a little before trying again - debug(`[${this.sessionId}]: ALERT! Live Source Data NOT in sync! Will try again after ${retryDelayMs}ms`); - await timer(retryDelayMs); - if (isLeader) { - this.timerCompensation = false; + // Handle if mediaSeqCounts are NOT synced up! + if (!MSEQ_COUNTS_ARE_IN_SYNC) { + bandwidthsToSkipOnRetry = []; + audiotracksToSkipOnRetry = []; + debug(`[${this.sessionId}]: Live Mseq counts=[${allStoredMediaSeqCounts}]`); + // Figure out what variants's are behind. + HIGHEST_MEDIA_SEQUENCE_COUNT = Math.max(...allStoredMediaSeqCounts); + Object.keys(this.liveSourceM3Us).map((variantKey) => { + if (this.liveSourceM3Us[variantKey].mediaSeq === HIGHEST_MEDIA_SEQUENCE_COUNT) { + if (this._isBandwidth(variantKey)) { + bandwidthsToSkipOnRetry.push(variantKey); + } else { + audiotracksToSkipOnRetry.push(variantKey); + } + } + }); + // Decrement fetch counter + FETCH_ATTEMPTS--; + // Calculate retry delay time. Default=1000 + let retryDelayMs = 1000; + if (Object.keys(this.liveSegQueue).length > 0) { + const firstBw = Object.keys(this.liveSegQueue)[0]; + const lastIdx = this.liveSegQueue[firstBw].length - 1; + if (this.liveSegQueue[firstBw][lastIdx].duration) { + retryDelayMs = this.liveSegQueue[firstBw][lastIdx].duration * 1000 * 0.25; + } + } + // If 3 tries already and only video is unsynced, Make the BAD VARIANTS INHERIT M3U's from the good ones. + if (FETCH_ATTEMPTS >= 4 && audiotracksToSkipOnRetry.length === this.audioManifestURIs.length) { + // Find Highest MSEQ + let [ahead, behind] = Object.keys(this.liveSourceM3Us).map((v) => { + const c = this.liveSourceM3Us[v].mediaSeq; + const a = []; + const b = []; + if (c === HIGHEST_MEDIA_SEQUENCE_COUNT) { + a.push({ c, v }); + } else { + b.push({ c, v }); + } + }); + // Find lowest bitrate with that highest MSEQ + const variantToPaste = ahead.reduce((min, item) => (item.v < min.v ? item : min), list[0]); + // Reassign that bitrate onto the one's originally planned for retry + const m3uToPaste = this.liveSourceM3Us[variantToPaste]; + behind.forEach((item) => { + this.liveSourceM3Us[item.v] = m3uToPaste; + }); + debug(`[${this.sessionId}]: ALERT! Live Source Data NOT in sync! Will fake sync by copy-pasting segments from best mseq`); + } else { + // Wait a little before trying again + debug(`[${this.sessionId}]: ALERT! Live Source Data NOT in sync! Will try again after ${retryDelayMs}ms`); + await timer(retryDelayMs); + if (isLeader) { + this.timerCompensation = false; + } + continue; + } } - continue; - } - currentMseqRaw = allStoredMediaSeqCounts[0]; + currentMseqRaw = allStoredMediaSeqCounts[0]; - if (!isLeader) { - let leadersFirstSeqCounts = await this.sessionLiveState.get("firstCounts"); - let tries = 20; + if (!isLeader) { + let leadersFirstSeqCounts = await this.sessionLiveState.get("firstCounts"); + let tries = 20; - while ((!isLeader && !leadersFirstSeqCounts.liveSourceMseqCount && tries > 0) || leadersFirstSeqCounts.liveSourceMseqCount === 0) { - debug(`[${this.sessionId}]: NEW FOLLOWER: Waiting for LEADER to add 'firstCounts' in store! Will look again after 1000ms (tries left=${tries})`); - await timer(1000); - leadersFirstSeqCounts = await this.sessionLiveState.get("firstCounts"); - tries--; - // Might take over as Leader if Leader is not setting data due to being down. - isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); - if (isLeader) { - debug(`[${this.sessionId}][${this.instanceId}]: I'm the new leader, and now I am going to add 'firstCounts' in store`); + while ((!isLeader && !leadersFirstSeqCounts.liveSourceMseqCount && tries > 0) || leadersFirstSeqCounts.liveSourceMseqCount === 0) { + debug( + `[${this.sessionId}]: NEW FOLLOWER: Waiting for LEADER to add 'firstCounts' in store! Will look again after 1000ms (tries left=${tries})` + ); + await timer(1000); + leadersFirstSeqCounts = await this.sessionLiveState.get("firstCounts"); + tries--; + // Might take over as Leader if Leader is not setting data due to being down. + isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); + if (isLeader) { + debug(`[${this.sessionId}][${this.instanceId}]: I'm the new leader, and now I am going to add 'firstCounts' in store`); + } + } + + if (tries === 0) { + isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); + if (isLeader) { + debug(`[${this.sessionId}][${this.instanceId}]: I'm the new leader, and now I am going to add 'firstCounts' in store`); + break; + } else { + debug(`[${this.sessionId}][${this.instanceId}]: The leader is still alive`); + leadersFirstSeqCounts = await this.sessionLiveState.get("firstCounts"); + if (!leadersFirstSeqCounts.liveSourceMseqCount) { + debug( + `[${this.sessionId}][${this.instanceId}]: Could not find 'firstCounts' in store. Abort Executing Promises II & Returning to Playhead.` + ); + return; + } + } } - } - if (tries === 0) { - isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); if (isLeader) { - debug(`[${this.sessionId}][${this.instanceId}]: I'm the new leader, and now I am going to add 'firstCounts' in store`); - break; - } else { - debug(`[${this.sessionId}][${this.instanceId}]: The leader is still alive`); - leadersFirstSeqCounts = await this.sessionLiveState.get("firstCounts"); - if (!leadersFirstSeqCounts.liveSourceMseqCount) { - debug(`[${this.sessionId}][${this.instanceId}]: Could not find 'firstCounts' in store. Abort Executing Promises II & Returning to Playhead.`); - return; + debug(`[${this.sessionId}]: NEW LEADER: Original Leader went missing, I am retrying live source fetch...`); + await this.sessionLiveState.set("transitSegs", this.vodSegments); + if (audioTracksExist) { + await this.sessionLiveState.set("transitSegsAudio", this.vodSegmentsAudio); } + debug( + `[${this.sessionId}]: NEW LEADER: I am adding 'transitSegs'${ + audioTracksExist ? "and 'transitSegsAudio'" : "" + } to Store for future followers` + ); + continue; } - } - if (isLeader) { - debug(`[${this.sessionId}]: NEW LEADER: Original Leader went missing, I am retrying live source fetch...`); - await this.sessionLiveState.set("transitSegs", this.vodSegments); - debug(`[${this.sessionId}]: NEW LEADER: I am adding 'transitSegs' to Store for future followers`); - continue; - } + // Respawners never do this, only starter followers. + // Edge Case: FOLLOWER transitioned from session with different segments from LEADER + if (leadersFirstSeqCounts.discSeqCount !== this.discSeqCount) { + this.discSeqCount = leadersFirstSeqCounts.discSeqCount; + } + if (leadersFirstSeqCounts.mediaSeqCount !== this.mediaSeqCount) { + this.mediaSeqCount = leadersFirstSeqCounts.mediaSeqCount; + debug( + `[${this.sessionId}]: FOLLOWER transistioned with wrong V2L segments, updating counts to [${this.mediaSeqCount}][${this.discSeqCount}], and reading 'transitSegs' from store` + ); + const transitSegs = await this.sessionLiveState.get("transitSegs"); + if (!this._isEmpty(transitSegs)) { + this.vodSegments = transitSegs; + } + if (audioTracksExist) { + const transitSegsAudio = await this.sessionLiveState.get("transitSegsAudio"); + if (!this._isEmpty(transitSegsAudio)) { + this.vodSegmentsAudio = transitSegsAudio; + } + } + } - // Respawners never do this, only starter followers. - // Edge Case: FOLLOWER transitioned from session with different segments from LEADER - if (leadersFirstSeqCounts.discSeqCount !== this.discSeqCount) { - this.discSeqCount = leadersFirstSeqCounts.discSeqCount; - } - if (leadersFirstSeqCounts.mediaSeqCount !== this.mediaSeqCount) { - this.mediaSeqCount = leadersFirstSeqCounts.mediaSeqCount; + // Prepare to load segments... debug( - `[${this.sessionId}]: FOLLOWER transistioned with wrong V2L segments, updating counts to [${this.mediaSeqCount}][${this.discSeqCount}], and reading 'transitSegs' from store` + `[${this.instanceId}][${this.sessionId}]: Newest mseq from LIVE=${currentMseqRaw} First mseq in store=${leadersFirstSeqCounts.liveSourceMseqCount}` ); - const transitSegs = await this.sessionLiveState.get("transitSegs"); - if (!this._isEmpty(transitSegs)) { - this.vodSegments = transitSegs; - } - } - - // Prepare to load segments... - debug(`[${this.instanceId}][${this.sessionId}]: Newest mseq from LIVE=${currentMseqRaw} First mseq in store=${leadersFirstSeqCounts.liveSourceMseqCount}`); - if (currentMseqRaw === leadersFirstSeqCounts.liveSourceMseqCount) { - this.pushAmount = 1; // Follower from start - } else { - // TODO: To support and account for past discontinuity tags in the Live Source stream, - // we will need to get the real 'current' discontinuity-sequence count from Leader somehow. + if (currentMseqRaw === leadersFirstSeqCounts.liveSourceMseqCount) { + this.pushAmount = 1; // Follower from start + this.pushAmountAudio = this.pushAmount; + } else { + // TODO: To support and account for past discontinuity tags in the Live Source stream, + // we will need to get the real 'current' discontinuity-sequence count from Leader somehow. - // RESPAWNED NODES - this.pushAmount = currentMseqRaw - leadersFirstSeqCounts.liveSourceMseqCount + 1; + // RESPAWNED NODES + this.pushAmount = currentMseqRaw - leadersFirstSeqCounts.liveSourceMseqCount + 1; + this.pushAmountAudio = this.pushAmount; - const transitSegs = await this.sessionLiveState.get("transitSegs"); - //debug(`[${this.sessionId}]: NEW FOLLOWER: I tried to get 'transitSegs'. This is what I found ${JSON.stringify(transitSegs)}`); - if (!this._isEmpty(transitSegs)) { - this.vodSegments = transitSegs; + const transitSegs = await this.sessionLiveState.get("transitSegs"); + //debug(`[${this.sessionId}]: NEW FOLLOWER: I tried to get 'transitSegs'. This is what I found ${JSON.stringify(transitSegs)}`); + if (!this._isEmpty(transitSegs)) { + this.vodSegments = transitSegs; + } + if (audioTracksExist) { + const transitSegsAudio = await this.sessionLiveState.get("transitSegsAudio"); + if (!this._isEmpty(transitSegsAudio)) { + this.vodSegmentsAudio = transitSegsAudio; + } + } } - } - debug(`[${this.sessionId}]: ...pushAmount=${this.pushAmount}`); - } else { - // LEADER calculates pushAmount differently... - if (this.firstTime) { - this.pushAmount = 1; // Leader from start + debug(`[${this.sessionId}]: ...pushAmount=${this.pushAmount}`); } else { - this.pushAmount = currentMseqRaw - this.lastRequestedMediaSeqRaw; - debug(`[${this.sessionId}]: ...calculating pushAmount=${currentMseqRaw}-${this.lastRequestedMediaSeqRaw}=${this.pushAmount}`); + // LEADER calculates pushAmount differently... + if (this.firstTime) { + this.pushAmount = 1; // Leader from start + this.pushAmountAudio = this.pushAmount; + } else { + this.pushAmount = currentMseqRaw - this.lastRequestedMediaSeqRaw; + this.pushAmountAudio = this.pushAmount; + debug(`[${this.sessionId}]: ...calculating pushAmount=${currentMseqRaw}-${this.lastRequestedMediaSeqRaw}=${this.pushAmount}`); + } + debug(`[${this.sessionId}]: ...pushAmount=${this.pushAmount}`); + break; } - debug(`[${this.sessionId}]: ...pushAmount=${this.pushAmount}`); + // Live Source Data is in sync, and LEADER & new FOLLOWER are in sync break; } - // Live Source Data is in sync, and LEADER & new FOLLOWER are in sync - break; + return { + success: FETCH_ATTEMPTS ? true : false, + currentMseqRaw: currentMseqRaw, + }; + } catch (e) { + console.error(e); + return Promise.reject(e); } + } - if (FETCH_ATTEMPTS === 0) { - debug(`[${this.sessionId}]: Fetching from Live-Source did not work! Returning to Playhead Loop...`); - return; - } + async _parseFromLiveSource(current_mediasequence_raw) { + try { + // --------------------------------- + // PARSE M3U's FROM LIVE-SOURCE + // --------------------------------- + let isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); + const audioTracksExist = Object.keys(this.audioManifestURIs).length > 0 ? true : false; + // NEW FOLLOWER - Edge Case: One Instance is ahead of another. Read latest live segs from store + if (!isLeader) { + const leadersCurrentMseqRaw = await this.sessionLiveState.get("lastRequestedMediaSeqRaw"); + const counts = await this.sessionLiveState.get("firstCounts"); + const leadersFirstMseqRaw = counts.liveSourceMseqCount; + if (leadersCurrentMseqRaw !== null && leadersCurrentMseqRaw > current_mediasequence_raw) { + // if leader never had any segs from prev mseq + if (leadersFirstMseqRaw !== null && leadersFirstMseqRaw === leadersCurrentMseqRaw) { + // Follower updates it's manifest ingedients (segment holders & counts) + this.lastRequestedMediaSeqRaw = leadersCurrentMseqRaw; + this.liveSegsForFollowers = await this.sessionLiveState.get("liveSegsForFollowers"); + if (audioTracksExist) { + this.liveSegsForFollowersAudio = await this.sessionLiveState.get("liveSegsForFollowersAudio"); + } - isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); - // NEW FOLLOWER - Edge Case: One Instance is ahead of another. Read latest live segs from store - if (!isLeader) { - const leadersCurrentMseqRaw = await this.sessionLiveState.get("lastRequestedMediaSeqRaw"); - const counts = await this.sessionLiveState.get("firstCounts"); - const leadersFirstMseqRaw = counts.liveSourceMseqCount; - if (leadersCurrentMseqRaw !== null && leadersCurrentMseqRaw > currentMseqRaw) { - // if leader never had any segs from prev mseq - if (leadersFirstMseqRaw !== null && leadersFirstMseqRaw === leadersCurrentMseqRaw) { - // Follower updates it's manifest ingedients (segment holders & counts) - this.lastRequestedMediaSeqRaw = leadersCurrentMseqRaw; - this.liveSegsForFollowers = await this.sessionLiveState.get("liveSegsForFollowers"); - debug(`[${this.sessionId}]: NEW FOLLOWER: Leader is ahead or behind me! Clearing Queue and Getting latest segments from store.`); - this._updateLiveSegQueue(); - this.firstTime = false; - debug(`[${this.sessionId}]: Got all needed segments from live-source (read from store).\nWe are now able to build Live Manifest: [${this.mediaSeqCount}]`); - return; - } else if (leadersCurrentMseqRaw < this.lastRequestedMediaSeqRaw) { - // WE ARE A RESPAWN-NODE, and we are ahead of leader. - this.blockGenerateManifest = true; + debug(`[${this.sessionId}]: NEW FOLLOWER: Leader is ahead or behind me! Clearing Queue and Getting latest segments from store.`); + this._updateLiveSegQueue(); + if (audioTracksExist) { + this._updateLiveSegQueueAudio(); + } + + this.firstTime = false; + debug( + `[${this.sessionId}]: Got all needed segments from live-source (read from store).\nWe are now able to build Live Manifest: [${this.mediaSeqCount}]` + ); + return; + } else if (leadersCurrentMseqRaw < this.lastRequestedMediaSeqRaw) { + // WE ARE A RESPAWN-NODE, and we are ahead of leader. + this.blockGenerateManifest = true; + } } } - } - if (this.allowedToSet) { - // Collect and Push Segment-Extracting Promises - let pushPromises = []; - for (let i = 0; i < Object.keys(this.mediaManifestURIs).length; i++) { - let bw = Object.keys(this.mediaManifestURIs)[i]; - // will add new segments to live seg queue - pushPromises.push(this._parseMediaManifest(this.liveSourceM3Us[bw].M3U, this.mediaManifestURIs[bw], bw, isLeader)); - debug(`[${this.sessionId}]: Pushed pushPromise for bw=${bw}`); + if (this.allowedToSet) { + // Collect and Push Segment-Extracting Promises + let pushPromises = []; + for (let i = 0; i < Object.keys(this.mediaManifestURIs).length; i++) { + let bw = Object.keys(this.mediaManifestURIs)[i]; + // will add new segments to live seg queue + pushPromises.push(this._parseMediaManifest(this.liveSourceM3Us[bw].M3U, this.mediaManifestURIs[bw], bw, i == 0, isLeader)); + debug(`[${this.sessionId}]: Pushed pushPromise for bw=${bw}`); + } + // Collect and Push Segment-Extracting Promises (audio) + for (let i = 0; i < Object.keys(this.audioManifestURIs).length; i++) { + let at = Object.keys(this.audioManifestURIs)[i]; + // will add new segments to live seg queue + pushPromises.push(this._parseAudioManifest(this.liveSourceM3Us[at].M3U, this.audioManifestURIs[at], at, i == 0, isLeader)); + debug(`[${this.sessionId}]: Pushed pushPromise for audiotrack=${at}`); + } + // Segment Pushing + debug(`[${this.sessionId}]: Executing Promises II: Segment Pushing`); + await allSettled(pushPromises); + + // UPDATE COUNTS, & Shift Segments in vodSegments and liveSegQueue if needed. + const leaderORFollower = isLeader ? "LEADER" : "NEW FOLLOWER"; + const newTotalDuration = this._incrementAndShift(leaderORFollower); + if (audioTracksExist) { + this._incrementAndShiftAudio(leaderORFollower); + } + if (newTotalDuration) { + debug(`[${this.sessionId}]: New Adjusted Playlist Duration=${newTotalDuration}s`); + } } - // Segment Pushing - debug(`[${this.sessionId}]: Executing Promises II: Segment Pushing`); - await allSettled(pushPromises); - // UPDATE COUNTS, & Shift Segments in vodSegments and liveSegQueue if needed. - const leaderORFollower = isLeader ? "LEADER" : "NEW FOLLOWER"; - const newTotalDuration = this._incrementAndShift(leaderORFollower); - if (newTotalDuration) { - debug(`[${this.sessionId}]: New Adjusted Playlist Duration=${newTotalDuration}s`); - } - } + // ----------------------------------------------------- + // Leader writes to store so that Followers can read. + // ----------------------------------------------------- + if (isLeader) { + if (this.allowedToSet) { + const liveBws = Object.keys(this.liveSegsForFollowers); + const segListSize = this.liveSegsForFollowers[liveBws[0]].length; + // Do not replace old data with empty data + if (segListSize > 0) { + debug(`[${this.sessionId}]: LEADER: Adding data to store!`); + await this.sessionLiveState.set("lastRequestedMediaSeqRaw", this.lastRequestedMediaSeqRaw); + await this.sessionLiveState.set("liveSegsForFollowers", this.liveSegsForFollowers); + if (audioTracksExist) { + await this.sessionLiveState.set("liveSegsForFollowersAudio", this.liveSegsForFollowersAudio); + } + } + } - // ----------------------------------------------------- - // Leader writes to store so that Followers can read. - // ----------------------------------------------------- - if (isLeader) { - if (this.allowedToSet) { - const liveBws = Object.keys(this.liveSegsForFollowers); - const segListSize = this.liveSegsForFollowers[liveBws[0]].length; - // Do not replace old data with empty data - if (segListSize > 0) { - debug(`[${this.sessionId}]: LEADER: Adding data to store!`); - await this.sessionLiveState.set("lastRequestedMediaSeqRaw", this.lastRequestedMediaSeqRaw); - await this.sessionLiveState.set("liveSegsForFollowers", this.liveSegsForFollowers); - } - } - - // [LASTLY]: LEADER does this for respawned-FOLLOWERS' sake. - if (this.firstTime && this.allowedToSet) { - // Buy some time for followers (NOT Respawned) to fetch their own L.S m3u8. - await timer(1000); // maybe remove - const firstCounts = { - liveSourceMseqCount: this.lastRequestedMediaSeqRaw, - mediaSeqCount: this.prevMediaSeqCount, - discSeqCount: this.prevDiscSeqCount, - }; - debug(`[${this.sessionId}]: LEADER: I am adding 'firstCounts'=${JSON.stringify(firstCounts)} to Store for future followers`); - await this.sessionLiveState.set("firstCounts", firstCounts); + // [LASTLY]: LEADER does this for respawned-FOLLOWERS' sake. + if (this.firstTime && this.allowedToSet) { + // Buy some time for followers (NOT Respawned) to fetch their own L.S m3u8. + await timer(1000); // maybe remove + let firstCounts = await this.sessionLiveState.get("firstCounts"); + firstCounts.liveSourceMseqCount = this.lastRequestedMediaSeqRaw; + firstCounts.mediaSeqCount = this.prevMediaSeqCount; + firstCounts.discSeqCount = this.prevDiscSeqCount; + + debug(`[${this.sessionId}]: LEADER: I am adding 'firstCounts'=${JSON.stringify(firstCounts)} to Store for future followers`); + await this.sessionLiveState.set("firstCounts", firstCounts); + } + debug(`[${this.sessionId}]: LEADER: I am using segs from Mseq=${this.lastRequestedMediaSeqRaw}`); + } else { + debug(`[${this.sessionId}]: NEW FOLLOWER: I am using segs from Mseq=${this.lastRequestedMediaSeqRaw}`); } - debug(`[${this.sessionId}]: LEADER: I am using segs from Mseq=${this.lastRequestedMediaSeqRaw}`); - } else { - debug(`[${this.sessionId}]: NEW FOLLOWER: I am using segs from Mseq=${this.lastRequestedMediaSeqRaw}`); - } - this.firstTime = false; - debug(`[${this.sessionId}]: Got all needed segments from live-source (from all bandwidths).\nWe are now able to build Live Manifest: [${this.mediaSeqCount}]`); + this.firstTime = false; + debug( + `[${this.sessionId}]: Got all needed segments from live-source (from all bandwidths).\nWe are now able to build Live Manifest: [${this.mediaSeqCount}]` + ); - return; + return; + } catch (e) { + console.error(e); + return Promise.reject(e); + } + } + /** + * This function adds new live segments to the node from which it can + * generate new manifests from. Method for attaining new segments differ + * depending on node Rank. The Leader collects from live source and + * Followers collect from shared storage. + */ + async _loadAllPlaylistManifests() { + try { + let isLeader = await this.sessionLiveStateStore.isLeader(this.instanceId); + if (!isLeader && this.lastRequestedMediaSeqRaw !== null) { + // FOLLWERS Do this + await this._collectSegmentsFromStore(); + } else { + // LEADERS and NEW-FOLLOWERS Do this + const result = await this._fetchFromLiveSource(); + if (result.success) { + await this._parseFromLiveSource(result.currentMseqRaw); + } + } + return; + } catch (e) { + console.error("Failure in _loadAllPlaylistManifests:" + e); + } } _shiftSegments(opt) { @@ -909,6 +1417,7 @@ class SessionLive { let _name = ""; let _removedSegments = 0; let _removedDiscontinuities = 0; + let _type = "VIDEO"; if (opt && opt.totalDur) { _totalDur = opt.totalDur; @@ -925,48 +1434,73 @@ class SessionLive { if (opt && opt.removedDiscontinuities) { _removedDiscontinuities = opt.removedDiscontinuities; } - const bws = Object.keys(_segments); + if (opt && opt.type) { + _type = opt.type; + } + const variantKeys = Object.keys(_segments); /* When Total Duration is past the Limit, start Shifting V2L|LIVE segments if found */ while (_totalDur > TARGET_PLAYLIST_DURATION_SEC) { + let result = null; + result = this._shiftVariantSegments(variantKeys, _name, _segments); // Skip loop if there are no more segments to remove... - if (_segments[bws[0]].length === 0) { - return { totalDuration: _totalDur, removedSegments: _removedSegments, removedDiscontinuities: _removedDiscontinuities, shiftedSegments: _segments }; - } - debug(`[${this.sessionId}]: ${_name}: (${_totalDur})s/(${TARGET_PLAYLIST_DURATION_SEC})s - Playlist Duration is Over the Target. Shift needed!`); - let timeToRemove = 0; - let incrementDiscSeqCount = false; - - // Shift Segments for each variant... - for (let i = 0; i < bws.length; i++) { - let seg = _segments[bws[i]].shift(); - if (i === 0) { - debug(`[${this.sessionId}]: ${_name}: (${bws[i]}) Ejected from playlist->: ${JSON.stringify(seg, null, 2)}`); - } - if (seg && seg.discontinuity) { - incrementDiscSeqCount = true; - if (_segments[bws[i]].length > 0) { - seg = _segments[bws[i]].shift(); - if (i === 0) { - debug(`[${this.sessionId}]: ${_name}: (${bws[i]}) Ejected from playlist->: ${JSON.stringify(seg, null, 2)}`); - } - } - } - if (seg && seg.duration) { - timeToRemove = seg.duration; - } + if (!result) { + return { + totalDuration: _totalDur, + removedSegments: _removedSegments, + removedDiscontinuities: _removedDiscontinuities, + shiftedSegments: _segments, + }; } - if (timeToRemove) { - _totalDur -= timeToRemove; + debug( + `[${this.sessionId}]: ${_name}: (${_totalDur})s/(${TARGET_PLAYLIST_DURATION_SEC})s - Playlist Duration is Over the Target. Shift needed!` + ); + _segments = result.segments; + if (result.timeToRemove) { + _totalDur -= result.timeToRemove; // Increment number of removed segments... _removedSegments++; } - if (incrementDiscSeqCount) { + if (result.incrementDiscSeqCount) { // Update Session Live Discontinuity Sequence Count _removedDiscontinuities++; } } - return { totalDuration: _totalDur, removedSegments: _removedSegments, removedDiscontinuities: _removedDiscontinuities, shiftedSegments: _segments }; + return { + totalDuration: _totalDur, + removedSegments: _removedSegments, + removedDiscontinuities: _removedDiscontinuities, + shiftedSegments: _segments, + }; + } + + _shiftVariantSegments(variantKeys, _name, _segments) { + if (_segments[variantKeys[0]].length === 0) { + return null; + } + let timeToRemove = 0; + let incrementDiscSeqCount = false; + + // Shift Segments for each variant... + for (let i = 0; i < variantKeys.length; i++) { + let seg = _segments[variantKeys[i]].shift(); + if (i === 0) { + debug(`[${this.sessionId}]: ${_name}: (${variantKeys[i]}) Ejected from playlist->: ${JSON.stringify(seg, null, 2)}`); + } + if (seg && seg.discontinuity) { + incrementDiscSeqCount = true; + if (_segments[variantKeys[i]].length > 0) { + seg = _segments[variantKeys[i]].shift(); + if (i === 0) { + debug(`[${this.sessionId}]: ${_name}: (${variantKeys[i]}) Ejected from playlist->: ${JSON.stringify(seg, null, 2)}`); + } + } + } + if (seg && seg.duration) { + timeToRemove = seg.duration; + } + } + return { timeToRemove: timeToRemove, incrementDiscSeqCount: incrementDiscSeqCount, segments: _segments }; } /** @@ -1053,6 +1587,85 @@ class SessionLive { return totalDur; } + _incrementAndShiftAudio(instanceName) { + if (!instanceName) { + instanceName = "UNKNOWN"; + } + const vodAudiotrack = Object.keys(this.vodSegmentsAudio); + const liveAudiotrack = Object.keys(this.liveSegQueueAudio); + let vodTotalDur = 0; + let liveTotalDur = 0; + let totalDur = 0; + let removedSegments = 0; + let removedDiscontinuities = 0; + + // Calculate Playlist Total Duration + this.vodSegmentsAudio[vodAudiotrack[0]].forEach((seg) => { + if (seg.duration) { + vodTotalDur += seg.duration; + } + }); + this.liveSegQueueAudio[liveAudiotrack[0]].forEach((seg) => { + if (seg.duration) { + liveTotalDur += seg.duration; + } + }); + totalDur = vodTotalDur + liveTotalDur; + debug(`[${this.sessionId}]: ${instanceName}: L2L dur->: ${liveTotalDur}s | V2L dur->: ${vodTotalDur}s | Total dur->: ${totalDur}s`); + + /** --- SHIFT then INCREMENT --- **/ + + // Shift V2L Segments + const outputV2L = this._shiftSegments({ + name: instanceName, + totalDur: totalDur, + segments: this.vodSegmentsAudio, + removedSegments: removedSegments, + removedDiscontinuities: removedDiscontinuities, + type: "AUDIO", + }); + // Update V2L Segments + this.vodSegmentsAudio = outputV2L.shiftedSegments; + // Update values + totalDur = outputV2L.totalDuration; + removedSegments = outputV2L.removedSegments; + removedDiscontinuities = outputV2L.removedDiscontinuities; + // Shift LIVE Segments + const outputLIVE = this._shiftSegments({ + name: instanceName, + totalDur: totalDur, + segments: this.liveSegQueueAudio, + removedSegments: removedSegments, + removedDiscontinuities: removedDiscontinuities, + type: "AUDIO", + }); + // Update LIVE Segments + this.liveSegQueueAudio = outputLIVE.shiftedSegments; + // Update values + totalDur = outputLIVE.totalDuration; + removedSegments = outputLIVE.removedSegments; + removedDiscontinuities = outputLIVE.removedDiscontinuities; + + // Update Session Live Discontinuity Sequence Count... + this.prevAudioDiscSeqCount = this.audioDiscSeqCount; + this.audioDiscSeqCount += removedDiscontinuities; + // Update Session Live Audio Sequence Count... + this.prevAudioSeqCount = this.audioSeqCount; + this.audioSeqCount += removedSegments; + if (this.restAmountAudio) { + this.audioSeqCount += this.restAmountAudio; + debug(`[${this.sessionId}]: ${instanceName}: Added restAmountAudio=[${this.restAmountAudio}] to 'audioSeqCount'`); + this.restAmountAudio = 0; + } + + if (this.audioDiscSeqCount !== this.prevAudioDiscSeqCount) { + debug(`[${this.sessionId}]: ${instanceName}: Incrementing Dseq Count from {${this.prevAudioDiscSeqCount}} -> {${this.audioDiscSeqCount}}`); + } + debug(`[${this.sessionId}]: ${instanceName}: Incrementing Mseq Count from [${this.prevAudioSeqCount}] -> [${this.audioSeqCount}]`); + debug(`[${this.sessionId}]: ${instanceName}: Finished updating all Counts and Segment Queues!`); + return totalDur; + } + async _loadMediaManifest(bw) { if (!this.sessionLiveState) { throw new Error("SessionLive not ready"); @@ -1099,7 +1712,70 @@ class SessionLive { }); } - _parseMediaManifest(m3u, mediaManifestUri, liveTargetBandwidth, isLeader) { + async _loadAudioManifest(audiotrack) { + try { + if (!this.sessionLiveState) { + throw new Error("SessionLive not ready"); + } + const liveTargetAudiotrack = this._findNearestAudiotrack(audiotrack, Object.keys(this.audioManifestURIs)); + debug(`[${this.sessionId}]: Requesting audiotrack (${audiotrack}), Nearest match is: ${JSON.stringify(liveTargetAudiotrack)}`); + // Get the target media manifest + const audioManifestUri = this.audioManifestURIs[liveTargetAudiotrack]; + const parser = m3u8.createStream(); + const controller = new AbortController(); + const timeout = setTimeout(() => { + debug(`[${this.sessionId}]: Request Timeout! Aborting Request to ${audioManifestUri}`); + controller.abort(); + }, FAIL_TIMEOUT); + const response = await fetch(audioManifestUri, { signal: controller.signal }); + try { + response.body.pipe(parser); + } catch (err) { + debug(`[${this.sessionId}]: Error when piping response to parser! ${JSON.stringify(err)}`); + return Promise.reject(err); + } finally { + clearTimeout(timeout); + } + return new Promise((resolve, reject) => { + parser.on("m3u", (m3u) => { + try { + const resolveObj = { + M3U: m3u, + mediaSeq: m3u.get("mediaSequence"), + audiotrack: liveTargetAudiotrack, + }; + resolve(resolveObj); + } catch (exc) { + debug(`[${this.sessionId}]: Error when parsing latest manifest`); + reject(exc); + } + }); + parser.on("error", (exc) => { + debug(`Parser Error: ${JSON.stringify(exc)}`); + reject(exc); + }); + }); + } catch (err) { + console.error(err); + return Promise.reject(err); + } + } + + _pushAmountBasedOnPreviousLastSegmentURI(m3u, isFirstTrack) { + if (!isFirstTrack) { + return null; + } + for (let i = m3u.items.PlaylistItem.length - 1; i >= 0; i--) { + const pli = m3u.items.PlaylistItem[i]; + if (pli.get("uri") === this.prevSeqBottomVideoSegUri || pli.get("uri") === this.prevSeqBottomAudioSegUri) { + let newAmount = m3u.items.PlaylistItem.length - i; + return newAmount; + } + } + return null; + }; + + _parseMediaManifest(m3u, mediaManifestUri, liveTargetBandwidth, isFirstBW, isLeader) { return new Promise(async (resolve, reject) => { try { if (!this.liveSegQueue[liveTargetBandwidth]) { @@ -1121,6 +1797,11 @@ class SessionLive { this.lastRequestedMediaSeqRaw = m3u.get("mediaSequence"); } this.targetDuration = m3u.get("targetDuration"); + + let newAmount = this._pushAmountBasedOnPreviousLastSegmentURI(m3u, isFirstBW); + if (newAmount !== null) { + this.pushAmount = newAmount; + } let startIdx = m3u.items.PlaylistItem.length - this.pushAmount; if (startIdx < 0) { this.restAmount = startIdx * -1; @@ -1128,7 +1809,52 @@ class SessionLive { } if (mediaManifestUri) { // push segments - this._addLiveSegmentsToQueue(startIdx, m3u.items.PlaylistItem, baseUrl, liveTargetBandwidth, isLeader); + this._addLiveSegmentsToQueue(isFirstBW, startIdx, m3u.items.PlaylistItem, baseUrl, liveTargetBandwidth, isLeader, PlaylistTypes.VIDEO); + } + resolve(); + } catch (exc) { + console.error("ERROR: " + exc); + reject(exc); + } + }); + } + + _parseAudioManifest(m3u, audioPlaylistUri, liveTargetAudiotrack, isFirstAT, isLeader) { + return new Promise(async (resolve, reject) => { + try { + if (!this.liveSegQueueAudio[liveTargetAudiotrack]) { + this.liveSegQueueAudio[liveTargetAudiotrack] = []; + } + if (!this.liveSegsForFollowersAudio[liveTargetAudiotrack]) { + this.liveSegsForFollowersAudio[liveTargetAudiotrack] = []; + } + let baseUrl = ""; + const m = audioPlaylistUri.match(/^(.*)\/.*?$/); + if (m) { + baseUrl = m[1] + "/"; + } + + //debug(`[${this.sessionId}]: Current RAW Mseq: [${m3u.get("mediaSequence")}]`); + //debug(`[${this.sessionId}]: Previous RAW Mseq: [${this.lastRequestedAudioSeqRaw}]`); + /* + WARN: We are assuming here that the MSEQ and Segment lengths are the same on Audio and Video + and therefor need to push an equal amount of segments + */ + if (this.pushAmountAudio >= 0) { + this.lastRequestedMediaSeqRaw = m3u.get("mediaSequence"); + } + this.targetDuration = m3u.get("targetDuration"); + let newAmount = this._pushAmountBasedOnPreviousLastSegmentURI(m3u, isFirstAT); + if (newAmount !== null) { + this.pushAmountAudio = newAmount; + } + let startIdx = m3u.items.PlaylistItem.length - this.pushAmountAudio; + if (startIdx < 0) { + this.restAmount = startIdx * -1; + startIdx = 0; + } + if (audioPlaylistUri) { + this._addLiveSegmentsToQueue(isFirstAT, startIdx, m3u.items.PlaylistItem, baseUrl, liveTargetAudiotrack, isLeader, PlaylistTypes.AUDIO); } resolve(); } catch (exc) { @@ -1146,86 +1872,184 @@ class SessionLive { * @param {string} baseUrl * @param {string} liveTargetBandwidth */ - _addLiveSegmentsToQueue(startIdx, playlistItems, baseUrl, liveTargetBandwidth, isLeader) { - const leaderOrFollower = isLeader ? "LEADER" : "NEW FOLLOWER"; - - for (let i = startIdx; i < playlistItems.length; i++) { - let seg = {}; - let playlistItem = playlistItems[i]; - let segmentUri; - let cueData = null; - let daterangeData = null; - let attributes = playlistItem["attributes"].attributes; - if (playlistItem.properties.discontinuity) { - this.liveSegQueue[liveTargetBandwidth].push({ discontinuity: true }); - this.liveSegsForFollowers[liveTargetBandwidth].push({ discontinuity: true }); - } - if ("cuein" in attributes) { - if (!cueData) { - cueData = {}; + _addLiveSegmentsToQueue(isFirst, startIdx, playlistItems, baseUrl, liveTargetVariant, isLeader, plType) { + try { + const leaderOrFollower = isLeader ? "LEADER" : "NEW FOLLOWER"; + let initSegment = undefined; + let initSegmentByteRange = undefined; + + for (let i = startIdx; i < playlistItems.length; i++) { + let seg = {}; + const playlistItem = playlistItems[i]; + const playlistItemPrev = playlistItems[i - 1] ? playlistItems[i - 1] : null; + let segmentUri; + let byteRange = undefined; + + let keys = undefined; + let daterangeData = null; + if (i === startIdx) { + for (let j = startIdx; j >= 0; j--) { + const pli = playlistItems[j]; + if (pli.get("map-uri")) { + initSegmentByteRange = pli.get("map-byterange"); + if (pli.get("map-uri").match("^http")) { + initSegment = pli.get("map-uri"); + } else { + initSegment = urlResolve(baseUrl, pli.get("map-uri")); + } + break; + } + } } - cueData["in"] = true; - } - if ("cueout" in attributes) { - if (!cueData) { - cueData = {}; + let attributes = playlistItem["attributes"].attributes; + if (playlistItem.get("map-uri")) { + initSegmentByteRange = playlistItem.get("map-byterange"); + if (playlistItem.get("map-uri").match("^http")) { + initSegment = playlistItem.get("map-uri"); + } else { + initSegment = urlResolve(baseUrl, playlistItem.get("map-uri")); + } } - cueData["out"] = true; - cueData["duration"] = attributes["cueout"]; - } - if ("cuecont" in attributes) { - if (!cueData) { - cueData = {}; + // some items such as CUE-IN parse as a PlaylistItem + // but have no URI + if (playlistItem.get("uri")) { + if (playlistItem.get("uri").match("^http")) { + segmentUri = playlistItem.get("uri"); + } else { + segmentUri = urlResolve(baseUrl, playlistItem.get("uri")); + } } - cueData["cont"] = true; - } - if ("scteData" in attributes) { - if (!cueData) { - cueData = {}; + if (playlistItem.get("discontinuity") && (playlistItemPrev && !playlistItemPrev.get("discontinuity"))) { + if (plType === PlaylistTypes.VIDEO) { + this.liveSegQueue[liveTargetVariant].push({ discontinuity: true }); + this.liveSegsForFollowers[liveTargetVariant].push({ discontinuity: true }); + } else if (plType === PlaylistTypes.AUDIO) { + this.liveSegQueueAudio[liveTargetVariant].push({ discontinuity: true }); + this.liveSegsForFollowersAudio[liveTargetVariant].push({ discontinuity: true }); + } else { + console.warn(`[${this.sessionId}]: WARNING: plType=${plType} Not valid (disc-seg)`); + } } - cueData["scteData"] = attributes["scteData"]; - } - if ("assetData" in attributes) { - if (!cueData) { - cueData = {}; + if (playlistItem.get("byteRange")) { + let [_, r, o] = playlistItem.get("byteRange").match(/^(\d+)@*(\d*)$/); + if (!o) { + o = byteRangeOffset; + } + byteRangeOffset = parseInt(r) + parseInt(o); + byteRange = `${r}@${o}`; } - cueData["assetData"] = attributes["assetData"]; - } - if ("daterange" in attributes) { - if (!daterangeData) { - daterangeData = {}; + if (playlistItem.get("keys")) { + keys = playlistItem.get("keys"); + } + let assetData = playlistItem.get("assetdata"); + let cueOut = playlistItem.get("cueout"); + let cueIn = playlistItem.get("cuein"); + let cueOutCont = playlistItem.get("cont-offset"); + let duration = 0; + let scteData = playlistItem.get("sctedata"); + if (typeof cueOut !== "undefined") { + duration = cueOut; + } else if (typeof cueOutCont !== "undefined") { + duration = playlistItem.get("cont-dur"); } - let allDaterangeAttributes = Object.keys(attributes["daterange"]); - allDaterangeAttributes.forEach((attr) => { - if (attr.match(/DURATION$/)) { - daterangeData[attr.toLowerCase()] = parseFloat(attributes["daterange"][attr]); + let cue = + cueOut || cueIn || cueOutCont || assetData + ? { + out: typeof cueOut !== "undefined", + cont: typeof cueOutCont !== "undefined" ? cueOutCont : null, + scteData: typeof scteData !== "undefined" ? scteData : null, + in: cueIn ? true : false, + duration: duration, + assetData: typeof assetData !== "undefined" ? assetData : null, + } + : null; + seg = { + duration: playlistItem.get("duration"), + timelinePosition: this.timeOffset != null ? this.timeOffset + timelinePosition : null, + cue: cue, + byteRange: byteRange, + }; + if (initSegment) { + seg.initSegment = initSegment; + } + if (initSegmentByteRange) { + seg.initSegmentByteRange = initSegmentByteRange; + } + if (segmentUri) { + seg.uri = segmentUri; + } + if (keys) { + seg.keys = keys; + } + if (i === startIdx) { + // Add daterange metadata if this is the first segment + if (this.rangeMetadata && !this._isEmpty(this.rangeMetadata)) { + seg["daterange"] = this.rangeMetadata; + } + } + if ("daterange" in attributes) { + if (!daterangeData) { + daterangeData = {}; + } + let allDaterangeAttributes = Object.keys(attributes["daterange"]); + allDaterangeAttributes.forEach((attr) => { + if (attr.match(/DURATION$/)) { + daterangeData[attr.toLowerCase()] = parseFloat(attributes["daterange"][attr]); + } else { + daterangeData[attr.toLowerCase()] = attributes["daterange"][attr]; + } + }); + } + if (playlistItem.get("uri")) { + if (daterangeData && !this._isEmpty(daterangeData)) { + seg["daterange"] = daterangeData; + } + // Push new Live Segments! But do not push duplicates + if (plType === PlaylistTypes.VIDEO) { + this._pushToQueue(seg, liveTargetVariant, leaderOrFollower); + if (isFirst) { + this.prevSeqBottomVideoSegUri = seg.uri; + } + } else if (plType === PlaylistTypes.AUDIO) { + this._pushToQueueAudio(seg, liveTargetVariant, leaderOrFollower); + if (isFirst) { + this.prevSeqBottomAudioSegUri = seg.uri; + } } else { - daterangeData[attr.toLowerCase()] = attributes["daterange"][attr]; + console.warn(`[${this.sessionId}]: WARNING: plType=${plType} Not valid (seg)`); } - }); - } - if (playlistItem.properties.uri) { - if (playlistItem.properties.uri.match("^http")) { - segmentUri = playlistItem.properties.uri; - } else { - segmentUri = url.resolve(baseUrl, playlistItem.properties.uri); - } - seg["duration"] = playlistItem.properties.duration; - seg["uri"] = segmentUri; - seg["cue"] = cueData; - if (daterangeData) { - seg["daterange"] = daterangeData; - } - // Push new Live Segments! But do not push duplicates - const liveSegURIs = this.liveSegQueue[liveTargetBandwidth].filter((seg) => seg.uri).map((seg) => seg.uri); - if (seg.uri && liveSegURIs.includes(seg.uri)) { - debug(`[${this.sessionId}]: ${leaderOrFollower}: Found duplicate live segment. Skip push! (${liveTargetBandwidth})`); - } else { - this.liveSegQueue[liveTargetBandwidth].push(seg); - this.liveSegsForFollowers[liveTargetBandwidth].push(seg); - debug(`[${this.sessionId}]: ${leaderOrFollower}: Pushed segment (${seg.uri ? seg.uri : "Disc-tag"}) to 'liveSegQueue' (${liveTargetBandwidth})`); } } + } catch (e) { + console.error(e); + return Promise.reject(e); + } + } + + _pushToQueue(seg, liveTargetBandwidth, logName) { + const liveSegURIs = this.liveSegQueue[liveTargetBandwidth].filter((seg) => seg.uri).map((seg) => seg.uri); + if (seg.uri && liveSegURIs.includes(seg.uri)) { + debug(`[${this.sessionId}]: ${logName}: Found duplicate live segment. Skip push! (${liveTargetBandwidth})`); + } else { + this.liveSegQueue[liveTargetBandwidth].push(seg); + this.liveSegsForFollowers[liveTargetBandwidth].push(seg); + debug( + `[${this.sessionId}]: ${logName}: Pushed Video segment (${seg.uri ? seg.uri : "Disc-tag"}) to 'liveSegQueue' (${liveTargetBandwidth})`); + } + } + + _pushToQueueAudio(seg, liveTargetAudiotrack, logName) { + const liveSegURIs = this.liveSegQueueAudio[liveTargetAudiotrack].filter((seg) => seg.uri).map((seg) => seg.uri); + if (seg.uri && liveSegURIs.includes(seg.uri)) { + debug(`[${this.sessionId}]: ${logName}: Found duplicate live segment. Skip push! track -> (${liveTargetAudiotrack})`); + } else { + this.liveSegQueueAudio[liveTargetAudiotrack].push(seg); + this.liveSegsForFollowersAudio[liveTargetAudiotrack].push(seg); + debug( + `[${this.sessionId}]: ${logName}: Pushed Audio segment (${ + seg.uri ? seg.uri : "Disc-tag" + }) to 'liveSegQueue' track -> (${liveTargetAudiotrack})` + ); } } @@ -1266,7 +2090,10 @@ class SessionLive { */ // DO NOT GENERATE MANIFEST CASE: Node has not found anything in store OR Node has not even check yet. - if (Object.keys(this.liveSegQueue).length === 0 || (this.liveSegQueue[liveTargetBandwidth] && this.liveSegQueue[liveTargetBandwidth].length === 0)) { + if ( + Object.keys(this.liveSegQueue).length === 0 || + (this.liveSegQueue[liveTargetBandwidth] && this.liveSegQueue[liveTargetBandwidth].length === 0) + ) { debug(`[${this.sessionId}]: Cannot Generate Manifest! <${this.instanceId}> Not yet collected ANY segments from Live Source...`); return null; } @@ -1303,58 +2130,115 @@ class SessionLive { if (Object.keys(this.vodSegments).length !== 0) { // Add transitional segments if there are any left. debug(`[${this.sessionId}]: Adding a Total of (${this.vodSegments[vodTargetBandwidth].length}) VOD segments to manifest`); - m3u8 = this._setMediaManifestTags(this.vodSegments, m3u8, vodTargetBandwidth); + m3u8 = this._setVariantManifestSegmentTags(this.vodSegments, m3u8, vodTargetBandwidth); // Add live-source segments - m3u8 = this._setMediaManifestTags(this.liveSegQueue, m3u8, liveTargetBandwidth); + m3u8 = this._setVariantManifestSegmentTags(this.liveSegQueue, m3u8, liveTargetBandwidth); } debug(`[${this.sessionId}]: Manifest Generation Complete!`); return m3u8; } - _setMediaManifestTags(segments, m3u8, bw) { - for (let i = 0; i < segments[bw].length; i++) { - const seg = segments[bw][i]; - if (seg.discontinuity) { - m3u8 += "#EXT-X-DISCONTINUITY\n"; - } - if (seg.cue) { - if (seg.cue.out) { - if (seg.cue.scteData) { - m3u8 += "#EXT-OATCLS-SCTE35:" + seg.cue.scteData + "\n"; - } - if (seg.cue.assetData) { - m3u8 += "#EXT-X-ASSET:" + seg.cue.assetData + "\n"; - } - m3u8 += "#EXT-X-CUE-OUT:DURATION=" + seg.cue.duration + "\n"; - } - if (seg.cue.cont) { - if (seg.cue.scteData) { - m3u8 += "#EXT-X-CUE-OUT-CONT:ElapsedTime=" + seg.cue.cont + ",Duration=" + seg.cue.duration + ",SCTE35=" + seg.cue.scteData + "\n"; - } else { - m3u8 += "#EXT-X-CUE-OUT-CONT:" + seg.cue.cont + "/" + seg.cue.duration + "\n"; - } - } + async _GenerateLiveAudioManifest(audioGroupId, audioLanguage) { + if (audioGroupId === null) { + throw new Error("No audioGroupId provided"); + } + if (audioLanguage === null) { + throw new Error("No audioLanguage provided"); + } + const liveTargetTrack = this._findNearestAudiotrack( + this._getTrackFromGroupAndLang(audioGroupId, audioLanguage), + Object.keys(this.audioManifestURIs) + ); + const vodTargetTrack = this._findNearestAudiotrack( + this._getTrackFromGroupAndLang(audioGroupId, audioLanguage), + Object.keys(this.vodSegmentsAudio) + ); + debug( + `[${this.sessionId}]: Client requesting manifest for VodTrackInfo=(${JSON.stringify(vodTargetTrack)}). Nearest LiveTrackInfo=(${JSON.stringify( + liveTargetTrack + )})` + ); + + if (this.blockGenerateManifest) { + debug(`[${this.sessionId}]: FOLLOWER: Cannot Generate Audio Manifest! Waiting to sync-up with Leader...`); + return null; + } + + // DO NOT GENERATE MANIFEST CASE: Node has not found anything in store OR Node has not even check yet. + if (Object.keys(this.liveSegQueueAudio).length === 0 || this.liveSegQueueAudio[liveTargetTrack].length === 0) { + debug(`[${this.sessionId}]: Cannot Generate Audio Manifest! <${this.instanceId}> Not yet collected ANY segments from Live Source...`); + return null; + } + + // DO NOT GENERATE MANIFEST CASE: Node is in the middle of gathering segs of all variants. + const tracks = Object.keys(this.liveSegQueueAudio); + let segAmounts = []; + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i]; + if (this.liveSegQueueAudio[track].length !== 0) { + segAmounts.push(this.liveSegQueueAudio[track].length); } - if (seg.datetime) { - m3u8 += `#EXT-X-PROGRAM-DATE-TIME:${seg.datetime}\n`; + } + + if (!segAmounts.every((val, i, arr) => val === arr[0])) { + console(`[${this.sessionId}]: Cannot Generate audio Manifest! <${this.instanceId}> Not yet collected ALL segments from Live Source...`); + return null; + } + + if (!this._isEmpty(this.liveSegQueueAudio) && this.liveSegQueueAudio[tracks[0]].length !== 0) { + this.targetDuration = this._getMaxDuration(this.liveSegQueueAudio[tracks[0]]); + } + + // Determine if VOD segments influence targetDuration + for (let i = 0; i < this.vodSegmentsAudio[vodTargetTrack].length; i++) { + let vodSeg = this.vodSegmentsAudio[vodTargetTrack][i]; + // Get max duration amongst segments + if (vodSeg.duration > this.targetDuration) { + this.targetDuration = vodSeg.duration; } - if (seg.daterange) { - const dateRangeAttributes = Object.keys(seg.daterange) - .map((key) => daterangeAttribute(key, seg.daterange[key])) - .join(","); - if (!seg.datetime && seg.daterange["start-date"]) { - m3u8 += "#EXT-X-PROGRAM-DATE-TIME:" + seg.daterange["start-date"] + "\n"; - } - m3u8 += "#EXT-X-DATERANGE:" + dateRangeAttributes + "\n"; + } + + debug(`[${this.sessionId}]: Started Generating the Audio Manifest File:[${this.audioSeqCount}]...`); + let m3u8 = "#EXTM3U\n"; + m3u8 += "#EXT-X-VERSION:6\n"; + m3u8 += m3u8Header(this.instanceId); + m3u8 += "#EXT-X-INDEPENDENT-SEGMENTS\n"; + m3u8 += "#EXT-X-TARGETDURATION:" + Math.round(this.targetDuration) + "\n"; + m3u8 += "#EXT-X-MEDIA-SEQUENCE:" + this.audioSeqCount + "\n"; + m3u8 += "#EXT-X-DISCONTINUITY-SEQUENCE:" + this.audioDiscSeqCount + "\n"; + if (Object.keys(this.vodSegmentsAudio).length !== 0) { + // Add transitional segments if there are any left. + debug(`[${this.sessionId}]: Adding a Total of (${this.vodSegmentsAudio[vodTargetTrack].length}) VOD audio segments to manifest`); + m3u8 = this._setVariantManifestSegmentTags(this.vodSegmentsAudio, m3u8, vodTargetTrack); + // Add live-source segments + m3u8 = this._setVariantManifestSegmentTags(this.liveSegQueueAudio, m3u8, liveTargetTrack); + } + debug(`[${this.sessionId}]: Audio manifest Generation Complete!`); + return m3u8; + } + _setVariantManifestSegmentTags(segments, m3u8, variantKey) { + let previousSeg = null; + const size = segments[variantKey].length; + for (let i = 0; i < size; i++) { + const seg = segments[variantKey][i]; + const nextSeg = segments[variantKey][i + 1] ? segments[variantKey][i + 1] : {}; + if (seg.discontinuity) { + m3u8 += "#EXT-X-DISCONTINUITY\n"; } - // Mimick logic used in hls-vodtolive if (seg.cue && seg.cue.in) { - m3u8 += "#EXT-X-CUE-IN" + "\n"; + m3u8 += "#EXT-X-CUE-IN\n"; + } + m3u8 += segToM3u8(seg, i, size, nextSeg, previousSeg); + // In case of duplicate disc-tags, remove one. + if (m3u8.includes("#EXT-X-DISCONTINUITY\n#EXT-X-DISCONTINUITY\n")) { + debug(`[${this.sessionId}]: Removing Duplicate Disc-tag from output M3u8`); + m3u8 = m3u8.replace("#EXT-X-DISCONTINUITY\n#EXT-X-DISCONTINUITY\n", "#EXT-X-DISCONTINUITY\n"); } - if (seg.uri) { - m3u8 += "#EXTINF:" + seg.duration.toFixed(3) + ",\n"; - m3u8 += seg.uri + "\n"; + if (m3u8.includes("#EXT-X-DISCONTINUITY\n#EXT-X-CUE-IN\n#EXT-X-DISCONTINUITY\n#EXT-X-CUE-IN\n")) { + debug(`[${this.sessionId}]: Removing Duplicate Disc-tag from output M3u8`); + m3u8 = m3u8.replace("#EXT-X-DISCONTINUITY\n#EXT-X-CUE-IN\n#EXT-X-DISCONTINUITY\n#EXT-X-CUE-IN\n", "#EXT-X-DISCONTINUITY\n#EXT-X-CUE-IN\n"); } + previousSeg = seg; } return m3u8; } @@ -1392,6 +2276,52 @@ class SessionLive { return null; } + _findAudioGroupsForLang(audioLanguage, segments) { + let trackInfos = []; + const groupIds = Object.keys(segments); + for (let i = 0; i < groupIds.length; i++) { + const groupId = groupIds[i]; + const langs = Object.keys(segments[groupId]); + for (let j = 0; j < langs.length; j++) { + const lang = langs[j]; + if (lang === audioLanguage) { + trackInfos.push({ audioGroupId: groupId, audioLanguage: lang }); + break; + } + } + } + return trackInfos; + } + + _findNearestAudiotrack(track, tracks) { + // perfect match + if (tracks.includes(track)) { + return track; + } + let tracksMatchingOnLanguage = tracks.filter((t) => { + if (this._getLangFromTrack(t) === this._getLangFromTrack(track)) { + return true; + } + return false; + }); + // If any matches, then it implies that no group ID matches, so use a fallback (first) group + if (tracksMatchingOnLanguage.length > 0) { + return tracksMatchingOnLanguage[0]; + } + // If no matches then check if we have any matched on group id, then use fallback (first) language + let tracksMatchingOnGroupId = tracks.filter((t) => { + if (this._getGroupFromTrack(t) === this._getGroupFromTrack(track)) { + return true; + } + return false; + }); + if (tracksMatchingOnGroupId.length > 0) { + return tracksMatchingOnGroupId[0]; + } + // No groupId or language matches the target, use fallback (first) track + return tracks[0]; + } + _getMaxDuration(segments) { if (!segments) { debug(`[${this.sessionId}]: ERROR segments is: ${segments}`); @@ -1422,6 +2352,44 @@ class SessionLive { }); this.mediaManifestURIs = newItem; } + _filterLiveProfilesAudio() { + const tracks = this.sessionAudioTracks.map((trackItem) => { + return this._getTrackFromGroupAndLang(trackItem.groupId, trackItem.language); + }); + const toKeep = new Set(); + let newItem = {}; + tracks.forEach((t) => { + let atToKeep = this._findNearestAudiotrack(t, Object.keys(this.audioManifestURIs)); + toKeep.add(atToKeep); + }); + toKeep.forEach((at) => { + newItem[at] = this.audioManifestURIs[at]; + }); + this.audioManifestURIs = newItem; + } + + _filterLiveAudioTracks() { + let audioTracks = this.sessionAudioTracks; + const toKeep = new Set(); + + let newItemsAudio = {}; + audioTracks.forEach((audioTrack) => { + let groupAndLangToKeep = this._findAudioGroupsForLang(audioTrack.language, this.audioManifestURIs); + toKeep.add(...groupAndLangToKeep); + }); + + toKeep.forEach((trackInfo) => { + if (trackInfo) { + if (!newItemsAudio[trackInfo.audioGroupId]) { + newItemsAudio[trackInfo.audioGroupId] = {}; + } + newItemsAudio[trackInfo.audioGroupId][trackInfo.audioLanguage] = this.audioManifestURIs[trackInfo.audioGroupId][trackInfo.audioLanguage]; + } + }); + if (!this._isEmpty(newItemsAudio)) { + this.audioManifestURIs = newItemsAudio; + } + } _getAnyFirstSegmentDurationMs() { if (this._isEmpty(this.liveSegQueue)) { @@ -1475,6 +2443,67 @@ class SessionLive { } return false; } + + _getGroupAndLangFromTrack(track) { + const GLItem = { + groupId: null, + language: null, + }; + const match = track.match(/g:(.*?);l:(.*)/); + if (match) { + const g = match[1]; + const l = match[2]; + if (g && l) { + GLItem.groupId = g; + GLItem.language = l; + return GLItem; + } + } + console.error(`Failed to extract GroupID and Language g=${g};l=${l}`); + return GLItem; + } + + _getLangFromTrack(track) { + const match = track.match(/g:(.*?);l:(.*)/); + if (match) { + const g = match[1]; + const l = match[2]; + if (g && l) { + return l; + } + } + console.error(`Failed to extract Language g=${g};l=${l}`); + return null; + } + + _getGroupFromTrack(track) { + const match = track.match(/g:(.*?);l:(.*)/); + if (match) { + const g = match[1]; + const l = match[2]; + if (g && l) { + return g; + } + } + console.error(`Failed to extract Group ID g=${g};l=${l}`); + return null; + } + + _getTrackFromGroupAndLang(g, l) { + return `g:${g};l:${l}`; + } + + _isBandwidth(bw) { + if (typeof bw === "number") { + return true; + } else if (typeof bw === "string") { + const parsedNumber = parseFloat(bw); + if (!isNaN(parsedNumber)) { + return true; + } + } + return false; + } } module.exports = SessionLive; diff --git a/engine/stream_switcher.js b/engine/stream_switcher.js index 348777b..73750f4 100644 --- a/engine/stream_switcher.js +++ b/engine/stream_switcher.js @@ -1,9 +1,10 @@ const debug = require("debug")("engine-stream-switcher"); const crypto = require("crypto"); const fetch = require("node-fetch"); +const url = require("url"); const { AbortController } = require("abort-controller"); const { SessionState } = require("./session_state"); -const { timer, findNearestValue, isValidUrl, fetchWithRetry } = require("./util"); +const { timer, findNearestValue, isValidUrl, fetchWithRetry, findAudioGroupOrLang, ItemIsEmpty } = require("./util"); const m3u8 = require("@eyevinn/m3u8"); const SwitcherState = Object.freeze({ @@ -116,9 +117,16 @@ class StreamSwitcher { if (isValidUrl(prerollUri)) { try { const segments = await this._loadPreroll(prerollUri); + if (this.useDemuxedAudio && ItemIsEmpty(segments.audioSegments)) { + const errmsg = `[${this.sessionId}]: Preroll is not demuxed. Preroll from uri=${prerollUri} will not be used.`; + debug(errmsg); + console.error("WARNING! " + errmsg); + } const prerollItem = { - segments: segments, + segments: segments.mediaSegments, + audioSegments: segments.audioSegments, maxAge: tsNow + 30 * 60 * 1000, + isValid: this.useDemuxedAudio ? !ItemIsEmpty(segments.audioSegments) : true }; this.prerollsCache[this.sessionId] = prerollItem; } catch (err) { @@ -157,7 +165,7 @@ class StreamSwitcher { validURI = await this._validURI(scheduleObj.uri); tries++; if (!validURI) { - const delayMs = tries * 500; + const delayMs = (tries * tries * 100); debug(`[${this.sessionId}]: Going to try validating Master URI again in ${delayMs}ms`); await timer(delayMs); } @@ -225,6 +233,12 @@ class StreamSwitcher { let currLiveCounts = 0; let currVodSegments = null; let eventSegments = null; + + let liveAudioSegments = null; + let currVodAudioSegments = null; + let eventAudioSegments = null; + + let liveUri = null; switch (state) { @@ -234,19 +248,29 @@ class StreamSwitcher { this.eventId = scheduleObj.eventId; currVodCounts = await session.getCurrentMediaAndDiscSequenceCount(); currVodSegments = await session.getCurrentMediaSequenceSegments({ targetMseq: currVodCounts.vodMediaSeqVideo }); + if (this.useDemuxedAudio) { + currVodAudioSegments = await session.getCurrentAudioSequenceSegments({ targetMseq: currVodCounts.vodMediaSeqAudio }); + } // Insert preroll if available for current channel - if (this.prerollsCache[this.sessionId]) { + if (this.prerollsCache[this.sessionId] && this.prerollsCache[this.sessionId].isValid) { const prerollSegments = this.prerollsCache[this.sessionId].segments; this._insertTimedMetadata(prerollSegments, scheduleObj.timedMetadata || {}); currVodSegments = this._mergeSegments(prerollSegments, currVodSegments, false); + if (this.useDemuxedAudio) { + + const prerollAudioSegments = this.prerollsCache[this.sessionId].audioSegments; + this._insertTimedMetadataAudio(prerollAudioSegments, scheduleObj.timedMetadata || {}); + currVodAudioSegments = this._mergeAudioSegments(prerollAudioSegments, currVodAudioSegments, false); + } } // In risk that the SL-playhead might have updated some data after // we reset last time... we should Reset SessionLive before sending new data. await sessionLive.resetLiveStoreAsync(0); - await sessionLive.setCurrentMediaAndDiscSequenceCount(currVodCounts.mediaSeq, currVodCounts.discSeq); + await sessionLive.setCurrentMediaAndDiscSequenceCount(currVodCounts.mediaSeq, currVodCounts.discSeq, currVodCounts.audioSeq, currVodCounts.discSeqAudio); await sessionLive.setCurrentMediaSequenceSegments(currVodSegments); + await sessionLive.setCurrentAudioSequenceSegments(currVodAudioSegments); liveUri = await sessionLive.setLiveUri(scheduleObj.uri); if (!liveUri) { @@ -275,8 +299,10 @@ class StreamSwitcher { this.eventId = scheduleObj.eventId; currVodCounts = await session.getCurrentMediaAndDiscSequenceCount(); eventSegments = await session.getTruncatedVodSegments(scheduleObj.uri, scheduleObj.duration / 1000); + eventAudioSegments = await session.getTruncatedVodAudioSegments(scheduleObj.uri, scheduleObj.duration / 1000); - if (!eventSegments) { + + if (!eventSegments || (this.useDemuxedAudio && !eventAudioSegments)) { debug(`[${this.sessionId}]: [ ERROR Switching from V2L->VOD ]`); this.working = false; this.eventId = null; @@ -284,13 +310,17 @@ class StreamSwitcher { } // Insert preroll if available for current channel - if (this.prerollsCache[this.sessionId]) { + if (this.prerollsCache[this.sessionId] && this.prerollsCache[this.sessionId].isValid) { const prerollSegments = this.prerollsCache[this.sessionId].segments; eventSegments = this._mergeSegments(prerollSegments, eventSegments, true); + if (this.useDemuxedAudio) { + const prerollAudioSegments = this.prerollsCache[this.sessionId].audioSegments; + eventAudioSegments = this._mergeAudioSegments(prerollAudioSegments, eventAudioSegments, true); + } } - await session.setCurrentMediaAndDiscSequenceCount(currVodCounts.mediaSeq, currVodCounts.discSeq); - await session.setCurrentMediaSequenceSegments(eventSegments, 0, true); + await session.setCurrentMediaAndDiscSequenceCount(currVodCounts.mediaSeq, currVodCounts.discSeq, currVodCounts.audioSeq, currVodCounts.audioDiscSeq); + await session.setCurrentMediaSequenceSegments(eventSegments, 0, true, eventAudioSegments, 0); this.working = false; debug(`[${this.sessionId}]: [ Switched from V2L->VOD ]`); @@ -307,32 +337,40 @@ class StreamSwitcher { debug(`[${this.sessionId}]: [ INIT Switching from LIVE->V2L ]`); this.eventId = null; liveSegments = await sessionLive.getCurrentMediaSequenceSegments(); + if (this.useDemuxedAudio) { + liveAudioSegments = await sessionLive.getCurrentAudioSequenceSegments(); + } liveCounts = await sessionLive.getCurrentMediaAndDiscSequenceCount(); - if (scheduleObj && !scheduleObj.duration) { debug(`[${this.sessionId}]: Cannot switch VOD. No duration specified for schedule item: [${scheduleObj.assetId}]`); } - - if (this._isEmpty(liveSegments.currMseqSegs)) { + if (this._isEmpty(liveSegments.currMseqSegs) || (this.useDemuxedAudio && this._isEmpty(liveAudioSegments.currMseqSegs))) { this.working = false; this.streamTypeLive = false; debug(`[${this.sessionId}]: [ Switched from LIVE->V2L ]`); return false; } - // Insert preroll, if available, for current channel - if (this.prerollsCache[this.sessionId]) { + if (this.prerollsCache[this.sessionId] && this.prerollsCache[this.sessionId].isValid) { const prerollSegments = this.prerollsCache[this.sessionId].segments; liveSegments.currMseqSegs = this._mergeSegments(prerollSegments, liveSegments.currMseqSegs, false); liveSegments.segCount += prerollSegments.length; + if (this.useDemuxedAudio) { + const prerollAudioSegments = this.prerollsCache[this.sessionId].audioSegments; + liveAudioSegments.currMseqSegs = this._mergeAudioSegments(prerollAudioSegments, liveAudioSegments.currMseqSegs, false); + liveAudioSegments.segCount += prerollAudioSegments.length; + } } - await session.setCurrentMediaAndDiscSequenceCount(liveCounts.mediaSeq, liveCounts.discSeq); - await session.setCurrentMediaSequenceSegments(liveSegments.currMseqSegs, liveSegments.segCount); + await session.setCurrentMediaAndDiscSequenceCount(liveCounts.mediaSeq, liveCounts.discSeq, liveCounts.audioSeq, liveCounts.audioDiscSeq); + if (this.useDemuxedAudio) { + await session.setCurrentMediaSequenceSegments(liveSegments.currMseqSegs, liveSegments.segCount, false, liveAudioSegments.currMseqSegs, liveAudioSegments.segCount); + } else { + await session.setCurrentMediaSequenceSegments(liveSegments.currMseqSegs, liveSegments.segCount, false); + } await sessionLive.resetSession(); sessionLive.resetLiveStoreAsync(RESET_DELAY); // In parallel - this.working = false; this.streamTypeLive = false; debug(`[${this.sessionId}]: [ Switched from LIVE->V2L ]`); @@ -341,7 +379,7 @@ class StreamSwitcher { this.streamTypeLive = false; this.working = false; this.eventId = null; - debug(`[${this.sessionId}]: [ ERROR Switching from LIVE->V2L ]`); + debug(`[${this.sessionId}]: [ ERROR Switching from LIVE->V2L ] ${err}`); throw new Error(err); } case SwitcherState.LIVE_TO_VOD: @@ -350,9 +388,12 @@ class StreamSwitcher { // TODO: Not yet fully tested/supported this.eventId = scheduleObj.eventId; liveSegments = await sessionLive.getCurrentMediaSequenceSegments(); + liveAudioSegments = await sessionLive.getCurrentAudioSequenceSegments(); liveCounts = await sessionLive.getCurrentMediaAndDiscSequenceCount(); - eventSegments = await session.getTruncatedVodSegments(scheduleObj.uri, scheduleObj.duration / 1000); + eventAudioSegments = await session.getTruncatedVodAudioSegments(scheduleObj.uri, scheduleObj.duration / 1000); + + if (!eventSegments) { debug(`[${this.sessionId}]: [ ERROR Switching from LIVE->VOD ]`); this.streamTypeLive = false; @@ -361,19 +402,28 @@ class StreamSwitcher { return false; } - await session.setCurrentMediaAndDiscSequenceCount(liveCounts.mediaSeq - 1, liveCounts.discSeq - 1); - await session.setCurrentMediaSequenceSegments(liveSegments.currMseqSegs, liveSegments.segCount); + await session.setCurrentMediaAndDiscSequenceCount(liveCounts.mediaSeq - 1, liveCounts.discSeq - 1, liveCounts.audioSeq - 1, liveCounts.audioDiscSeq - 1); + if (this.useDemuxedAudio) { + await session.setCurrentMediaSequenceSegments(liveSegments.currMseqSegs, liveSegments.segCount, false, liveAudioSegments.currMseqSegs, liveAudioSegments.segCount); + } else { + await session.setCurrentMediaSequenceSegments(liveSegments.currMseqSegs, liveSegments.segCount); + } // Insert preroll, if available, for current channel - if (this.prerollsCache[this.sessionId]) { + if (this.prerollsCache[this.sessionId] && this.prerollsCache[this.sessionId].isValid) { const prerollSegments = this.prerollsCache[this.sessionId].segments; eventSegments = this._mergeSegments(prerollSegments, eventSegments, true); + + if (this.useDemuxedAudio) { + const prerollAudioSegments = this.prerollsCache[this.sessionId].audioSegments; + eventAudioSegments = this._mergeAudioSegments(prerollAudioSegments, eventAudioSegments, true); + } } await session.setCurrentMediaSequenceSegments(eventSegments, 0, true); await sessionLive.resetSession(); sessionLive.resetLiveStoreAsync(RESET_DELAY); // In parallel - + this.working = false; this.streamTypeLive = false; debug(`[${this.sessionId}]: Switched from LIVE->VOD`); @@ -391,20 +441,31 @@ class StreamSwitcher { // TODO: Not yet fully tested/supported this.eventId = scheduleObj.eventId; eventSegments = await sessionLive.getCurrentMediaSequenceSegments(); + eventAudioSegments = await sessionLive.getCurrentAudioSequenceSegments(); currLiveCounts = await sessionLive.getCurrentMediaAndDiscSequenceCount(); await sessionLive.resetSession(); await sessionLive.resetLiveStoreAsync(0); // Insert preroll, if available, for current channel - if (this.prerollsCache[this.sessionId]) { + if (this.prerollsCache[this.sessionId] && this.prerollsCache[this.sessionId].isValid) { const prerollSegments = this.prerollsCache[this.sessionId].segments; this._insertTimedMetadata(prerollSegments, scheduleObj.timedMetadata || {}); eventSegments.currMseqSegs = this._mergeSegments(prerollSegments, eventSegments.currMseqSegs, false); + + if (this.useDemuxedAudio) { + const prerollSegmentsAudio = this.prerollsCache[this.sessionId].audioSegments; + this._insertTimedMetadataAudio(prerollSegmentsAudio, scheduleObj.timedMetadata || {}); + eventSegments.currMseqSegs = this._mergeAudioSegments(prerollSegmentsAudio, eventAudioSegments.currMseqSegs, false); + } } - await sessionLive.setCurrentMediaAndDiscSequenceCount(currLiveCounts.mediaSeq, currLiveCounts.discSeq); + const faild = await sessionLive.setCurrentMediaAndDiscSequenceCount(currLiveCounts.mediaSeq, currLiveCounts.discSeq, currLiveCounts.audioSeq, currLiveCounts.audioDiscSeq); + if (!faild) { + console.error("cound not set switch live-> live", currVodCounts.mediaSeq, currVodCounts.discSeq, currVodCounts.audioSeq, currVodCounts.audioDiscSeq) + } await sessionLive.setCurrentMediaSequenceSegments(eventSegments.currMseqSegs); + await sessionLive.setCurrentAudioSequenceSegments(eventAudioSegments.currMseqSegs); liveUri = await sessionLive.setLiveUri(scheduleObj.uri); if (!liveUri) { @@ -447,6 +508,9 @@ class StreamSwitcher { } async _validURI(uri) { + if (!uri) { + return false; + } const controller = new AbortController(); const timeout = setTimeout(() => { debug(`[${this.sessionId}]: Request Timeout @ ${uri}`); @@ -470,8 +534,11 @@ class StreamSwitcher { async _loadPreroll(uri) { const prerollSegments = {}; + const prerollSegmentsAudio = {}; const mediaM3UPlaylists = {}; const mediaURIs = {}; + const audioURIs = {}; + const audioM3UPlaylists = {}; try { const m3u = await this._fetchParseM3u8(uri); debug(`[${this.sessionId}]: ...Fetched a New Preroll Slate Master Manifest from:\n${uri}`); @@ -481,21 +548,69 @@ class StreamSwitcher { for (let i = 0; i < m3u.items.StreamItem.length; i++) { const streamItem = m3u.items.StreamItem[i]; const bw = streamItem.get("bandwidth"); + + const mediaUri = streamItem.get("uri"); if (mediaUri.match("^http")) { mediaURIs[bw] = mediaUri; } else { mediaURIs[bw] = new URL(mediaUri, uri).href; } + + if (streamItem.get("audio")) { + const audioGroupId = streamItem.get("audio") + audioURIs[audioGroupId] = {}; + let audioGroupItems = m3u.items.MediaItem.filter((item) => { + return item.get("type") === "AUDIO" && item.get("group-id") === audioGroupId; + }); + let audioLanguages = audioGroupItems.map((item) => { + let itemLang; + if (!item.get("language")) { + itemLang = item.get("name"); + } else { + itemLang = item.get("language"); + } + audioURIs[audioGroupId][itemLang] = []; + return itemLang; + }); + for (let j = 0; j < audioGroupItems.length; j++) { + const mediaUri = audioGroupItems[j].get("uri"); + if (mediaUri.match("^http")) { + audioURIs[audioGroupId][audioLanguages[j]] = mediaUri; + } else { + audioURIs[audioGroupId][audioLanguages[j]] = new URL(mediaUri, uri).href; + } + } + } + } + + if (this.useDemuxedAudio && !audioURIs) { + throw new Error("Preroll is not demuxed"); } // Fetch and parse Media URIs const bandwidths = Object.keys(mediaURIs); const loadMediaPromises = []; + const loadAudioPromises = []; // Queue up... - bandwidths.forEach((bw) => loadMediaPromises.push(this._fetchParseM3u8(mediaURIs[bw]))); + bandwidths.forEach( + (bw) => loadMediaPromises.push( + this._fetchParseM3u8(mediaURIs[bw]) + )); + if (this.useDemuxedAudio) { + const groupIds = Object.keys(audioURIs); + for (let i = 0; i < groupIds.length; i++) { + const groupId = groupIds[i]; + const langs = Object.keys(audioURIs[groupId]); + for (let j = 0; j < langs.length; j++) { + const lang = langs[j]; + loadAudioPromises.push(this._fetchParseM3u8(audioURIs[groupId][lang])); + } + } + } // Execute... const results = await Promise.allSettled(loadMediaPromises); + const resultsAudio = await Promise.allSettled(loadAudioPromises); // Process... results.forEach((item, idx) => { if (item.status === "fulfilled" && item.value) { @@ -504,6 +619,18 @@ class StreamSwitcher { mediaM3UPlaylists[bw] = resultM3U.items.PlaylistItem; } }); + + if (resultsAudio) { + resultsAudio.forEach((item, idx) => { + const resultM3U = item.value; + const indexes = this._getGroupAndLangIdxFromIdx(idx, audioURIs) + if (!audioM3UPlaylists[indexes.groupId]) { + audioM3UPlaylists[indexes.groupId] = {}; + } + audioM3UPlaylists[indexes.groupId][indexes.lang] = resultM3U.items.PlaylistItem; + }); + } + } else if (m3u.items.PlaylistItem.length > 0) { // Process the Media M3U. const arbitraryBw = 1; @@ -520,81 +647,163 @@ class StreamSwitcher { if (!prerollSegments[bw]) { prerollSegments[bw] = []; } - for (let k = 0; k < mediaM3UPlaylists[bw].length; k++) { - let seg = {}; - let playlistItem = mediaM3UPlaylists[bw][k]; - let segmentUri; - let cueData = null; - let daterangeData = null; - let attributes = playlistItem["attributes"].attributes; - if (playlistItem.properties.discontinuity) { - prerollSegments[bw].push({ discontinuity: true }); - } - if ("cuein" in attributes) { - if (!cueData) { - cueData = {}; + prerollSegments[bw] = this._createCustomSimpleSegmentList(mediaM3UPlaylists[bw], mediaURIs[bw]); + } + if (this.useDemuxedAudio) { + const groupIds = Object.keys(audioM3UPlaylists); + for (let i = 0; i < groupIds.length; i++) { + const groupId = groupIds[i]; + const langs = Object.keys(audioM3UPlaylists[groupId]); + for (let j = 0; j < langs.length; j++) { + const lang = langs[j]; + if (!prerollSegmentsAudio[groupId]) { + prerollSegmentsAudio[groupId] = {}; } - cueData["in"] = true; - } - if ("cueout" in attributes) { - if (!cueData) { - cueData = {}; + if (!prerollSegmentsAudio[groupId][lang]) { + prerollSegmentsAudio[groupId][lang] = []; } - cueData["out"] = true; - cueData["duration"] = attributes["cueout"]; + prerollSegmentsAudio[groupId][lang] = this._createCustomSimpleSegmentList(audioM3UPlaylists[groupId][lang], audioURIs[groupId][lang]); } - if ("cuecont" in attributes) { - if (!cueData) { - cueData = {}; - } - cueData["cont"] = true; + } + } + debug(`[${this.sessionId}]: Loaded all Variants of the Preroll Slate!`); + return { mediaSegments: prerollSegments, audioSegments: prerollSegmentsAudio }; + } catch (err) { + throw new Error(err); + } + } + + _createCustomSimpleSegmentList(segmentList, manifestURI) { + let segments = []; + for (let k = 0; k < segmentList.length; k++) { + try { + let seg = {}; + const playlistItem = segmentList[k]; + let segmentUri; + let byteRange = undefined; + let initSegment = undefined; + let initSegmentByteRange = undefined; + let keys = undefined; + let daterangeData = null; + let attributes = playlistItem["attributes"].attributes; + if (playlistItem.get("map-uri")) { + initSegmentByteRange = playlistItem.get("map-byterange"); + if (playlistItem.get("map-uri").match("^http")) { + initSegment = playlistItem.get("map-uri"); + } else { + initSegment = new URL(playlistItem.get("map-uri"), manifestURI).href; } - if ("scteData" in attributes) { - if (!cueData) { - cueData = {}; - } - cueData["scteData"] = attributes["scteData"]; + } + // some items such as CUE-IN parse as a PlaylistItem + // but have no URI + if (playlistItem.get("uri")) { + if (playlistItem.get("uri").match("^http")) { + segmentUri = playlistItem.get("uri"); + } else { + segmentUri = new URL(playlistItem.get("uri"), manifestURI).href; } - if ("assetData" in attributes) { - if (!cueData) { - cueData = {}; - } - cueData["assetData"] = attributes["assetData"]; + } + if (playlistItem.get("discontinuity")) { + segments.push({ discontinuity: true }); + } + if (playlistItem.get("byteRange")) { + let [_, r, o] = playlistItem.get("byteRange").match(/^(\d+)@*(\d*)$/); + if (!o) { + o = byteRangeOffset; } - if ("daterange" in attributes) { - if (!daterangeData) { - daterangeData = {}; - } - let allDaterangeAttributes = Object.keys(attributes["daterange"]); - allDaterangeAttributes.forEach((attr) => { - if (attr.match(/DURATION$/)) { - daterangeData[attr.toLowerCase()] = parseFloat(attributes["daterange"][attr]); - } else { - daterangeData[attr.toLowerCase()] = attributes["daterange"][attr]; + byteRangeOffset = parseInt(r) + parseInt(o); + byteRange = `${r}@${o}`; + } + if (playlistItem.get("keys")) { + keys = playlistItem.get("keys"); + } + let assetData = playlistItem.get("assetdata"); + let cueOut = playlistItem.get("cueout"); + let cueIn = playlistItem.get("cuein"); + let cueOutCont = playlistItem.get("cont-offset"); + let duration = 0; + let scteData = playlistItem.get("sctedata"); + if (typeof cueOut !== "undefined") { + duration = cueOut; + } else if (typeof cueOutCont !== "undefined") { + duration = playlistItem.get("cont-dur"); + } + let cue = + cueOut || cueIn || cueOutCont || assetData + ? { + out: typeof cueOut !== "undefined", + cont: typeof cueOutCont !== "undefined" ? cueOutCont : null, + scteData: typeof scteData !== "undefined" ? scteData : null, + in: cueIn ? true : false, + duration: duration, + assetData: typeof assetData !== "undefined" ? assetData : null, } - }); + : null; + seg = { + duration: playlistItem.get("duration"), + timelinePosition: this.timeOffset != null ? this.timeOffset + timelinePosition : null, + cue: cue, + byteRange: byteRange, + }; + if (initSegment) { + seg.initSegment = initSegment; + } + if (initSegmentByteRange) { + seg.initSegmentByteRange = initSegmentByteRange; + } + if (segmentUri) { + seg.uri = segmentUri; + } + if (keys) { + seg.keys = keys; + } + if (segments.length === 0) { + // Add daterange metadata if this is the first segment + if (this.rangeMetadata && !this._isEmpty(this.rangeMetadata)) { + seg["daterange"] = this.rangeMetadata; } - if (playlistItem.properties.uri) { - if (playlistItem.properties.uri.match("^http")) { - segmentUri = playlistItem.properties.uri; + } + if ("daterange" in attributes) { + if (!daterangeData) { + daterangeData = {}; + } + let allDaterangeAttributes = Object.keys(attributes["daterange"]); + allDaterangeAttributes.forEach((attr) => { + if (attr.match(/DURATION$/)) { + daterangeData[attr.toLowerCase()] = parseFloat(attributes["daterange"][attr]); } else { - segmentUri = new URL(playlistItem.properties.uri, mediaURIs[bw]).href; - } - seg["duration"] = playlistItem.properties.duration; - seg["uri"] = segmentUri; - seg["cue"] = cueData; - if (daterangeData) { - seg["daterange"] = daterangeData; + daterangeData[attr.toLowerCase()] = attributes["daterange"][attr]; } + }); + } + if (playlistItem.properties.uri) { + if (daterangeData && !this._isEmpty(this.daterangeData)) { + seg["daterange"] = daterangeData; } - prerollSegments[bw].push(seg); } + segments.push(seg); + } catch (e) { + console.error(e); + } + } + return segments + } + + _getGroupAndLangIdxFromIdx(idx, audioObject) { + const startIdx = 0; + let answerFound = false; + let storedLength = 0; + while (!answerFound) { + let groupIds = Object.keys(audioObject); + let langs = Object.keys(audioObject[groupIds[startIdx]]); + if (langs.length + storedLength > idx) { + answerFound = true + } else { + storedLength = langs.length; + startIdx++; } - debug(`[${this.sessionId}]: Loaded all Variants of the Preroll Slate!`); - return prerollSegments; - } catch (err) { - throw new Error(err); } + return { groupId: groupIds[startIdx], lang: langs[idx - storedLength] } } // Input: hls vod uri. Output: an M3U object. @@ -629,7 +838,7 @@ class StreamSwitcher { } else { const lastSeg = toSegments[bw][toSegments[bw].length - 1]; if (lastSeg.uri && !lastSeg.discontinuity) { - toSegments[bw].push({ discontinuity: true, cue: { in: true } }); + toSegments[bw].push({ discontinuity: true}); OUTPUT_SEGMENTS[bw] = toSegments[bw].concat(fromSegments[targetBw]); } else if (lastSeg.discontinuity && !lastSeg.cue) { toSegments[bw][toSegments[bw].length - 1].cue = { in: true } @@ -643,6 +852,50 @@ class StreamSwitcher { return OUTPUT_SEGMENTS; } + _mergeAudioSegments(fromSegments, toSegments, prepend) { + const OUTPUT_SEGMENTS = {}; + const fromGroups = Object.keys(fromSegments); + const toGroups = Object.keys(toSegments); + + for (let i = 0; i < toGroups.length; i++) { + const groupId = toGroups[i]; + if (!OUTPUT_SEGMENTS[groupId]) { + OUTPUT_SEGMENTS[groupId] = {} + } + const toLangs = Object.keys(toSegments[groupId]) + + for (let j = 0; j < toLangs.length; j++) { + const lang = toLangs[j]; + if (!OUTPUT_SEGMENTS[groupId][lang]) { + OUTPUT_SEGMENTS[groupId][lang] = []; + } + + const targetGroupId = findAudioGroupOrLang(groupId, fromGroups); + const fromLangs = Object.keys(fromSegments[targetGroupId]); + const targetLang = findAudioGroupOrLang(lang, fromLangs); + if (prepend) { + OUTPUT_SEGMENTS[groupId][lang] = fromSegments[targetGroupId][targetLang].concat(toSegments[groupId][lang]); + OUTPUT_SEGMENTS[groupId][lang].unshift({ discontinuity: true }); + } else { + const size = toSegments[groupId][lang].length; + const lastSeg = toSegments[groupId][lang][size - 1]; + if (lastSeg.uri && !lastSeg.discontinuity) { + toSegments[groupId][lang].push({ discontinuity: true }); + OUTPUT_SEGMENTS[groupId][lang] = toSegments[groupId][lang].concat(fromSegments[targetGroupId][targetLang]); + } else if (lastSeg.discontinuity && !lastSeg.cue) { + console.log("lastSeg", lastSeg, toSegments[groupId][lang], 666) + toSegments[targetGroupId][lang][toSegments[groupId][lang].length - 1].cue = { in: true } + OUTPUT_SEGMENTS[groupId][lang] = toSegments[groupId][lang].concat(fromSegments[targetGroupId][targetLang]); + } else { + OUTPUT_SEGMENTS[groupId][lang] = toSegments[groupId][lang].concat(fromSegments[targetGroupId][targetLang]); + OUTPUT_SEGMENTS[groupId][lang].push({ discontinuity: true }); + } + } + } + }; + return OUTPUT_SEGMENTS; + } + _insertTimedMetadata(segments, timedMetadata) { const bandwidths = Object.keys(segments); debug(`[${this.sessionId}]: Inserting timed metadata ${Object.keys(timedMetadata).join(',')}`); @@ -654,9 +907,34 @@ class StreamSwitcher { daterangeData[k] = timedMetadata[k]; }); } - segments[bw][0]["daterange"] = daterangeData; + if (Object.keys(daterangeData).length > 0) { + segments[bw][0]["daterange"] = daterangeData; + } }); } + + _insertTimedMetadataAudio(segments, timedMetadata) { + const groupIds = Object.keys(segments); + debug(`[${this.sessionId}]: Inserting timed metadata ${Object.keys(timedMetadata).join(',')}`); + for (let i = 0; i < groupIds.length; i++) { + const groupId = groupIds[i]; + const langs = Object.keys(segments[groupId]); + for (let j = 0; j < langs.length; j++) { + const lang = langs[j]; + let daterangeData = segments[groupId][lang][0]["daterange"]; + if (!daterangeData) { + daterangeData = {}; + Object.keys(timedMetadata).forEach((k) => { + daterangeData[k] = timedMetadata[k]; + }); + } + if (Object.keys(daterangeData).length > 0) { + segments[groupId][lang][0]["daterange"] = daterangeData; + } + } + + } + } } module.exports = StreamSwitcher; diff --git a/engine/util.js b/engine/util.js index 8a4ac4f..cddfa5c 100644 --- a/engine/util.js +++ b/engine/util.js @@ -158,6 +158,14 @@ const findNearestValue = (val, array) => { return Math.abs(b - val) < Math.abs(a - val) ? b : a; }); }; +const findAudioGroupOrLang = (val, array) => { + for(let i = 0; i < array.length; i++) { + if (array[i] === val) { + return val + } + } + return array[0]; +}; const isValidUrl = (url) => { try { @@ -251,9 +259,11 @@ module.exports = { logerror, timer, WaitTimeGenerator, + ItemIsEmpty, findNearestValue, isValidUrl, fetchWithRetry, codecsFromString, timeLeft, + findAudioGroupOrLang, }; diff --git a/examples/demux.ts b/examples/demux.ts index 4c85a03..a59ef56 100644 --- a/examples/demux.ts +++ b/examples/demux.ts @@ -24,17 +24,29 @@ class RefAssetManager implements IAssetManager { { id: 1, title: "Elephants dream", - uri: "https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/index.m3u8", + uri: "https://mtoczko.github.io/hls-test-streams/test-audio-pdt/playlist.m3u8", }, + + ], + 2: [ { id: 2, - title: "Test HLS Bird noises (1m10s)", - uri: "https://mtoczko.github.io/hls-test-streams/test-audio-pdt/playlist.m3u8", + title: "DEV DEMUX ASSET ts but perfect match langs", + uri: "https://trailer-admin-cdn.a2d.tv/virtualchannels/dev_asset_001/demux/demux2.2.m3u8", + }, + ], + 3: [ + { + id: 3, + title: "DEV DEMUX ASSET ts but has 3 langs not 2", + uri: "https://trailer-admin-cdn.a2d-dev.tv/demux/asset_001/master_720360enspde.m3u8", }, ], }; this.pos = { 1: 0, + 2: 0, + 3: 0, }; } @@ -65,7 +77,14 @@ class RefAssetManager implements IAssetManager { class RefChannelManager implements IChannelManager { getChannels(): Channel[] { - return [{ id: "1", profile: this._getProfile(), audioTracks: this._getAudioTracks(), subtitleTracks: this._getSubtitleTracks() }]; + return [ + //{ id: "1", profile: this._getProfile(), audioTracks: this._getAudioTracks(), subtitleTracks: this._getSubtitleTracks() }, + //{ id: "2", profile: this._getProfile(), audioTracks: this._getAudioTracks(), subtitleTracks: this._getSubtitleTracks() }, + { id: "3", profile: this._getProfile(), audioTracks: [ + { language: "en", name: "English", default: true }, + { language: "sp", name: "Spanish", default: false }, + { language: "de", name: "German", default: false }, + ], subtitleTracks: this._getSubtitleTracks() }]; } _getProfile(): ChannelProfile[] { @@ -94,8 +113,8 @@ class RefChannelManager implements IChannelManager { } _getSubtitleTracks(): SubtitleTracks[] { return [ - { language: "zh", name: "chinese", default: true }, - { language: "fr", name: "french", default: false } + // { language: "zh", name: "chinese", default: true }, + // { language: "fr", name: "french", default: false } ]; } } @@ -118,4 +137,4 @@ const engineOptions: ChannelEngineOpts = { const engine = new ChannelEngine(refAssetManager, engineOptions); engine.start(); -engine.listen(process.env.PORT || 8000); +engine.listen(process.env.PORT || 5000); diff --git a/examples/livemix-demux.ts b/examples/livemix-demux.ts new file mode 100644 index 0000000..5b2c4ae --- /dev/null +++ b/examples/livemix-demux.ts @@ -0,0 +1,215 @@ +/* + * Reference implementation of Channel Engine library + */ + +import { ChannelEngine, ChannelEngineOpts, + IAssetManager, IChannelManager, IStreamSwitchManager, + VodRequest, VodResponse, Channel, ChannelProfile, + Schedule, AudioTracks + } from "../index"; + const { v4: uuidv4 } = require('uuid'); + + const DEMUX_CONTENT = { + TS: { + slate: "https://testcontent.eyevinn.technology/ce_test_content/DEMUX_VINJETTE_TS_001/master.m3u8", + vod: "https://testcontent.eyevinn.technology/ce_test_content/DEMUX_VOD_TS_001/master.m3u8", + trailer: "https://testcontent.eyevinn.technology/ce_test_content/DEMUX_TRAILER_TS_001/master.m3u8", + bumper: "https://testcontent.eyevinn.technology/ce_test_content/DEMUX_VINJETTE_TS_001/master.m3u8", + live: null // If you do not have a demux live stream available, you can always use a local CE V2L stream (ex: demux.ts) + }, + CMAF: { + slate: "https://testcontent.eyevinn.technology/ce_test_content/DEMUX_DEMO_FILLER_CMAF_001/master.m3u8", + vod: "https://testcontent.eyevinn.technology/ce_test_content/DEMUX_DEMO_VOD_CMAF_001/master_en.m3u8", // 5 min + trailer: "https://testcontent.eyevinn.technology/ce_test_content/DEMUX_TRAILER_CMAF_001/master.m3u8", + bumper: "https://testcontent.eyevinn.technology/ce_test_content/DEMUX_VINJETTE_CMAF_001/master.m3u8", + live: null + } + }; + + const HLS_CONTENT = DEMUX_CONTENT.CMAF; + + const STITCH_ENDPOINT = process.env.STITCH_ENDPOINT || "https://eyevinn-guide.eyevinn-lambda-stitch.auto.prod.osaas.io/stitch/master.m3u8"; + + class RefAssetManager implements IAssetManager { + private assets; + private pos; + constructor(opts?) { + this.assets = { + '1': [ + { id: 1, title: "Untitled VOD", uri: HLS_CONTENT.vod }, + ] + }; + this.pos = { + '1': 0 + }; + } + + /* @param {Object} vodRequest + * { + * sessionId, + * category, + * playlistId + * } + */ + getNextVod(vodRequest: VodRequest): Promise { + return new Promise((resolve, reject) => { + const channelId = vodRequest.playlistId; + if (this.assets[channelId]) { + let vod = this.assets[channelId][this.pos[channelId]++]; + if (this.pos[channelId] > this.assets[channelId].length - 1) { + this.pos[channelId] = 0; + } + const payload = { + uri: vod.uri, + breaks: [ + { + pos: 0, + duration: 30 * 1000, + url: HLS_CONTENT.trailer + } + ] + }; + const buff = Buffer.from(JSON.stringify(payload)); + const encodedPayload = buff.toString("base64"); + const vodResponse = { + id: vod.id, + title: vod.title, + uri: STITCH_ENDPOINT + "?payload=" + encodedPayload + }; + resolve(vodResponse); + } else { + reject("Invalid channelId provided"); + } + }); + } + + handleError(err, vodResponse) { + console.error(err.message); + } + } + + class RefChannelManager implements IChannelManager { + private channels; + constructor(opts?) { + this.channels = []; + if (process.env.TEST_CHANNELS) { + const testChannelsCount = parseInt(process.env.TEST_CHANNELS, 10); + for (let i = 0; i < testChannelsCount; i++) { + this.channels.push({ id: `${i + 1}`, profile: this._getProfile(), audioTracks: this._getAudioTracks() }); + } + } else { + this.channels = [{ id: "1", profile: this._getProfile(), audioTracks: this._getAudioTracks() }]; + } + } + + getChannels(): Channel[] { + return this.channels; + } + + _getProfile(): ChannelProfile[] { + return [ + { bw: 8242000, codecs: 'avc1.4d001f,mp4a.40.2', resolution: [1024, 458] }, + { bw: 1274000, codecs: 'avc1.4d001f,mp4a.40.2', resolution: [640, 286] }, + { bw: 742000, codecs: 'avc1.4d001f,mp4a.40.2', resolution: [480, 214] }, + ] + } + + _getAudioTracks(): AudioTracks[] { + return [ + { language: "en", name: "English", default: true }, + ]; + } + } + const StreamType = Object.freeze({ + LIVE: 1, + VOD: 2, + }); + + class StreamSwitchManager implements IStreamSwitchManager { + private schedule; + constructor() { + this.schedule = {}; + if (process.env.TEST_CHANNELS) { + const testChannelsCount = parseInt(process.env.TEST_CHANNELS, 10); + for (let i = 0; i < testChannelsCount; i++) { + const channelId = `${i + 1}`; + this.schedule[channelId] = []; + } + } else { + this.schedule = { + '1': [] + }; + } + } + + generateID(): string { + return uuidv4(); + } + + getPrerollUri(channelId): Promise { + const defaultPrerollSlateUri = HLS_CONTENT.bumper + return new Promise((resolve, reject) => { resolve(defaultPrerollSlateUri); }); + } + + getSchedule(channelId): Promise { + return new Promise((resolve, reject) => { + if (this.schedule[channelId]) { + const tsNow = Date.now(); + const streamDuration = 2 * 60 * 1000; + const startOffset = tsNow + streamDuration; + const endTime = startOffset + streamDuration; + this.schedule[channelId] = this.schedule[channelId].filter((obj) => obj.end_time >= tsNow); + if (this.schedule[channelId].length === 0 && HLS_CONTENT.live) { + this.schedule[channelId].push({ + eventId: this.generateID(), + assetId: this.generateID(), + title: "Live stream test", + type: StreamType.LIVE, + start_time: startOffset, + end_time: endTime, + uri: HLS_CONTENT.live, + } + /*, + { + eventId: this.generateID(), + assetId: this.generateID(), + title: "Scheduled VOD test", + type: StreamType.VOD, + start_time: (endTime + 100*1000), + end_time: (endTime + 100*1000) + streamDuration, + uri: "https://maitv-vod.lab.eyevinn.technology/COME_TO_DADDY_Trailer_2020.mp4/master.m3u8", + duration: streamDuration, + }*/ + ); + } + resolve(this.schedule[channelId]); + } else { + reject("Invalid channelId provided"); + } + }); + } + } + + const refAssetManager = new RefAssetManager(); + const refChannelManager = new RefChannelManager(); + const refStreamSwitchManager = new StreamSwitchManager(); + + const engineOptions: ChannelEngineOpts = { + heartbeat: "/", + averageSegmentDuration: 6000, + channelManager: refChannelManager, + streamSwitchManager: refStreamSwitchManager, + defaultSlateUri: HLS_CONTENT.slate, + slateRepetitions: 10, + slateDuration: 3000, + redisUrl: process.env.REDIS_URL, + useDemuxedAudio: true, + playheadDiffThreshold: 500, + alwaysNewSegments: true, + maxTickInterval: 8000, + }; + + const engine = new ChannelEngine(refAssetManager, engineOptions); + engine.start(); + engine.listen(process.env.PORT || 8000); + \ No newline at end of file diff --git a/examples/livemix.ts b/examples/livemix.ts index d578064..0602d1a 100644 --- a/examples/livemix.ts +++ b/examples/livemix.ts @@ -9,7 +9,7 @@ import { ChannelEngine, ChannelEngineOpts, } from "../index"; const { v4: uuidv4 } = require('uuid'); -const STITCH_ENDPOINT = process.env.STITCH_ENDPOINT || "http://lambda.eyevinn.technology/stitch/master.m3u8"; +const STITCH_ENDPOINT = process.env.STITCH_ENDPOINT || "https://eyevinn-guide.eyevinn-lambda-stitch.auto.prod.osaas.io/stitch/master.m3u8"; class RefAssetManager implements IAssetManager { private assets; private pos; @@ -205,4 +205,4 @@ const engineOptions: ChannelEngineOpts = { const engine = new ChannelEngine(refAssetManager, engineOptions); engine.start(); -engine.listen(process.env.PORT || 8000); +engine.listen(process.env.PORT || 8000); \ No newline at end of file diff --git a/examples/multicodec.ts b/examples/multicodec.ts index e77c3ad..1174a71 100644 --- a/examples/multicodec.ts +++ b/examples/multicodec.ts @@ -35,6 +35,7 @@ class RefAssetManager implements IAssetManager { * playlistId * } */ + getNextVod(vodRequest: VodRequest): Promise { console.log(this.assets); return new Promise((resolve, reject) => { diff --git a/package.json b/package.json index d2c358a..c6c45a4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "postversion": "git push && git push --tags", "start": "node dist/examples/default.js", "start-demux": "node dist/examples/demux.js", - "start-livemix": "node dist/examples/livemix.js" + "start-livemix": "node dist/examples/livemix.js", + "start-livemix-demux": "node dist/examples/livemix-demux.js" }, "engines": { "node": ">=14 <20" @@ -31,7 +32,7 @@ "@eyevinn/hls-repeat": "^0.2.0", "@eyevinn/hls-truncate": "^0.3.0", "@eyevinn/hls-vodtolive": "^4.1.1", - "@eyevinn/m3u8": "^0.5.3", + "@eyevinn/m3u8": "^0.5.6", "abort-controller": "^3.0.0", "debug": "^3.2.7", "ioredis": "^5.3.2", diff --git a/spec/engine/stream_switcher_spec.js b/spec/engine/stream_switcher_spec.js index 4a7a337..17e9aad 100644 --- a/spec/engine/stream_switcher_spec.js +++ b/spec/engine/stream_switcher_spec.js @@ -12,37 +12,37 @@ const StreamType = Object.freeze({ const tsNow = Date.now(); class TestAssetManager { - constructor(opts, assets) { - this.assets = [ - { id: 1, title: "Tears of Steel", uri: "https://maitv-vod.lab.eyevinn.technology/tearsofsteel_4k.mov/master.m3u8" }, - { id: 2, title: "VINN", uri: "https://maitv-vod.lab.eyevinn.technology/VINN.mp4/master.m3u8" } - ]; - if (assets) { - this.assets = assets; - } - this.pos = 0; - this.doFail = false; - if (opts && opts.fail) { - this.doFail = true; - } - if (opts && opts.failOnIndex) { - this.failOnIndex = 1; - } + constructor(opts, assets) { + this.assets = [ + { id: 1, title: "Tears of Steel", uri: "https://maitv-vod.lab.eyevinn.technology/tearsofsteel_4k.mov/master.m3u8" }, + { id: 2, title: "VINN", uri: "https://maitv-vod.lab.eyevinn.technology/VINN.mp4/master.m3u8" } + ]; + if (assets) { + this.assets = assets; + } + this.pos = 0; + this.doFail = false; + if (opts && opts.fail) { + this.doFail = true; + } + if (opts && opts.failOnIndex) { + this.failOnIndex = 1; } + } - getNextVod(vodRequest) { - return new Promise((resolve, reject) => { - if (this.doFail || this.pos === this.failOnIndex) { - reject("should fail"); - } else { - const vod = this.assets[this.pos++]; - if (this.pos > this.assets.length - 1) { - this.pos = 0; - } - resolve(vod); + getNextVod(vodRequest) { + return new Promise((resolve, reject) => { + if (this.doFail || this.pos === this.failOnIndex) { + reject("should fail"); + } else { + const vod = this.assets[this.pos++]; + if (this.pos > this.assets.length - 1) { + this.pos = 0; } - }); - } + resolve(vod); + } + }); + } } const allListSchedules = [ @@ -64,7 +64,7 @@ const allListSchedules = [ start_time: tsNow + 20 * 1000, end_time: tsNow + 20 * 1000 + 1 * 60 * 1000, uri: "https://maitv-vod.lab.eyevinn.technology/MORBIUS_Trailer_2020.mp4/master.m3u8", - duration: 60*1000, + duration: 60 * 1000, } ], [ @@ -83,7 +83,7 @@ const allListSchedules = [ start_time: tsNow + 20 * 1000 + 1 * 60 * 1000, end_time: tsNow + 20 * 1000 + 1 * 60 * 1000 + 60 * 1000, uri: "https://maitv-vod.lab.eyevinn.technology/MORBIUS_Trailer_2020.mp4/master.m3u8", - duration: 60*1000, + duration: 60 * 1000, } ], [ @@ -112,7 +112,7 @@ const allListSchedules = [ start_time: tsNow + 20 * 1000, end_time: tsNow + 20 * 1000 + 1 * 60 * 1000, uri: "https://maitv-vod.lab.eyevinn.technology/MORBIUS_Trailer_2020.mp4/master.m3u8", - duration: 60*1000, + duration: 60 * 1000, }, { eventId: "live-4", @@ -131,7 +131,7 @@ const allListSchedules = [ start_time: tsNow + 20 * 1000, end_time: tsNow + 20 * 1000 + 1 * 60 * 1000, uri: "https://maitv-vod.lab.eyevinn.technology/MORBIUS_Trailer_2020.mp4/master.m3u8", - duration: 60*1000, + duration: 60 * 1000, }, { eventId: "vod-4", @@ -140,7 +140,7 @@ const allListSchedules = [ start_time: tsNow + 20 * 1000 + 1 * 60 * 1000, end_time: tsNow + 20 * 1000 + 1 * 60 * 1000 + 60 * 1000, uri: "https://maitv-vod.lab.eyevinn.technology/MORBIUS_Trailer_2020.mp4/master.m3u8", - duration: 60*1000, + duration: 60 * 1000, }, ], [ @@ -168,33 +168,33 @@ class TestSwitchManager { } const mockLiveSegments = { - "180000": [{duration: 7,uri: "http://mock.mock.com/180000/seg09.ts"}, - {duration: 7,uri: "http://mock.mock.com/180000/seg10.ts"}, - {duration: 7,uri: "http://mock.mock.com/180000/seg11.ts"}, - {duration: 7,uri: "http://mock.mock.com/180000/seg12.ts"}, - {duration: 7,uri: "http://mock.mock.com/180000/seg13.ts"}, - {duration: 7,uri: "http://mock.mock.com/180000/seg14.ts"}, - {duration: 7,uri: "http://mock.mock.com/180000/seg15.ts"}, - {duration: 7,uri: "http://mock.mock.com/180000/seg16.ts"}, - {discontinuity: true }], - "1258000": [{duration: 7,uri: "http://mock.mock.com/1258000/seg09.ts"}, - {duration: 7,uri: "http://mock.mock.com/1258000/seg10.ts"}, - {duration: 7,uri: "http://mock.mock.com/1258000/seg11.ts"}, - {duration: 7,uri: "http://mock.mock.com/1258000/seg12.ts"}, - {duration: 7,uri: "http://mock.mock.com/1258000/seg13.ts"}, - {duration: 7,uri: "http://mock.mock.com/1258000/seg14.ts"}, - {duration: 7,uri: "http://mock.mock.com/1258000/seg15.ts"}, - {duration: 7,uri: "http://mock.mock.com/1258000/seg16.ts"}, - {discontinuity: true }], - "2488000": [{duration: 7,uri: "http://mock.mock.com/2488000/seg09.ts"}, - {duration: 7,uri: "http://mock.mock.com/2488000/seg10.ts"}, - {duration: 7,uri: "http://mock.mock.com/2488000/seg11.ts"}, - {duration: 7,uri: "http://mock.mock.com/2488000/seg12.ts"}, - {duration: 7,uri: "http://mock.mock.com/2488000/seg13.ts"}, - {duration: 7,uri: "http://mock.mock.com/2488000/seg14.ts"}, - {duration: 7,uri: "http://mock.mock.com/2488000/seg15.ts"}, - {duration: 7,uri: "http://mock.mock.com/2488000/seg16.ts"}, - {discontinuity: true }] + "180000": [{ duration: 7, uri: "http://mock.mock.com/180000/seg09.ts" }, + { duration: 7, uri: "http://mock.mock.com/180000/seg10.ts" }, + { duration: 7, uri: "http://mock.mock.com/180000/seg11.ts" }, + { duration: 7, uri: "http://mock.mock.com/180000/seg12.ts" }, + { duration: 7, uri: "http://mock.mock.com/180000/seg13.ts" }, + { duration: 7, uri: "http://mock.mock.com/180000/seg14.ts" }, + { duration: 7, uri: "http://mock.mock.com/180000/seg15.ts" }, + { duration: 7, uri: "http://mock.mock.com/180000/seg16.ts" }, + { discontinuity: true }], + "1258000": [{ duration: 7, uri: "http://mock.mock.com/1258000/seg09.ts" }, + { duration: 7, uri: "http://mock.mock.com/1258000/seg10.ts" }, + { duration: 7, uri: "http://mock.mock.com/1258000/seg11.ts" }, + { duration: 7, uri: "http://mock.mock.com/1258000/seg12.ts" }, + { duration: 7, uri: "http://mock.mock.com/1258000/seg13.ts" }, + { duration: 7, uri: "http://mock.mock.com/1258000/seg14.ts" }, + { duration: 7, uri: "http://mock.mock.com/1258000/seg15.ts" }, + { duration: 7, uri: "http://mock.mock.com/1258000/seg16.ts" }, + { discontinuity: true }], + "2488000": [{ duration: 7, uri: "http://mock.mock.com/2488000/seg09.ts" }, + { duration: 7, uri: "http://mock.mock.com/2488000/seg10.ts" }, + { duration: 7, uri: "http://mock.mock.com/2488000/seg11.ts" }, + { duration: 7, uri: "http://mock.mock.com/2488000/seg12.ts" }, + { duration: 7, uri: "http://mock.mock.com/2488000/seg13.ts" }, + { duration: 7, uri: "http://mock.mock.com/2488000/seg14.ts" }, + { duration: 7, uri: "http://mock.mock.com/2488000/seg15.ts" }, + { duration: 7, uri: "http://mock.mock.com/2488000/seg16.ts" }, + { discontinuity: true }] }; describe("The Stream Switcher", () => { @@ -220,8 +220,8 @@ describe("The Stream Switcher", () => { it("should return false if no StreamSwitchManager was given.", async () => { const assetMgr = new TestAssetManager(); const testStreamSwitcher = new StreamSwitcher(); - const session = new Session(assetMgr, {sessionId: "1"}, sessionStore); - const sessionLive = new SessionLive({sessionId: "1"}, sessionLiveStore); + const session = new Session(assetMgr, { sessionId: "1" }, sessionStore); + const sessionLive = new SessionLive({ sessionId: "1" }, sessionLiveStore); await session.initAsync(); await session.incrementAsync(); @@ -233,12 +233,12 @@ describe("The Stream Switcher", () => { it("should validate uri and switch back to linear-vod (session) from event-livestream (sessionLive) if uri is unreachable", async () => { const switchMgr = new TestSwitchManager(0); - const testStreamSwitcher = new StreamSwitcher({streamSwitchManager: switchMgr}); + const testStreamSwitcher = new StreamSwitcher({ streamSwitchManager: switchMgr }); const assetMgr = new TestAssetManager(); - const session = new Session(assetMgr, {sessionId: "1"}, sessionStore); - const sessionLive = new SessionLive({sessionId: "1"}, sessionLiveStore); - spyOn(sessionLive, "resetLiveStoreAsync").and.callFake( () => true ); - spyOn(sessionLive, "resetSession").and.callFake( () => true ); + const session = new Session(assetMgr, { sessionId: "1" }, sessionStore); + const sessionLive = new SessionLive({ sessionId: "1" }, sessionLiveStore); + spyOn(sessionLive, "resetLiveStoreAsync").and.callFake(() => true); + spyOn(sessionLive, "resetSession").and.callFake(() => true); spyOn(sessionLive, "getCurrentMediaSequenceSegments").and.returnValue({ currMseqSegs: mockLiveSegments, segCount: 8 }); spyOn(session, "setCurrentMediaSequenceSegments").and.returnValue(true); spyOn(session, "setCurrentMediaAndDiscSequenceCount").and.returnValue(true); @@ -248,7 +248,7 @@ describe("The Stream Switcher", () => { await sessionLive.initAsync(); sessionLive.startPlayheadAsync(); - + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); expect(testStreamSwitcher.getEventId()).toBe(null); jasmine.clock().mockDate(tsNow); @@ -271,12 +271,12 @@ describe("The Stream Switcher", () => { it("should switch from linear-vod (session) to event-livestream (sessionLive) according to schedule", async () => { const switchMgr = new TestSwitchManager(0); - const testStreamSwitcher = new StreamSwitcher({streamSwitchManager: switchMgr}); + const testStreamSwitcher = new StreamSwitcher({ streamSwitchManager: switchMgr }); const assetMgr = new TestAssetManager(); - const session = new Session(assetMgr, {sessionId: "1"}, sessionStore); - const sessionLive = new SessionLive({sessionId: "1"}, sessionLiveStore); - spyOn(sessionLive, "resetLiveStoreAsync").and.callFake( () => true ); - spyOn(sessionLive, "resetSession").and.callFake( () => true ); + const session = new Session(assetMgr, { sessionId: "1" }, sessionStore); + const sessionLive = new SessionLive({ sessionId: "1" }, sessionLiveStore); + spyOn(sessionLive, "resetLiveStoreAsync").and.callFake(() => true); + spyOn(sessionLive, "resetSession").and.callFake(() => true); spyOn(sessionLive, "getCurrentMediaSequenceSegments").and.returnValue({ currMseqSegs: mockLiveSegments, segCount: 8 }); await session.initAsync(); @@ -294,12 +294,12 @@ describe("The Stream Switcher", () => { it("should switch from event-livestream (sessionLive) to linear-vod (session) according to schedule", async () => { const switchMgr = new TestSwitchManager(0); - const testStreamSwitcher = new StreamSwitcher({streamSwitchManager: switchMgr}); + const testStreamSwitcher = new StreamSwitcher({ streamSwitchManager: switchMgr }); const assetMgr = new TestAssetManager(); - const session = new Session(assetMgr, {sessionId: "1"}, sessionStore); - const sessionLive = new SessionLive({sessionId: "1"}, sessionLiveStore); - spyOn(sessionLive, "resetLiveStoreAsync").and.callFake( () => true ); - spyOn(sessionLive, "resetSession").and.callFake( () => true ); + const session = new Session(assetMgr, { sessionId: "1" }, sessionStore); + const sessionLive = new SessionLive({ sessionId: "1" }, sessionLiveStore); + spyOn(sessionLive, "resetLiveStoreAsync").and.callFake(() => true); + spyOn(sessionLive, "resetSession").and.callFake(() => true); spyOn(sessionLive, "getCurrentMediaSequenceSegments").and.returnValue({ currMseqSegs: mockLiveSegments, segCount: 8 }); spyOn(session, "setCurrentMediaSequenceSegments").and.returnValue(true); spyOn(session, "setCurrentMediaAndDiscSequenceCount").and.returnValue(true); @@ -320,9 +320,9 @@ describe("The Stream Switcher", () => { it("should switch from linear-vod (session) to event-vod (session) according to schedule", async () => { const switchMgr = new TestSwitchManager(1); - const testStreamSwitcher = new StreamSwitcher({streamSwitchManager: switchMgr}); + const testStreamSwitcher = new StreamSwitcher({ streamSwitchManager: switchMgr }); const assetMgr = new TestAssetManager(); - const session = new Session(assetMgr, {sessionId: "1"}, sessionStore); + const session = new Session(assetMgr, { sessionId: "1" }, sessionStore); spyOn(session, "setCurrentMediaSequenceSegments").and.returnValue(true); spyOn(session, "setCurrentMediaAndDiscSequenceCount").and.returnValue(true); @@ -340,9 +340,9 @@ describe("The Stream Switcher", () => { it("should switch from event-vod (session) to linear-vod (session) according to schedule", async () => { const switchMgr = new TestSwitchManager(1); - const testStreamSwitcher = new StreamSwitcher({streamSwitchManager: switchMgr}); + const testStreamSwitcher = new StreamSwitcher({ streamSwitchManager: switchMgr }); const assetMgr = new TestAssetManager(); - const session = new Session(assetMgr, {sessionId: "1"}, sessionStore); + const session = new Session(assetMgr, { sessionId: "1" }, sessionStore); spyOn(session, "setCurrentMediaSequenceSegments").and.returnValue(true); spyOn(session, "setCurrentMediaAndDiscSequenceCount").and.returnValue(true); @@ -360,9 +360,9 @@ describe("The Stream Switcher", () => { it("should not switch from linear-vod (session) to event-vod (session) if duration is not set in schedule", async () => { const switchMgr = new TestSwitchManager(6); - const testStreamSwitcher = new StreamSwitcher({streamSwitchManager: switchMgr}); + const testStreamSwitcher = new StreamSwitcher({ streamSwitchManager: switchMgr }); const assetMgr = new TestAssetManager(); - const session = new Session(assetMgr, {sessionId: "1"}, sessionStore); + const session = new Session(assetMgr, { sessionId: "1" }, sessionStore); await session.initAsync(); await session.incrementAsync(); @@ -374,113 +374,194 @@ describe("The Stream Switcher", () => { }); xit("should switch from linear-vod (session) to event-livestream (sessionLive) according to schedule. " + - "\nThen directly to event-vod (session) and finally switch back to linear-vod (session)", async () => { - const switchMgr = new TestSwitchManager(2); - const testStreamSwitcher = new StreamSwitcher({streamSwitchManager: switchMgr}); - const assetMgr = new TestAssetManager(); - const session = new Session(assetMgr, {sessionId: "1"}, sessionStore); - const sessionLive = new SessionLive({sessionId: "1"}, sessionLiveStore); - spyOn(sessionLive, "_loadAllMediaManifests").and.returnValue(mockLiveSegments); - - await session.initAsync(); - await session.incrementAsync(); - sessionLive.initAsync(); - - jasmine.clock().mockDate(tsNow); - jasmine.clock().tick((25 * 1000)); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(true); - expect(testStreamSwitcher.getEventId()).toBe("live-1"); - jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000))); - await sessionLive.getCurrentMediaManifestAsync(180000); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); - expect(testStreamSwitcher.getEventId()).toBe("vod-1"); - jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000)) + (60*1000)); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); - expect(testStreamSwitcher.getEventId()).toBe(null); - }); + "\nThen directly to event-vod (session) and finally switch back to linear-vod (session)", async () => { + const switchMgr = new TestSwitchManager(2); + const testStreamSwitcher = new StreamSwitcher({ streamSwitchManager: switchMgr }); + const assetMgr = new TestAssetManager(); + const session = new Session(assetMgr, { sessionId: "1" }, sessionStore); + const sessionLive = new SessionLive({ sessionId: "1" }, sessionLiveStore); + spyOn(sessionLive, "_loadAllMediaManifests").and.returnValue(mockLiveSegments); + + await session.initAsync(); + await session.incrementAsync(); + sessionLive.initAsync(); + + jasmine.clock().mockDate(tsNow); + jasmine.clock().tick((25 * 1000)); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(true); + expect(testStreamSwitcher.getEventId()).toBe("live-1"); + jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000))); + await sessionLive.getCurrentMediaManifestAsync(180000); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); + expect(testStreamSwitcher.getEventId()).toBe("vod-1"); + jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000)) + (60 * 1000)); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); + expect(testStreamSwitcher.getEventId()).toBe(null); + }); xit("should switch from linear-vod (session) to event-livestream (sessionLive) according to schedule. " + - "\nThen directly to 2nd event-livestream (sessionLive) and finally switch back to linear-vod (session)", async () => { - const switchMgr = new TestSwitchManager(3); - const testStreamSwitcher = new StreamSwitcher({streamSwitchManager: switchMgr}); - const assetMgr = new TestAssetManager(); - const session = new Session(assetMgr, {sessionId: "1"}, sessionStore); - const sessionLive = new SessionLive({sessionId: "1"}, sessionLiveStore); - spyOn(sessionLive, "_loadAllMediaManifests").and.returnValue(mockLiveSegments); - - await session.initAsync(); - await session.incrementAsync(); - await sessionLive.initAsync(); - - jasmine.clock().mockDate(tsNow); - jasmine.clock().tick((25 * 1000)); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(true); - expect(testStreamSwitcher.getEventId()).toBe("live-2"); - jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000))); - await sessionLive.getCurrentMediaManifestAsync(180000); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(true); - expect(testStreamSwitcher.getEventId()).toBe("live-3"); - jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000)) + (60*1000)); - await sessionLive.getCurrentMediaManifestAsync(180000); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); - expect(testStreamSwitcher.getEventId()).toBe(null); - }); + "\nThen directly to 2nd event-livestream (sessionLive) and finally switch back to linear-vod (session)", async () => { + const switchMgr = new TestSwitchManager(3); + const testStreamSwitcher = new StreamSwitcher({ streamSwitchManager: switchMgr }); + const assetMgr = new TestAssetManager(); + const session = new Session(assetMgr, { sessionId: "1" }, sessionStore); + const sessionLive = new SessionLive({ sessionId: "1" }, sessionLiveStore); + spyOn(sessionLive, "_loadAllMediaManifests").and.returnValue(mockLiveSegments); + + await session.initAsync(); + await session.incrementAsync(); + await sessionLive.initAsync(); + + jasmine.clock().mockDate(tsNow); + jasmine.clock().tick((25 * 1000)); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(true); + expect(testStreamSwitcher.getEventId()).toBe("live-2"); + jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000))); + await sessionLive.getCurrentMediaManifestAsync(180000); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(true); + expect(testStreamSwitcher.getEventId()).toBe("live-3"); + jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000)) + (60 * 1000)); + await sessionLive.getCurrentMediaManifestAsync(180000); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); + expect(testStreamSwitcher.getEventId()).toBe(null); + }); it("should switch from linear-vod (session) to event-vod (session) according to schedule. " + - "\nThen directly to event-livestream (sessionLive) and finally switch back to linear-vod (session)", async () => { - const switchMgr = new TestSwitchManager(4); - const testStreamSwitcher = new StreamSwitcher({streamSwitchManager: switchMgr}); - const assetMgr = new TestAssetManager(); - const session = new Session(assetMgr, {sessionId: "1"}, sessionStore); - const sessionLive = new SessionLive({sessionId: "1"}, sessionLiveStore); - spyOn(sessionLive, "resetLiveStoreAsync").and.callFake( () => true ); - spyOn(sessionLive, "resetSession").and.callFake( () => true ); - spyOn(sessionLive, "getCurrentMediaSequenceSegments").and.returnValue({ currMseqSegs: mockLiveSegments, segCount: 8 }); - spyOn(session, "setCurrentMediaSequenceSegments").and.returnValue(true); - spyOn(session, "setCurrentMediaAndDiscSequenceCount").and.returnValue(true); - - await session.initAsync(); - await session.incrementAsync(); - await sessionLive.initAsync(); - - sessionLive.startPlayheadAsync(); - - jasmine.clock().mockDate(tsNow); - jasmine.clock().tick((25 * 1000)); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); - expect(testStreamSwitcher.getEventId()).toBe("vod-2"); - jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000))); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(true); - expect(testStreamSwitcher.getEventId()).toBe("live-4"); - jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000)) + (60*1000)); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); - expect(testStreamSwitcher.getEventId()).toBe(null); - }); + "\nThen directly to event-livestream (sessionLive) and finally switch back to linear-vod (session)", async () => { + const switchMgr = new TestSwitchManager(4); + const testStreamSwitcher = new StreamSwitcher({ streamSwitchManager: switchMgr }); + const assetMgr = new TestAssetManager(); + const session = new Session(assetMgr, { sessionId: "1" }, sessionStore); + const sessionLive = new SessionLive({ sessionId: "1" }, sessionLiveStore); + spyOn(sessionLive, "resetLiveStoreAsync").and.callFake(() => true); + spyOn(sessionLive, "resetSession").and.callFake(() => true); + spyOn(sessionLive, "getCurrentMediaSequenceSegments").and.returnValue({ currMseqSegs: mockLiveSegments, segCount: 8 }); + spyOn(session, "setCurrentMediaSequenceSegments").and.returnValue(true); + spyOn(session, "setCurrentMediaAndDiscSequenceCount").and.returnValue(true); + + await session.initAsync(); + await session.incrementAsync(); + await sessionLive.initAsync(); + + sessionLive.startPlayheadAsync(); + + jasmine.clock().mockDate(tsNow); + jasmine.clock().tick((25 * 1000)); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); + expect(testStreamSwitcher.getEventId()).toBe("vod-2"); + jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000))); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(true); + expect(testStreamSwitcher.getEventId()).toBe("live-4"); + jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000)) + (60 * 1000)); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); + expect(testStreamSwitcher.getEventId()).toBe(null); + }); it("should switch from linear-vod (session) to event-vod (session) according to schedule. " + - "\nThen directly to 2nd event-vod (session) and finally switch back to linear-vod (session)", async () => { + "\nThen directly to 2nd event-vod (session) and finally switch back to linear-vod (session)", async () => { + const switchMgr = new TestSwitchManager(5); + const testStreamSwitcher = new StreamSwitcher({ streamSwitchManager: switchMgr }); + const assetMgr = new TestAssetManager(); + const session = new Session(assetMgr, { sessionId: "1" }, sessionStore); + const sessionLive = new SessionLive({ sessionId: "1" }, sessionLiveStore); + spyOn(sessionLive, "_loadAllMediaManifests").and.returnValue(mockLiveSegments); + spyOn(session, "setCurrentMediaSequenceSegments").and.returnValue(true); + spyOn(session, "setCurrentMediaAndDiscSequenceCount").and.returnValue(true); + + await session.initAsync(); + await session.incrementAsync(); + await sessionLive.initAsync(); + + jasmine.clock().mockDate(tsNow); + jasmine.clock().tick((25 * 1000)); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); + expect(testStreamSwitcher.getEventId()).toBe("vod-3"); + jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000))); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); + expect(testStreamSwitcher.getEventId()).toBe("vod-4"); + jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000)) + (60 * 1000)); + expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); + expect(testStreamSwitcher.getEventId()).toBe(null); + }); + + fit("should merge audio segments correctly", async () => { const switchMgr = new TestSwitchManager(5); - const testStreamSwitcher = new StreamSwitcher({streamSwitchManager: switchMgr}); - const assetMgr = new TestAssetManager(); - const session = new Session(assetMgr, {sessionId: "1"}, sessionStore); - const sessionLive = new SessionLive({sessionId: "1"}, sessionLiveStore); - spyOn(sessionLive, "_loadAllMediaManifests").and.returnValue(mockLiveSegments); - spyOn(session, "setCurrentMediaSequenceSegments").and.returnValue(true); - spyOn(session, "setCurrentMediaAndDiscSequenceCount").and.returnValue(true); - - await session.initAsync(); - await session.incrementAsync(); - await sessionLive.initAsync(); + const sessionLive = new StreamSwitcher({ streamSwitchManager: switchMgr }); + const fromSegments = { + aac: { + en: [ + { + id: 1, + uri: "en1.m3u8" + }, + { + id: 2, + uri: "en2.m3u8" + }, + { + id: 3, + uri: "en3.m3u8" + }, + ], + es: [ + { + id: 1, + uri: "es1.m3u8" + }, + { + id: 2, + uri: "es2.m3u8" + }, + { + id: 3, + uri: "es3.m3u8" + }, + ] + } - jasmine.clock().mockDate(tsNow); - jasmine.clock().tick((25 * 1000)); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); - expect(testStreamSwitcher.getEventId()).toBe("vod-3"); - jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000))); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); - expect(testStreamSwitcher.getEventId()).toBe("vod-4"); - jasmine.clock().tick((1 + (60 * 1000 + 20 * 1000)) + (60*1000)); - expect(await testStreamSwitcher.streamSwitcher(session, sessionLive)).toBe(false); - expect(testStreamSwitcher.getEventId()).toBe(null); + }; + const toSegments = { + aac: { + en: [ + { + id: 4, + uri: "en4.m3u8" + }, + { + id: 5, + uri: "en5.m3u8" + }, + { + id: 6, + uri: "en6.m3u8" + }, + ] + } + }; + let newList = sessionLive._mergeAudioSegments(toSegments, fromSegments, true); + let result = { + aac: { + en: [ + { discontinuity: true }, + { id: 4, uri: 'en4.m3u8' }, + { id: 5, uri: 'en5.m3u8' }, + { id: 6, uri: 'en6.m3u8' }, + { id: 1, uri: 'en1.m3u8' }, + { id: 2, uri: 'en2.m3u8' }, + { id: 3, uri: 'en3.m3u8' } + ], + es: [ + { discontinuity: true }, + { id: 4, uri: 'en4.m3u8' }, + { id: 5, uri: 'en5.m3u8' }, + { id: 6, uri: 'en6.m3u8' }, + { id: 1, uri: 'es1.m3u8' }, + { id: 2, uri: 'es2.m3u8' }, + { id: 3, uri: 'es3.m3u8' } + ] + } + } + + expect(newList).toEqual(result); }); }); \ No newline at end of file