Browse Source

Playlist loader cleanup (#1265)

* Remove unused bandwidth from PlaylistLoader
* es6ify playlist-loader.js
* Convert PlaylistLoader to a proper class
pull/6/head
Garrett 8 years ago
committed by Matthew Neil
parent
commit
d4abc21836
  1. 544
      src/playlist-loader.js
  2. 747
      test/playlist-loader.test.js

544
src/playlist-loader.js

@ -26,15 +26,13 @@ import window from 'global/window';
* playlists. * playlists.
* @return a list of merged segment objects * @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; 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]); result[i - offset] = mergeOptions(original[i], result[i - offset]);
} }
return result; return result;
@ -52,59 +50,95 @@ const updateSegments = function(original, update, offset) {
* master playlist with the updated media playlist merged in, or * master playlist with the updated media playlist merged in, or
* null if the merge produced no change. * 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 i = master.playlists.length;
let playlist;
let segment;
let j;
while (i--) { 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 * @param {Boolean} withCredentials the withCredentials xhr option
* @constructor * @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 // any in-flight request is now finished
request = null;
this.request = null;
if (startingState) { 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, status: xhr.status,
message: 'HLS playlist request error at URL: ' + url, message: 'HLS playlist request error at URL: ' + url,
responseText: xhr.responseText, responseText: xhr.responseText,
code: (xhr.status >= 500) ? 4 : 2 code: (xhr.status >= 500) ? 4 : 2
}; };
loader.trigger('error');
};
this.trigger('error');
}
// update the playlist loader's state in response to a new or // update the playlist loader's state in response to a new or
// updated playlist. // 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 // 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.push(xhr.responseText);
parser.end(); parser.end();
parser.manifest.uri = url; parser.manifest.uri = url;
@ -177,95 +229,88 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
parser.manifest.attributes = parser.manifest.attributes || {}; parser.manifest.attributes = parser.manifest.attributes || {};
// merge this playlist into the master // 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) { 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 { } else {
// if the playlist is unchanged since the last reload, // if the playlist is unchanged since the last reload,
// try again after half the target duration // try again after half the target duration
refreshDelay /= 2; refreshDelay /= 2;
loader.trigger('playlistunchanged');
this.trigger('playlistunchanged');
} }
// refresh live playlists after a target duration passes // 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); }, refreshDelay);
} }
loader.trigger('loadedplaylist');
};
// initialize the loader state
loader.state = 'HAVE_NOTHING';
this.trigger('loadedplaylist');
}
/** /**
* Abort any outstanding work and clean up. * 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.onreadystatechange = null;
oldRequest.abort(); oldRequest.abort();
} }
};
}
/** /**
* Returns the number of enabled playlists on the master playlist object * Returns the number of enabled playlists on the master playlist object
* *
* @return {Number} number of eneabled playlists * @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 * Returns whether the current playlist is the lowest rendition
* *
* @return {Boolean} true if on 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; 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 false;
} }
return (playlist.attributes.BANDWIDTH || 0) < currentBandwidth; return (playlist.attributes.BANDWIDTH || 0) < currentBandwidth;
}).length === 0); }).length === 0);
};
}
/** /**
* Returns whether the current playlist is the final available rendition * Returns whether the current playlist is the final available rendition
* *
* @return {Boolean} true if on final 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 * When called without any arguments, returns the currently
@ -279,46 +324,45 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
* object to switch to * object to switch to
* @return {Playlist} the current loaded media * @return {Playlist} the current loaded media
*/ */
loader.media = function(playlist) {
let startingState = loader.state;
let mediaChange;
media(playlist) {
// getter // getter
if (!playlist) { if (!playlist) {
return loader.media_;
return this.media_;
} }
// setter // 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 // find the playlist object if the target playlist has been
// specified by URI // specified by URI
if (typeof playlist === 'string') { if (typeof playlist === 'string') {
if (!loader.master.playlists[playlist]) {
if (!this.master.playlists[playlist]) {
throw new Error('Unknown playlist URI: ' + 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 // switch to fully loaded playlists immediately
if (loader.master.playlists[playlist.uri].endList) {
if (this.master.playlists[playlist.uri].endList) {
// abort outstanding playlist requests // 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 // trigger media change if the active media has been updated
if (mediaChange) { if (mediaChange) {
loader.trigger('mediachanging');
loader.trigger('mediachange');
this.trigger('mediachanging');
this.trigger('mediachange');
} }
return; return;
} }
@ -328,18 +372,18 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
return; return;
} }
loader.state = 'SWITCHING_MEDIA';
this.state = 'SWITCHING_MEDIA';
// there is already an outstanding playlist request // 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 // requesting to switch to the same playlist multiple times
// has no effect after the first // has no effect after the first
return; return;
} }
request.onreadystatechange = null;
request.abort();
request = null;
this.request.onreadystatechange = null;
this.request.abort();
this.request = null;
} }
// request the new playlist // request the new playlist
@ -347,212 +391,144 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
this.trigger('mediachanging'); 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 // disposed
if (!request) {
if (!this.request) {
return; return;
} }
if (error) { 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 // fire loadedmetadata the first time a media playlist is loaded
if (startingState === 'HAVE_MASTER') { if (startingState === 'HAVE_MASTER') {
loader.trigger('loadedmetadata');
this.trigger('loadedmetadata');
} else { } 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 * 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 // If we pause the loader before any data has been retrieved, its as if we never
// started, so reset to an unstarted state. // started, so reset to an unstarted state.
loader.started = false;
this.started = false;
} }
// Need to restore state now that no activity is happening // 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 // 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 // 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_ // 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 { } 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 * 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) { 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; return;
} }
if (!loader.started) {
loader.start();
if (!this.started) {
this.start();
return; return;
} }
if (media && !media.endList) { if (media && !media.endList) {
loader.trigger('mediaupdatetimeout');
this.trigger('mediaupdatetimeout');
} else { } else {
loader.trigger('loadedplaylist');
this.trigger('loadedplaylist');
} }
};
}
/** /**
* start loading of the playlist * start loading of the playlist
*/ */
loader.start = () => {
loader.started = true;
start() {
this.started = true;
// request the specified URL // 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 // disposed
if (!request) {
if (!this.request) {
return; return;
} }
// clear the loader's request reference // clear the loader's request reference
request = null;
this.request = null;
if (error) { if (error) {
loader.error = {
this.error = {
status: req.status, status: req.status,
message: 'HLS playlist request error at URL: ' + srcUrl,
message: 'HLS playlist request error at URL: ' + this.srcUrl,
responseText: req.responseText, responseText: req.responseText,
// MEDIA_ERR_NETWORK // MEDIA_ERR_NETWORK
code: 2 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.push(req.responseText);
parser.end(); parser.end();
loader.state = 'HAVE_MASTER';
this.state = 'HAVE_MASTER';
parser.manifest.uri = srcUrl;
parser.manifest.uri = this.srcUrl;
// loaded a master playlist // loaded a master playlist
if (parser.manifest.playlists) { 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 // no media playlist was specifically selected so start
// from the first listed one // from the first listed one
loader.media(parser.manifest.playlists[0]);
this.media(parser.manifest.playlists[0]);
} }
return; return;
} }
// loaded a media playlist // loaded a media playlist
// infer a master playlist if none was previously requested // infer a master playlist if none was previously requested
loader.master = {
this.master = {
mediaGroups: { mediaGroups: {
'AUDIO': {}, 'AUDIO': {},
'VIDEO': {}, 'VIDEO': {},
@ -561,20 +537,16 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
}, },
uri: window.location.href, uri: window.location.href,
playlists: [{ 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 // m3u8-parser does not attach an attributes property to media playlists so make
// sure that the property is attached to avoid undefined reference errors // 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;
}
}

747
test/playlist-loader.test.js

@ -1,5 +1,11 @@
import QUnit from 'qunit'; 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 xhrFactory from '../src/xhr';
import { useFakeEnvironment } from './test-helpers'; import { useFakeEnvironment } from './test-helpers';
import window from 'global/window'; 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) { QUnit.test('throws if the playlist url is empty or undefined', function(assert) {
assert.throws(function() { assert.throws(function() {
PlaylistLoader(); PlaylistLoader();

Loading…
Cancel
Save