86a32489 by Matthew Neil Committed by Jon-Carlos Rivera

Fix request timeouts (#770)

* Removed segment timeout when on the lowest enabled rendition
* Move timeout check to only run when needed
1 parent 8e167d54
......@@ -59,6 +59,10 @@ export default class MasterPlaylistController extends videojs.EventTarget {
this.hls_ = tech.hls;
this.mode_ = mode;
this.audioTracks_ = [];
this.requestOptions_ = {
withCredentials: this.withCredentials,
timeout: null
};
this.mediaSource = new videojs.MediaSource({ mode });
this.mediaSource.on('audioinfo', (e) => this.trigger(e));
......@@ -69,7 +73,6 @@ export default class MasterPlaylistController extends videojs.EventTarget {
hls: this.hls_,
mediaSource: this.mediaSource,
currentTime: this.tech_.currentTime.bind(this.tech_),
withCredentials: this.withCredentials,
seekable: () => this.seekable(),
seeking: () => this.tech_.seeking(),
setCurrentTime: (a) => this.tech_.setCurrentTime(a),
......@@ -90,11 +93,14 @@ export default class MasterPlaylistController extends videojs.EventTarget {
this.masterPlaylistLoader_.on('loadedmetadata', () => {
let media = this.masterPlaylistLoader_.media();
let requestTimeout = (this.masterPlaylistLoader_.targetDuration * 1.5) * 1000;
this.requestOptions_.timeout = requestTimeout;
// if this isn't a live video and preload permits, start
// downloading segments
if (media.endList && this.tech_.preload() !== 'none') {
this.mainSegmentLoader_.playlist(media);
this.mainSegmentLoader_.playlist(media, this.requestOptions_);
this.mainSegmentLoader_.expired(this.masterPlaylistLoader_.expired_);
this.mainSegmentLoader_.load();
}
......@@ -122,7 +128,7 @@ export default class MasterPlaylistController extends videojs.EventTarget {
// that the segments have changed in some way and use that to
// update the SegmentLoader instead of doing it twice here and
// on `mediachange`
this.mainSegmentLoader_.playlist(updatedPlaylist);
this.mainSegmentLoader_.playlist(updatedPlaylist, this.requestOptions_);
this.mainSegmentLoader_.expired(this.masterPlaylistLoader_.expired_);
this.updateDuration();
......@@ -143,14 +149,23 @@ export default class MasterPlaylistController extends videojs.EventTarget {
this.masterPlaylistLoader_.on('mediachange', () => {
let media = this.masterPlaylistLoader_.media();
let requestTimeout = (this.masterPlaylistLoader_.targetDuration * 1.5) * 1000;
this.mainSegmentLoader_.abort();
// If we don't have any more available playlists, we don't want to
// timeout the request.
if (this.masterPlaylistLoader_.isLowestEnabledRendition_()) {
this.requestOptions_.timeout = 0;
} else {
this.requestOptions_.timeout = requestTimeout;
}
// TODO: Create a new event on the PlaylistLoader that signals
// that the segments have changed in some way and use that to
// update the SegmentLoader instead of doing it twice here and
// on `loadedplaylist`
this.mainSegmentLoader_.playlist(media);
this.mainSegmentLoader_.playlist(media, this.requestOptions_);
this.mainSegmentLoader_.expired(this.masterPlaylistLoader_.expired_);
this.mainSegmentLoader_.load();
......@@ -340,7 +355,7 @@ export default class MasterPlaylistController extends videojs.EventTarget {
let media = this.audioPlaylistLoader_.media();
/* eslint-enable no-shadow */
this.audioSegmentLoader_.playlist(media);
this.audioSegmentLoader_.playlist(media, this.requestOptions_);
this.addMimeType_(this.audioSegmentLoader_, 'mp4a.40.2', media);
// if the video is already playing, or if this isn't a live video and preload
......@@ -370,7 +385,7 @@ export default class MasterPlaylistController extends videojs.EventTarget {
return;
}
this.audioSegmentLoader_.playlist(updatedPlaylist);
this.audioSegmentLoader_.playlist(updatedPlaylist, this.requestOptions_);
});
this.audioPlaylistLoader_.on('error', () => {
......
......@@ -177,6 +177,7 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
// merge this playlist into the master
update = updateMaster(loader.master, parser.manifest);
refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
loader.targetDuration = parser.manifest.targetDuration;
if (update) {
loader.master = update;
loader.updateMediaPlaylist_(parser.manifest);
......@@ -227,6 +228,44 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
}
};
/**
* 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((element, index, array) => {
return !element.excludeUntil || element.excludeUntil <= Date.now();
}).length;
};
/**
* Returns whether the current playlist is the lowest rendition
*
* @return {Boolean} true if on lowest rendition
*/
loader.isLowestEnabledRendition_ = function() {
if (!loader.media()) {
return false;
}
let currentPlaylist = loader.media().attributes.BANDWIDTH;
return !(loader.master.playlists.filter((element, index, array) => {
let enabled = typeof element.excludeUntil === 'undefined' ||
element.excludeUntil <= Date.now();
if (!enabled) {
return false;
}
let item = element.attributes.BANDWIDTH;
return item <= currentPlaylist;
}).length > 1);
};
/**
* When called without any arguments, returns the currently
* active media playlist. When called with a single argument,
......
......@@ -140,7 +140,6 @@ export default class SegmentLoader extends videojs.EventTarget {
this.seeking_ = settings.seeking;
this.setCurrentTime_ = settings.setCurrentTime;
this.mediaSource_ = settings.mediaSource;
this.withCredentials_ = settings.withCredentials;
this.checkBufferTimeout_ = null;
this.error_ = void 0;
this.expired_ = 0;
......@@ -150,6 +149,7 @@ export default class SegmentLoader extends videojs.EventTarget {
this.pendingSegment_ = null;
this.sourceUpdater_ = null;
this.hls_ = settings.hls;
this.xhrOptions_ = null;
}
/**
......@@ -238,8 +238,10 @@ export default class SegmentLoader extends videojs.EventTarget {
*
* @param {PlaylistLoader} media the playlist to set on the segment loader
*/
playlist(media) {
playlist(media, options = {}) {
this.playlist_ = media;
this.xhrOptions_ = options;
// if we were unpaused but waiting for a playlist, start
// buffering now
if (this.sourceUpdater_ &&
......@@ -506,7 +508,6 @@ export default class SegmentLoader extends videojs.EventTarget {
*/
loadSegment_(segmentInfo) {
let segment;
let requestTimeout;
let keyXhr;
let segmentXhr;
let seekable = this.seekable_();
......@@ -534,27 +535,25 @@ export default class SegmentLoader extends videojs.EventTarget {
}
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
// Set xhr timeout to 150% of the segment duration to allow us
// some time to switch renditions in the event of a catastrophic
// decrease in network performance or a server issue.
requestTimeout = (segment.duration * 1.5) * 1000;
if (segment.key) {
keyXhr = this.hls_.xhr({
let keyRequestOptions = videojs.mergeOptions(this.xhrOptions_, {
uri: segment.key.resolvedUri,
responseType: 'arraybuffer',
withCredentials: this.withCredentials_,
timeout: requestTimeout
}, this.handleResponse_.bind(this));
responseType: 'arraybuffer'
});
keyXhr = this.hls_.xhr(keyRequestOptions, this.handleResponse_.bind(this));
}
this.pendingSegment_ = segmentInfo;
segmentXhr = this.hls_.xhr({
let segmentRequestOptions = videojs.mergeOptions(this.xhrOptions_, {
uri: segmentInfo.uri,
responseType: 'arraybuffer',
withCredentials: this.withCredentials_,
timeout: requestTimeout,
headers: segmentXhrHeaders(segment)
}, this.handleResponse_.bind(this));
});
segmentXhr = this.hls_.xhr(segmentRequestOptions, this.handleResponse_.bind(this));
this.xhr_ = {
keyXhr,
......
......@@ -484,6 +484,40 @@ QUnit.test('updates the duration after switching playlists', function() {
'16 bytes downloaded');
});
QUnit.test('removes request timeout when segment timesout on lowest rendition',
function() {
this.masterPlaylistController.mediaSource.trigger('sourceopen');
// master
standardXHRResponse(this.requests[0]);
// media
standardXHRResponse(this.requests[1]);
QUnit.equal(this.masterPlaylistController.requestOptions_.timeout,
this.masterPlaylistController.masterPlaylistLoader_.targetDuration * 1.5 *
1000,
'default request timeout');
QUnit.ok(!this.masterPlaylistController
.masterPlaylistLoader_
.isLowestEnabledRendition_(), 'Not lowest rendition');
// Cause segment to timeout to force player into lowest rendition
this.requests[2].timedout = true;
// Downloading segment should cause media change and timeout removal
// segment 0
standardXHRResponse(this.requests[2]);
// Download new segment after media change
standardXHRResponse(this.requests[3]);
QUnit.ok(this.masterPlaylistController
.masterPlaylistLoader_.isLowestEnabledRendition_(), 'On lowest rendition');
QUnit.equal(this.masterPlaylistController.requestOptions_.timeout, 0,
'request timeout 0');
});
QUnit.test('seekable uses the intersection of alternate audio and combined tracks',
function() {
let origSeekable = Playlist.seekable;
......
......@@ -121,6 +121,48 @@ QUnit.test('resolves relative media playlist URIs', function() {
'resolved media URI');
});
QUnit.test('playlist loader returns the correct amount of enabled playlists', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'video1/media.m3u8\n' +
'#EXT-X-STREAM-INF:\n' +
'video2/media.m3u8\n');
QUnit.equal(loader.enabledPlaylists_(), 2, 'Returned initial amount of playlists');
loader.master.playlists[0].excludeUntil = Date.now() + 100000;
this.clock.tick(1000);
QUnit.equal(loader.enabledPlaylists_(), 1, 'Returned one less playlist');
});
QUnit.test('playlist loader detects if we are on lowest rendition', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'video1/media.m3u8\n' +
'#EXT-X-STREAM-INF:\n' +
'video2/media.m3u8\n');
loader.media = function() {
return {attributes: {BANDWIDTH: 10}};
};
loader.master.playlists = [{attributes: {BANDWIDTH: 10}},
{attributes: {BANDWIDTH: 20}}];
QUnit.ok(loader.isLowestEnabledRendition_(), 'Detected on lowest rendition');
loader.media = function() {
return {attributes: {BANDWIDTH: 20}};
};
QUnit.ok(!loader.isLowestEnabledRendition_(), 'Detected not on lowest rendition');
});
QUnit.test('recognizes absolute URIs and requests them unmodified', function() {
let loader = new PlaylistLoader('manifest/media.m3u8', this.fakeHls);
......
......@@ -647,77 +647,6 @@ QUnit.test('live playlists do not trigger ended', function() {
QUnit.equal(loader.mediaRequests, 1, '1 request');
});
QUnit.test('respects the global withCredentials option', function() {
let hlsOptions = videojs.options.hls;
videojs.options.hls = {
withCredentials: true
};
loader = new SegmentLoader({
hls: this.fakeHls,
currentTime() {
return currentTime;
},
seekable: () => this.seekable,
mediaSource
});
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key');
QUnit.ok(this.requests[0].withCredentials, 'key request used withCredentials');
QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment');
QUnit.ok(this.requests[1].withCredentials, 'segment request used withCredentials');
videojs.options.hls = hlsOptions;
});
QUnit.test('respects the withCredentials option', function() {
loader = new SegmentLoader({
hls: this.fakeHls,
currentTime() {
return currentTime;
},
seekable: () => this.seekable,
mediaSource,
withCredentials: true
});
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key');
QUnit.ok(this.requests[0].withCredentials, 'key request used withCredentials');
QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment');
QUnit.ok(this.requests[1].withCredentials, 'segment request used withCredentials');
});
QUnit.test('the withCredentials option overrides the global', function() {
let hlsOptions = videojs.options.hls;
videojs.options.hls = {
withCredentials: true
};
loader = new SegmentLoader({
hls: this.fakeHls,
currentTime() {
return currentTime;
},
mediaSource,
seekable: () => this.seekable,
withCredentials: false
});
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key');
QUnit.ok(!this.requests[0].withCredentials, 'overrode key request withCredentials');
QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment');
QUnit.ok(!this.requests[1].withCredentials, 'overrode segment request withCredentials');
videojs.options.hls = hlsOptions;
});
QUnit.test('remains ready if there are no segments', function() {
loader.playlist(playlistWithDuration(0));
loader.mimeType(this.mimeType);
......
......@@ -1219,6 +1219,25 @@ QUnit.test('if withCredentials global option is used, withCredentials is set on
videojs.options.hls = hlsOptions;
});
QUnit.test('the withCredentials option overrides the global default', function() {
let hlsOptions = videojs.options.hls;
this.player.dispose();
videojs.options.hls = {
withCredentials: true
};
this.player = createPlayer();
this.player.src({
src: 'http://example.com/media.m3u8',
type: 'application/vnd.apple.mpegurl',
withCredentials: false
});
openMediaSource(this.player, this.clock);
QUnit.ok(!this.requests[0].withCredentials,
'with credentials should be set to false if if overrode global option');
videojs.options.hls = hlsOptions;
});
QUnit.test('if mode global option is used, mode is set to global option', function() {
let hlsOptions = videojs.options.hls;
......