From d4abc2183690edc8ee06b20175082e0d7af014b4 Mon Sep 17 00:00:00 2001 From: Garrett Date: Mon, 2 Oct 2017 17:07:16 -0400 Subject: [PATCH] Playlist loader cleanup (#1265) * Remove unused bandwidth from PlaylistLoader * es6ify playlist-loader.js * Convert PlaylistLoader to a proper class --- src/playlist-loader.js | 544 ++++++++++++------------- test/playlist-loader.test.js | 747 ++++++++++++++++++++++++++++++++++- 2 files changed, 1004 insertions(+), 287 deletions(-) diff --git a/src/playlist-loader.js b/src/playlist-loader.js index 4c352353..8424e566 100644 --- a/src/playlist-loader.js +++ b/src/playlist-loader.js @@ -26,15 +26,13 @@ import window from 'global/window'; * playlists. * @return a list of merged segment objects */ -const updateSegments = function(original, update, offset) { - let result = update.slice(); - let length; - let i; +export const updateSegments = (original, update, offset) => { + const result = update.slice(); offset = offset || 0; - length = Math.min(original.length, update.length + offset); + const length = Math.min(original.length, update.length + offset); - for (i = offset; i < length; i++) { + for (let i = offset; i < length; i++) { result[i - offset] = mergeOptions(original[i], result[i - offset]); } return result; @@ -52,59 +50,95 @@ const updateSegments = function(original, update, offset) { * master playlist with the updated media playlist merged in, or * null if the merge produced no change. */ -const updateMaster = function(master, media) { - let changed = false; - let result = mergeOptions(master, {}); +export const updateMaster = (master, media) => { + const result = mergeOptions(master, {}); + const playlist = result.playlists.filter((p) => p.uri === media.uri)[0]; + + if (!playlist) { + return null; + } + + // consider the playlist unchanged if the number of segments is equal and the media + // sequence number is unchanged + if (playlist.segments && + media.segments && + playlist.segments.length === media.segments.length && + playlist.mediaSequence === media.mediaSequence) { + return null; + } + + const mergedPlaylist = mergeOptions(playlist, media); + + // if the update could overlap existing segment information, merge the two segment lists + if (playlist.segments) { + mergedPlaylist.segments = updateSegments( + playlist.segments, + media.segments, + media.mediaSequence - playlist.mediaSequence + ); + } + + // resolve any segment URIs to prevent us from having to do it later + mergedPlaylist.segments.forEach((segment) => { + if (!segment.resolvedUri) { + segment.resolvedUri = resolveUrl(mergedPlaylist.resolvedUri, segment.uri); + } + if (segment.key && !segment.key.resolvedUri) { + segment.key.resolvedUri = resolveUrl(mergedPlaylist.resolvedUri, segment.key.uri); + } + if (segment.map && !segment.map.resolvedUri) { + segment.map.resolvedUri = resolveUrl(mergedPlaylist.resolvedUri, segment.map.uri); + } + }); + + // TODO Right now in the playlists array there are two references to each playlist, one + // that is referenced by index, and one by URI. The index reference may no longer be + // necessary. + for (let i = 0; i < result.playlists.length; i++) { + if (result.playlists[i].uri === media.uri) { + result.playlists[i] = mergedPlaylist; + } + } + result.playlists[media.uri] = mergedPlaylist; + + return result; +}; + +export const setupMediaPlaylists = (master) => { + // setup by-URI lookups and resolve media playlist URIs let i = master.playlists.length; - let playlist; - let segment; - let j; while (i--) { - playlist = result.playlists[i]; - if (playlist.uri === media.uri) { - // consider the playlist unchanged if the number of segments - // are equal and the media sequence number is unchanged - if (playlist.segments && - media.segments && - playlist.segments.length === media.segments.length && - playlist.mediaSequence === media.mediaSequence) { - continue; - } + let playlist = master.playlists[i]; - result.playlists[i] = mergeOptions(playlist, media); - result.playlists[media.uri] = result.playlists[i]; - - // if the update could overlap existing segment information, - // merge the two lists - if (playlist.segments) { - result.playlists[i].segments = updateSegments( - playlist.segments, - media.segments, - media.mediaSequence - playlist.mediaSequence - ); - } - // resolve any missing segment and key URIs - j = 0; - if (result.playlists[i].segments) { - j = result.playlists[i].segments.length; - } - while (j--) { - segment = result.playlists[i].segments[j]; - if (!segment.resolvedUri) { - segment.resolvedUri = resolveUrl(playlist.resolvedUri, segment.uri); - } - if (segment.key && !segment.key.resolvedUri) { - segment.key.resolvedUri = resolveUrl(playlist.resolvedUri, segment.key.uri); - } - if (segment.map && !segment.map.resolvedUri) { - segment.map.resolvedUri = resolveUrl(playlist.resolvedUri, segment.map.uri); + master.playlists[playlist.uri] = playlist; + playlist.resolvedUri = resolveUrl(master.uri, playlist.uri); + + if (!playlist.attributes) { + // Although the spec states an #EXT-X-STREAM-INF tag MUST have a + // BANDWIDTH attribute, we can play the stream without it. This means a poorly + // formatted master playlist may not have an attribute list. An attributes + // property is added here to prevent undefined references when we encounter + // this scenario. + playlist.attributes = {}; + + log.warn('Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.'); + } + } +}; + +export const resolveMediaGroupUris = (master) => { + ['AUDIO', 'SUBTITLES'].forEach((mediaType) => { + for (let groupKey in master.mediaGroups[mediaType]) { + for (let labelKey in master.mediaGroups[mediaType][groupKey]) { + let mediaProperties = master.mediaGroups[mediaType][groupKey][labelKey]; + + if (mediaProperties.uri) { + mediaProperties.resolvedUri = resolveUrl(master.uri, mediaProperties.uri); } } - changed = true; } - } - return changed ? result : null; + }); }; /** @@ -116,59 +150,77 @@ const updateMaster = function(master, media) { * @param {Boolean} withCredentials the withCredentials xhr option * @constructor */ -const PlaylistLoader = function(srcUrl, hls, withCredentials) { - /* eslint-disable consistent-this */ - let loader = this; - /* eslint-enable consistent-this */ - let mediaUpdateTimeout; - let request; - let playlistRequestError; - let haveMetadata; +export default class PlaylistLoader extends EventTarget { + constructor(srcUrl, hls, withCredentials) { + super(); - PlaylistLoader.prototype.constructor.call(this); + this.srcUrl = srcUrl; + this.hls_ = hls; + this.withCredentials = withCredentials; - this.hls_ = hls; + if (!this.srcUrl) { + throw new Error('A non-empty playlist URL is required'); + } - if (!srcUrl) { - throw new Error('A non-empty playlist URL is required'); - } + // initialize the loader state + this.state = 'HAVE_NOTHING'; + + // live playlist staleness timeout + this.on('mediaupdatetimeout', () => { + if (this.state !== 'HAVE_METADATA') { + // only refresh the media playlist if no other activity is going on + return; + } - playlistRequestError = function(xhr, url, startingState) { - loader.setBandwidth(request || xhr); + this.state = 'HAVE_CURRENT_METADATA'; + this.request = this.hls_.xhr({ + uri: resolveUrl(this.master.uri, this.media().uri), + withCredentials: this.withCredentials + }, (error, req) => { + // disposed + if (!this.request) { + return; + } + + if (error) { + return this.playlistRequestError( + this.request, this.media().uri, 'HAVE_METADATA'); + } + + this.haveMetadata(this.request, this.media().uri); + }); + }); + } + + playlistRequestError(xhr, url, startingState) { // any in-flight request is now finished - request = null; + this.request = null; if (startingState) { - loader.state = startingState; + this.state = startingState; } - loader.error = { - playlist: loader.master.playlists[url], + this.error = { + playlist: this.master.playlists[url], status: xhr.status, message: 'HLS playlist request error at URL: ' + url, responseText: xhr.responseText, code: (xhr.status >= 500) ? 4 : 2 }; - loader.trigger('error'); - }; + this.trigger('error'); + } // update the playlist loader's state in response to a new or // updated playlist. - haveMetadata = function(xhr, url) { - let parser; - let refreshDelay; - let update; - - loader.setBandwidth(request || xhr); - + haveMetadata(xhr, url) { // any in-flight request is now finished - request = null; + this.request = null; + this.state = 'HAVE_METADATA'; - loader.state = 'HAVE_METADATA'; + const parser = new m3u8.Parser(); - parser = new m3u8.Parser(); parser.push(xhr.responseText); parser.end(); parser.manifest.uri = url; @@ -177,95 +229,88 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) { parser.manifest.attributes = parser.manifest.attributes || {}; // merge this playlist into the master - update = updateMaster(loader.master, parser.manifest); - refreshDelay = (parser.manifest.targetDuration || 10) * 1000; - loader.targetDuration = parser.manifest.targetDuration; + const update = updateMaster(this.master, parser.manifest); + let refreshDelay = (parser.manifest.targetDuration || 10) * 1000; + + this.targetDuration = parser.manifest.targetDuration; if (update) { - loader.master = update; - loader.media_ = loader.master.playlists[parser.manifest.uri]; + this.master = update; + this.media_ = this.master.playlists[parser.manifest.uri]; } else { // if the playlist is unchanged since the last reload, // try again after half the target duration refreshDelay /= 2; - loader.trigger('playlistunchanged'); + this.trigger('playlistunchanged'); } // refresh live playlists after a target duration passes - if (!loader.media().endList) { - window.clearTimeout(mediaUpdateTimeout); - mediaUpdateTimeout = window.setTimeout(function() { - loader.trigger('mediaupdatetimeout'); + if (!this.media().endList) { + window.clearTimeout(this.mediaUpdateTimeout); + this.mediaUpdateTimeout = window.setTimeout(() => { + this.trigger('mediaupdatetimeout'); }, refreshDelay); } - loader.trigger('loadedplaylist'); - }; - - // initialize the loader state - loader.state = 'HAVE_NOTHING'; + this.trigger('loadedplaylist'); + } /** * Abort any outstanding work and clean up. */ - loader.dispose = function() { - loader.stopRequest(); - window.clearTimeout(mediaUpdateTimeout); - loader.off(); - }; + dispose() { + this.stopRequest(); + window.clearTimeout(this.mediaUpdateTimeout); + } - loader.stopRequest = () => { - if (request) { - let oldRequest = request; + stopRequest() { + if (this.request) { + const oldRequest = this.request; - request = null; + this.request = null; oldRequest.onreadystatechange = null; oldRequest.abort(); } - }; + } /** * Returns the number of enabled playlists on the master playlist object * * @return {Number} number of eneabled playlists */ - loader.enabledPlaylists_ = function() { - return loader.master.playlists.filter(isEnabled).length; - }; + enabledPlaylists_() { + return this.master.playlists.filter(isEnabled).length; + } /** * Returns whether the current playlist is the lowest rendition * * @return {Boolean} true if on lowest rendition */ - loader.isLowestEnabledRendition_ = function() { - if (loader.master.playlists.length === 1) { + isLowestEnabledRendition_() { + if (this.master.playlists.length === 1) { return true; } - let media = loader.media(); - - let currentBandwidth = media.attributes.BANDWIDTH || Number.MAX_VALUE; + const currentBandwidth = this.media().attributes.BANDWIDTH || Number.MAX_VALUE; - return (loader.master.playlists.filter((playlist) => { - const enabled = isEnabled(playlist); - - if (!enabled) { + return (this.master.playlists.filter((playlist) => { + if (!isEnabled(playlist)) { return false; } return (playlist.attributes.BANDWIDTH || 0) < currentBandwidth; }).length === 0); - }; + } /** * Returns whether the current playlist is the final available rendition * * @return {Boolean} true if on final rendition */ - loader.isFinalRendition_ = function() { - return (loader.master.playlists.filter(isEnabled).length === 1); - }; + isFinalRendition_() { + return (this.master.playlists.filter(isEnabled).length === 1); + } /** * When called without any arguments, returns the currently @@ -279,46 +324,45 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) { * object to switch to * @return {Playlist} the current loaded media */ - loader.media = function(playlist) { - let startingState = loader.state; - let mediaChange; - + media(playlist) { // getter if (!playlist) { - return loader.media_; + return this.media_; } // setter - if (loader.state === 'HAVE_NOTHING') { - throw new Error('Cannot switch media playlist from ' + loader.state); + if (this.state === 'HAVE_NOTHING') { + throw new Error('Cannot switch media playlist from ' + this.state); } + const startingState = this.state; + // find the playlist object if the target playlist has been // specified by URI if (typeof playlist === 'string') { - if (!loader.master.playlists[playlist]) { + if (!this.master.playlists[playlist]) { throw new Error('Unknown playlist URI: ' + playlist); } - playlist = loader.master.playlists[playlist]; + playlist = this.master.playlists[playlist]; } - mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri; + const mediaChange = !this.media_ || playlist.uri !== this.media_.uri; // switch to fully loaded playlists immediately - if (loader.master.playlists[playlist.uri].endList) { + if (this.master.playlists[playlist.uri].endList) { // abort outstanding playlist requests - if (request) { - request.onreadystatechange = null; - request.abort(); - request = null; + if (this.request) { + this.request.onreadystatechange = null; + this.request.abort(); + this.request = null; } - loader.state = 'HAVE_METADATA'; - loader.media_ = playlist; + this.state = 'HAVE_METADATA'; + this.media_ = playlist; // trigger media change if the active media has been updated if (mediaChange) { - loader.trigger('mediachanging'); - loader.trigger('mediachange'); + this.trigger('mediachanging'); + this.trigger('mediachange'); } return; } @@ -328,18 +372,18 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) { return; } - loader.state = 'SWITCHING_MEDIA'; + this.state = 'SWITCHING_MEDIA'; // there is already an outstanding playlist request - if (request) { - if (resolveUrl(loader.master.uri, playlist.uri) === request.url) { + if (this.request) { + if (resolveUrl(this.master.uri, playlist.uri) === this.request.url) { // requesting to switch to the same playlist multiple times // has no effect after the first return; } - request.onreadystatechange = null; - request.abort(); - request = null; + this.request.onreadystatechange = null; + this.request.abort(); + this.request = null; } // request the new playlist @@ -347,212 +391,144 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) { this.trigger('mediachanging'); } - request = this.hls_.xhr({ - uri: resolveUrl(loader.master.uri, playlist.uri), - withCredentials - }, function(error, req) { + this.request = this.hls_.xhr({ + uri: resolveUrl(this.master.uri, playlist.uri), + withCredentials: this.withCredentials + }, (error, req) => { // disposed - if (!request) { + if (!this.request) { return; } if (error) { - return playlistRequestError(request, playlist.uri, startingState); + return this.playlistRequestError(this.request, playlist.uri, startingState); } - haveMetadata(req, playlist.uri); + this.haveMetadata(req, playlist.uri); // fire loadedmetadata the first time a media playlist is loaded if (startingState === 'HAVE_MASTER') { - loader.trigger('loadedmetadata'); + this.trigger('loadedmetadata'); } else { - loader.trigger('mediachange'); + this.trigger('mediachange'); } }); - }; - - /** - * set the bandwidth on an xhr to the bandwidth on the playlist - */ - loader.setBandwidth = function(xhr) { - loader.bandwidth = xhr.bandwidth; - }; - - // live playlist staleness timeout - loader.on('mediaupdatetimeout', function() { - if (loader.state !== 'HAVE_METADATA') { - // only refresh the media playlist if no other activity is going on - return; - } - - loader.state = 'HAVE_CURRENT_METADATA'; - - request = this.hls_.xhr({ - uri: resolveUrl(loader.master.uri, loader.media().uri), - withCredentials - }, function(error, req) { - // disposed - if (!request) { - return; - } - - if (error) { - return playlistRequestError(request, loader.media().uri, 'HAVE_METADATA'); - } - - haveMetadata(request, loader.media().uri); - }); - }); + } /** * pause loading of the playlist */ - loader.pause = () => { - loader.stopRequest(); - window.clearTimeout(mediaUpdateTimeout); - if (loader.state === 'HAVE_NOTHING') { + pause() { + this.stopRequest(); + window.clearTimeout(this.mediaUpdateTimeout); + if (this.state === 'HAVE_NOTHING') { // If we pause the loader before any data has been retrieved, its as if we never // started, so reset to an unstarted state. - loader.started = false; + this.started = false; } // Need to restore state now that no activity is happening - if (loader.state === 'SWITCHING_MEDIA') { + if (this.state === 'SWITCHING_MEDIA') { // if the loader was in the process of switching media, it should either return to // HAVE_MASTER or HAVE_METADATA depending on if the loader has loaded a media // playlist yet. This is determined by the existence of loader.media_ - if (loader.media_) { - loader.state = 'HAVE_METADATA'; + if (this.media_) { + this.state = 'HAVE_METADATA'; } else { - loader.state = 'HAVE_MASTER'; + this.state = 'HAVE_MASTER'; } - } else if (loader.state === 'HAVE_CURRENT_METADATA') { - loader.state = 'HAVE_METADATA'; + } else if (this.state === 'HAVE_CURRENT_METADATA') { + this.state = 'HAVE_METADATA'; } - }; + } /** * start loading of the playlist */ - loader.load = (isFinalRendition) => { - const media = loader.media(); + load(isFinalRendition) { + window.clearTimeout(this.mediaUpdateTimeout); - window.clearTimeout(mediaUpdateTimeout); + const media = this.media(); if (isFinalRendition) { - let refreshDelay = media ? (media.targetDuration / 2) * 1000 : 5 * 1000; + const refreshDelay = media ? (media.targetDuration / 2) * 1000 : 5 * 1000; - mediaUpdateTimeout = window.setTimeout(loader.load.bind(null, false), refreshDelay); + this.mediaUpdateTimeout = window.setTimeout(() => this.load(), refreshDelay); return; } - if (!loader.started) { - loader.start(); + if (!this.started) { + this.start(); return; } if (media && !media.endList) { - loader.trigger('mediaupdatetimeout'); + this.trigger('mediaupdatetimeout'); } else { - loader.trigger('loadedplaylist'); + this.trigger('loadedplaylist'); } - }; + } /** * start loading of the playlist */ - loader.start = () => { - loader.started = true; + start() { + this.started = true; // request the specified URL - request = this.hls_.xhr({ - uri: srcUrl, - withCredentials - }, function(error, req) { - let parser; - let playlist; - let i; - + this.request = this.hls_.xhr({ + uri: this.srcUrl, + withCredentials: this.withCredentials + }, (error, req) => { // disposed - if (!request) { + if (!this.request) { return; } // clear the loader's request reference - request = null; + this.request = null; if (error) { - loader.error = { + this.error = { status: req.status, - message: 'HLS playlist request error at URL: ' + srcUrl, + message: 'HLS playlist request error at URL: ' + this.srcUrl, responseText: req.responseText, // MEDIA_ERR_NETWORK code: 2 }; - if (loader.state === 'HAVE_NOTHING') { - loader.started = false; + if (this.state === 'HAVE_NOTHING') { + this.started = false; } - return loader.trigger('error'); + return this.trigger('error'); } - parser = new m3u8.Parser(); + const parser = new m3u8.Parser(); + parser.push(req.responseText); parser.end(); - loader.state = 'HAVE_MASTER'; + this.state = 'HAVE_MASTER'; - parser.manifest.uri = srcUrl; + parser.manifest.uri = this.srcUrl; // loaded a master playlist if (parser.manifest.playlists) { - loader.master = parser.manifest; - - // setup by-URI lookups and resolve media playlist URIs - i = loader.master.playlists.length; - while (i--) { - playlist = loader.master.playlists[i]; - loader.master.playlists[playlist.uri] = playlist; - playlist.resolvedUri = resolveUrl(loader.master.uri, playlist.uri); - - if (!playlist.attributes) { - // Although the spec states an #EXT-X-STREAM-INF tag MUST have a - // BANDWIDTH attribute, we can play the stream without it. This means a poorly - // formatted master playlist may not have an attribute list. An attributes - // property is added here to prevent undefined references when we encounter - // this scenario. - playlist.attributes = {}; - - log.warn( - 'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.'); - } - } + this.master = parser.manifest; - // resolve any media group URIs - ['AUDIO', 'SUBTITLES'].forEach((mediaType) => { - for (let groupKey in loader.master.mediaGroups[mediaType]) { - for (let labelKey in loader.master.mediaGroups[mediaType][groupKey]) { - let mediaProperties = - loader.master.mediaGroups[mediaType][groupKey][labelKey]; - - if (mediaProperties.uri) { - mediaProperties.resolvedUri = - resolveUrl(loader.master.uri, mediaProperties.uri); - } - } - } - }); - - loader.trigger('loadedplaylist'); - if (!request) { + setupMediaPlaylists(this.master); + resolveMediaGroupUris(this.master); + + this.trigger('loadedplaylist'); + if (!this.request) { // no media playlist was specifically selected so start // from the first listed one - loader.media(parser.manifest.playlists[0]); + this.media(parser.manifest.playlists[0]); } return; } // loaded a media playlist // infer a master playlist if none was previously requested - loader.master = { + this.master = { mediaGroups: { 'AUDIO': {}, 'VIDEO': {}, @@ -561,20 +537,16 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) { }, uri: window.location.href, playlists: [{ - uri: srcUrl + uri: this.srcUrl }] }; - loader.master.playlists[srcUrl] = loader.master.playlists[0]; - loader.master.playlists[0].resolvedUri = srcUrl; + this.master.playlists[this.srcUrl] = this.master.playlists[0]; + this.master.playlists[0].resolvedUri = this.srcUrl; // m3u8-parser does not attach an attributes property to media playlists so make // sure that the property is attached to avoid undefined reference errors - loader.master.playlists[0].attributes = loader.master.playlists[0].attributes || {}; - haveMetadata(req, srcUrl); - return loader.trigger('loadedmetadata'); + this.master.playlists[0].attributes = this.master.playlists[0].attributes || {}; + this.haveMetadata(req, this.srcUrl); + return this.trigger('loadedmetadata'); }); - }; -}; - -PlaylistLoader.prototype = new EventTarget(); - -export default PlaylistLoader; + } +} diff --git a/test/playlist-loader.test.js b/test/playlist-loader.test.js index af2bf0d9..33445f4d 100644 --- a/test/playlist-loader.test.js +++ b/test/playlist-loader.test.js @@ -1,5 +1,11 @@ import QUnit from 'qunit'; -import PlaylistLoader from '../src/playlist-loader'; +import { + default as PlaylistLoader, + updateSegments, + updateMaster, + setupMediaPlaylists, + resolveMediaGroupUris +} from '../src/playlist-loader'; import xhrFactory from '../src/xhr'; import { useFakeEnvironment } from './test-helpers'; import window from 'global/window'; @@ -28,6 +34,745 @@ QUnit.module('Playlist Loader', { } }); +QUnit.test('updateSegments copies over properties', function(assert) { + assert.deepEqual( + [ + { uri: 'test-uri-0', startTime: 0, endTime: 10 }, + { + uri: 'test-uri-1', + startTime: 10, + endTime: 20, + map: { someProp: 99, uri: '4' } + } + ], + updateSegments( + [ + { uri: 'test-uri-0', startTime: 0, endTime: 10 }, + { uri: 'test-uri-1', startTime: 10, endTime: 20, map: { someProp: 1 } } + ], + [ + { uri: 'test-uri-0' }, + { uri: 'test-uri-1', map: { someProp: 99, uri: '4' } } + ], + 0), + 'retains properties from original segment'); + + assert.deepEqual( + [ + { uri: 'test-uri-0', map: { someProp: 100 } }, + { uri: 'test-uri-1', map: { someProp: 99, uri: '4' } } + ], + updateSegments( + [ + { uri: 'test-uri-0' }, + { uri: 'test-uri-1', map: { someProp: 1 } } + ], + [ + { uri: 'test-uri-0', map: { someProp: 100 } }, + { uri: 'test-uri-1', map: { someProp: 99, uri: '4' } } + ], + 0), + 'copies over/overwrites properties without offset'); + + assert.deepEqual( + [ + { uri: 'test-uri-1', map: { someProp: 1 } }, + { uri: 'test-uri-2', map: { someProp: 100, uri: '2' } } + ], + updateSegments( + [ + { uri: 'test-uri-0' }, + { uri: 'test-uri-1', map: { someProp: 1 } } + ], + [ + { uri: 'test-uri-1' }, + { uri: 'test-uri-2', map: { someProp: 100, uri: '2' } } + ], + 1), + 'copies over/overwrites properties with offset of 1'); + + assert.deepEqual( + [ + { uri: 'test-uri-2' }, + { uri: 'test-uri-3', map: { someProp: 100, uri: '2' } } + ], + updateSegments( + [ + { uri: 'test-uri-0' }, + { uri: 'test-uri-1', map: { someProp: 1 } } + ], + [ + { uri: 'test-uri-2' }, + { uri: 'test-uri-3', map: { someProp: 100, uri: '2' } } + ], + 2), + 'copies over/overwrites properties with offset of 2'); +}); + +QUnit.test('updateMaster returns null when no playlists', function(assert) { + const master = { + playlists: [] + }; + const media = {}; + + assert.deepEqual(updateMaster(master, media), null, 'returns null when no playlists'); +}); + +QUnit.test('updateMaster returns null when no change', function(assert) { + const master = { + playlists: [{ + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] + }] + }; + const media = { + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + segments: [{ + duration: 10, + uri: 'segment-0-uri' + }] + }; + + assert.deepEqual(updateMaster(master, media), null, 'returns null'); +}); + +QUnit.test('updateMaster updates master when new media sequence', function(assert) { + const master = { + playlists: [{ + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] + }] + }; + const media = { + mediaSequence: 1, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + segments: [{ + duration: 10, + uri: 'segment-0-uri' + }] + }; + + assert.deepEqual( + updateMaster(master, media), + { + playlists: [{ + mediaSequence: 1, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] + }] + }, + 'updates master when new media sequence'); +}); + +QUnit.test('updateMaster retains top level values in master', function(assert) { + const master = { + mediaGroups: { + AUDIO: { + 'GROUP-ID': { + default: true, + uri: 'audio-uri' + } + } + }, + playlists: [{ + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] + }] + }; + const media = { + mediaSequence: 1, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + segments: [{ + duration: 10, + uri: 'segment-0-uri' + }] + }; + + assert.deepEqual( + updateMaster(master, media), + { + mediaGroups: { + AUDIO: { + 'GROUP-ID': { + default: true, + uri: 'audio-uri' + } + } + }, + playlists: [{ + mediaSequence: 1, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] + }] + }, + 'retains top level values in master'); +}); + +QUnit.test('updateMaster adds new segments to master', function(assert) { + const master = { + mediaGroups: { + AUDIO: { + 'GROUP-ID': { + default: true, + uri: 'audio-uri' + } + } + }, + playlists: [{ + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] + }] + }; + const media = { + mediaSequence: 1, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + segments: [{ + duration: 10, + uri: 'segment-0-uri' + }, { + duration: 9, + uri: 'segment-1-uri' + }] + }; + + assert.deepEqual( + updateMaster(master, media), + { + mediaGroups: { + AUDIO: { + 'GROUP-ID': { + default: true, + uri: 'audio-uri' + } + } + }, + playlists: [{ + mediaSequence: 1, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }, { + duration: 9, + uri: 'segment-1-uri', + resolvedUri: urlTo('segment-1-uri') + }] + }] + }, + 'adds new segment to master'); +}); + +QUnit.test('updateMaster changes old values', function(assert) { + const master = { + mediaGroups: { + AUDIO: { + 'GROUP-ID': { + default: true, + uri: 'audio-uri' + } + } + }, + playlists: [{ + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] + }] + }; + const media = { + mediaSequence: 1, + attributes: { + BANDWIDTH: 8, + newField: 1 + }, + uri: 'playlist-0-uri', + segments: [{ + duration: 8, + uri: 'segment-0-uri' + }, { + duration: 10, + uri: 'segment-1-uri' + }] + }; + + assert.deepEqual( + updateMaster(master, media), + { + mediaGroups: { + AUDIO: { + 'GROUP-ID': { + default: true, + uri: 'audio-uri' + } + } + }, + playlists: [{ + mediaSequence: 1, + attributes: { + BANDWIDTH: 8, + newField: 1 + }, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 8, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }, { + duration: 10, + uri: 'segment-1-uri', + resolvedUri: urlTo('segment-1-uri') + }] + }] + }, + 'changes old values'); +}); + +QUnit.test('updateMaster retains saved segment values', function(assert) { + const master = { + playlists: [{ + mediaSequence: 0, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri'), + startTime: 0, + endTime: 10 + }] + }] + }; + const media = { + mediaSequence: 0, + uri: 'playlist-0-uri', + segments: [{ + duration: 8, + uri: 'segment-0-uri' + }, { + duration: 10, + uri: 'segment-1-uri' + }] + }; + + assert.deepEqual( + updateMaster(master, media), + { + playlists: [{ + mediaSequence: 0, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 8, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri'), + startTime: 0, + endTime: 10 + }, { + duration: 10, + uri: 'segment-1-uri', + resolvedUri: urlTo('segment-1-uri') + }] + }] + }, + 'retains saved segment values'); +}); + +QUnit.test('updateMaster resolves key and map URIs', function(assert) { + const master = { + playlists: [{ + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }, { + duration: 10, + uri: 'segment-1-uri', + resolvedUri: urlTo('segment-1-uri') + }] + }] + }; + const media = { + mediaSequence: 3, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + segments: [{ + duration: 9, + uri: 'segment-2-uri', + key: { + uri: 'key-2-uri' + }, + map: { + uri: 'map-2-uri' + } + }, { + duration: 11, + uri: 'segment-3-uri', + key: { + uri: 'key-3-uri' + }, + map: { + uri: 'map-3-uri' + } + }] + }; + + assert.deepEqual( + updateMaster(master, media), + { + playlists: [{ + mediaSequence: 3, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 9, + uri: 'segment-2-uri', + resolvedUri: urlTo('segment-2-uri'), + key: { + uri: 'key-2-uri', + resolvedUri: urlTo('key-2-uri') + }, + map: { + uri: 'map-2-uri', + resolvedUri: urlTo('map-2-uri') + } + }, { + duration: 11, + uri: 'segment-3-uri', + resolvedUri: urlTo('segment-3-uri'), + key: { + uri: 'key-3-uri', + resolvedUri: urlTo('key-3-uri') + }, + map: { + uri: 'map-3-uri', + resolvedUri: urlTo('map-3-uri') + } + }] + }] + }, + 'resolves key and map URIs'); +}); + +QUnit.test('setupMediaPlaylists does nothing if no playlists', function(assert) { + const master = { + playlists: [] + }; + + setupMediaPlaylists(master); + + assert.deepEqual(master, { + playlists: [] + }, 'master remains unchanged'); +}); + +QUnit.test('setupMediaPlaylists adds URI keys for each playlist', function(assert) { + const master = { + uri: 'master-uri', + playlists: [{ + uri: 'uri-0' + }, { + uri: 'uri-1' + }] + }; + const expectedPlaylist0 = { + attributes: {}, + resolvedUri: urlTo('uri-0'), + uri: 'uri-0' + }; + const expectedPlaylist1 = { + attributes: {}, + resolvedUri: urlTo('uri-1'), + uri: 'uri-1' + }; + + setupMediaPlaylists(master); + + assert.deepEqual(master.playlists[0], expectedPlaylist0, 'retained playlist indices'); + assert.deepEqual(master.playlists[1], expectedPlaylist1, 'retained playlist indices'); + assert.deepEqual(master.playlists['uri-0'], expectedPlaylist0, 'added playlist key'); + assert.deepEqual(master.playlists['uri-1'], expectedPlaylist1, 'added playlist key'); + + assert.equal(this.env.log.warn.calls, 2, 'logged two warnings'); + assert.equal(this.env.log.warn.args[0], + 'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.', + 'logged a warning'); + assert.equal(this.env.log.warn.args[1], + 'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.', + 'logged a warning'); +}); + +QUnit.test('setupMediaPlaylists adds attributes objects if missing', function(assert) { + const master = { + uri: 'master-uri', + playlists: [{ + uri: 'uri-0' + }, { + uri: 'uri-1' + }] + }; + + setupMediaPlaylists(master); + + assert.ok(master.playlists[0].attributes, 'added attributes object'); + assert.ok(master.playlists[1].attributes, 'added attributes object'); + + assert.equal(this.env.log.warn.calls, 2, 'logged two warnings'); + assert.equal(this.env.log.warn.args[0], + 'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.', + 'logged a warning'); + assert.equal(this.env.log.warn.args[1], + 'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.', + 'logged a warning'); +}); + +QUnit.test('setupMediaPlaylists resolves playlist URIs', function(assert) { + const master = { + uri: 'master-uri', + playlists: [{ + attributes: { BANDWIDTH: 10 }, + uri: 'uri-0' + }, { + attributes: { BANDWIDTH: 100 }, + uri: 'uri-1' + }] + }; + + setupMediaPlaylists(master); + + assert.equal(master.playlists[0].resolvedUri, urlTo('uri-0'), 'resolves URI'); + assert.equal(master.playlists[1].resolvedUri, urlTo('uri-1'), 'resolves URI'); +}); + +QUnit.test('resolveMediaGroupUris does nothing when no media groups', function(assert) { + const master = { + uri: 'master-uri', + playlists: [], + mediaGroups: [] + }; + + resolveMediaGroupUris(master); + assert.deepEqual(master, { + uri: 'master-uri', + playlists: [], + mediaGroups: [] + }, 'does nothing when no media groups'); +}); + +QUnit.test('resolveMediaGroupUris resolves media group URIs', function(assert) { + const master = { + uri: 'master-uri', + playlists: [{ + attributes: { BANDWIDTH: 10 }, + uri: 'playlist-0' + }], + mediaGroups: { + // CLOSED-CAPTIONS will never have a URI + 'CLOSED-CAPTIONS': { + cc1: { + English: {} + } + }, + 'AUDIO': { + low: { + // audio doesn't need a URI if it is a label for muxed + main: {}, + commentary: { + uri: 'audio-low-commentary-uri' + } + }, + high: { + main: {}, + commentary: { + uri: 'audio-high-commentary-uri' + } + } + }, + 'SUBTITLES': { + sub1: { + english: { + uri: 'subtitles-1-english-uri' + }, + spanish: { + uri: 'subtitles-1-spanish-uri' + } + }, + sub2: { + english: { + uri: 'subtitles-2-english-uri' + }, + spanish: { + uri: 'subtitles-2-spanish-uri' + } + }, + sub3: { + english: { + uri: 'subtitles-3-english-uri' + }, + spanish: { + uri: 'subtitles-3-spanish-uri' + } + } + } + } + }; + + resolveMediaGroupUris(master); + + assert.deepEqual(master, { + uri: 'master-uri', + playlists: [{ + attributes: { BANDWIDTH: 10 }, + uri: 'playlist-0' + }], + mediaGroups: { + // CLOSED-CAPTIONS will never have a URI + 'CLOSED-CAPTIONS': { + cc1: { + English: {} + } + }, + 'AUDIO': { + low: { + // audio doesn't need a URI if it is a label for muxed + main: {}, + commentary: { + uri: 'audio-low-commentary-uri', + resolvedUri: urlTo('audio-low-commentary-uri') + } + }, + high: { + main: {}, + commentary: { + uri: 'audio-high-commentary-uri', + resolvedUri: urlTo('audio-high-commentary-uri') + } + } + }, + 'SUBTITLES': { + sub1: { + english: { + uri: 'subtitles-1-english-uri', + resolvedUri: urlTo('subtitles-1-english-uri') + }, + spanish: { + uri: 'subtitles-1-spanish-uri', + resolvedUri: urlTo('subtitles-1-spanish-uri') + } + }, + sub2: { + english: { + uri: 'subtitles-2-english-uri', + resolvedUri: urlTo('subtitles-2-english-uri') + }, + spanish: { + uri: 'subtitles-2-spanish-uri', + resolvedUri: urlTo('subtitles-2-spanish-uri') + } + }, + sub3: { + english: { + uri: 'subtitles-3-english-uri', + resolvedUri: urlTo('subtitles-3-english-uri') + }, + spanish: { + uri: 'subtitles-3-spanish-uri', + resolvedUri: urlTo('subtitles-3-spanish-uri') + } + } + } + } + }, 'resolved URIs of certain media groups'); +}); + QUnit.test('throws if the playlist url is empty or undefined', function(assert) { assert.throws(function() { PlaylistLoader();