Browse Source

fix: Various fixes for llhls so that we start closer to live, and stay closer to live (#1201)

* Don't switch renditions when the pending rendition is the rendition we would switch to
* Don't switch renditions before playback starts for llhls
* Don't set seekable until all source buffers have been created
* Take into account parts and preload segments when during duration calculations
* Reset the segment loader on rendition change for live streams, still resync for vod
* Try to choose an independent first part if we have no buffered data
* Determine if we made a bad part guess for our segment download
refactor/media-groups
Brandon Casey 4 years ago
committed by GitHub
parent
commit
bf4a45811f
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 29
      src/master-playlist-controller.js
  2. 13
      src/playback-watcher.js
  3. 7
      src/playlist-loader.js
  4. 50
      src/playlist.js
  5. 74
      src/segment-loader.js
  6. 42
      src/sync-controller.js
  7. 111
      test/loader-common.js
  8. 19
      test/master-playlist-controller.test.js
  9. 55
      test/playback-watcher.test.js
  10. 147
      test/playlist-loader.test.js
  11. 137
      test/playlist.test.js
  12. 248
      test/segment-loader.test.js

29
src/master-playlist-controller.js

@ -47,8 +47,9 @@ const sumLoaderStat = function(stat) {
};
const shouldSwitchToMedia = function({
currentPlaylist,
buffered,
currentTime,
nextPlaylist,
forwardBuffer,
bufferLowWaterLine,
bufferHighWaterLine,
duration,
@ -73,15 +74,25 @@ const shouldSwitchToMedia = function({
return false;
}
// determine if current time is in a buffered range.
const isBuffered = Boolean(Ranges.findRange(buffered, currentTime).length);
// If the playlist is live, then we want to not take low water line into account.
// This is because in LIVE, the player plays 3 segments from the end of the
// playlist, and if `BUFFER_LOW_WATER_LINE` is greater than the duration availble
// in those segments, a viewer will never experience a rendition upswitch.
if (!currentPlaylist.endList) {
// For LLHLS live streams, don't switch renditions before playback has started, as it almost
// doubles the time to first playback.
if (!isBuffered && typeof currentPlaylist.partTargetDuration === 'number') {
log(`not ${sharedLogLine} as current playlist is live llhls, but currentTime isn't in buffered.`);
return false;
}
log(`${sharedLogLine} as current playlist is live`);
return true;
}
const forwardBuffer = Ranges.timeAheadOf(buffered, currentTime);
const maxBufferLowWaterLine = experimentalBufferBasedABR ?
Config.EXPERIMENTAL_MAX_BUFFER_LOW_WATER_LINE : Config.MAX_BUFFER_LOW_WATER_LINE;
@ -732,18 +743,18 @@ export class MasterPlaylistController extends videojs.EventTarget {
}
shouldSwitchToMedia_(nextPlaylist) {
const currentPlaylist = this.masterPlaylistLoader_.media();
const buffered = this.tech_.buffered();
const forwardBuffer = buffered.length ?
buffered.end(buffered.length - 1) - this.tech_.currentTime() : 0;
const currentPlaylist = this.masterPlaylistLoader_.media() ||
this.masterPlaylistLoader_.pendingMedia_;
const currentTime = this.tech_.currentTime();
const bufferLowWaterLine = this.bufferLowWaterLine();
const bufferHighWaterLine = this.bufferHighWaterLine();
const buffered = this.tech_.buffered();
return shouldSwitchToMedia({
buffered,
currentTime,
currentPlaylist,
nextPlaylist,
forwardBuffer,
bufferLowWaterLine,
bufferHighWaterLine,
duration: this.duration(),
@ -1434,7 +1445,9 @@ export class MasterPlaylistController extends videojs.EventTarget {
onSyncInfoUpdate_() {
let audioSeekable;
if (!this.masterPlaylistLoader_) {
// If we have two source buffers and only one is created then the seekable range will be incorrect.
// We should wait until all source buffers are created.
if (!this.masterPlaylistLoader_ || this.sourceUpdater_.hasCreatedSourceBuffers()) {
return;
}

13
src/playback-watcher.js

@ -351,10 +351,15 @@ export default class PlaybackWatcher {
const buffered = this.tech_.buffered();
const audioBuffered = sourceUpdater.audioBuffer ? sourceUpdater.audioBuffered() : null;
const videoBuffered = sourceUpdater.videoBuffer ? sourceUpdater.videoBuffered() : null;
const media = this.media();
// verify that at least two segment durations or one part duration have been
// appended before checking for a gap.
const minAppendedDuration = media.partTargetDuration ? media.partTargetDuration :
(media.targetDuration - Ranges.TIME_FUDGE_FACTOR) * 2;
// verify that at least two segment durations have been
// appended before checking for a gap.
const twoSegmentDurations = (this.media().targetDuration - Ranges.TIME_FUDGE_FACTOR) * 2;
const bufferedToCheck = [audioBuffered, videoBuffered];
for (let i = 0; i < bufferedToCheck.length; i++) {
@ -365,9 +370,9 @@ export default class PlaybackWatcher {
const timeAhead = Ranges.timeAheadOf(bufferedToCheck[i], currentTime);
// if we are less than two video/audio segment durations behind,
// we haven't appended enough to call this a bad seek.
if (timeAhead < twoSegmentDurations) {
// if we are less than two video/audio segment durations or one part
// duration behind we haven't appended enough to call this a bad seek.
if (timeAhead < minAppendedDuration) {
return false;
}
}

7
src/playlist-loader.js

@ -257,7 +257,8 @@ const getAllSegments = function(media) {
export const isPlaylistUnchanged = (a, b) => a === b ||
(a.segments && b.segments && a.segments.length === b.segments.length &&
a.endList === b.endList &&
a.mediaSequence === b.mediaSequence);
a.mediaSequence === b.mediaSequence &&
a.preloadSegment === b.preloadSegment);
/**
* Returns a new master playlist that is the result of merging an
@ -516,6 +517,8 @@ export default class PlaylistLoader extends EventTarget {
this.targetDuration = playlist.partTargetDuration || playlist.targetDuration;
this.pendingMedia_ = null;
if (update) {
this.master = update;
this.media_ = this.master.playlists[id];
@ -662,6 +665,8 @@ export default class PlaylistLoader extends EventTarget {
this.trigger('mediachanging');
}
this.pendingMedia_ = playlist;
this.request = this.vhs_.xhr({
uri: playlist.resolvedUri,
withCredentials: this.withCredentials

50
src/playlist.js

@ -10,6 +10,43 @@ import {TIME_FUDGE_FACTOR} from './ranges.js';
const {createTimeRange} = videojs;
/**
* Get the duration of a segment, with special cases for
* llhls segments that do not have a duration yet.
*
* @param {Object} playlist
* the playlist that the segment belongs to.
* @param {Object} segment
* the segment to get a duration for.
*
* @return {number}
* the segment duration
*/
export const segmentDurationWithParts = (playlist, segment) => {
// if this isn't a preload segment
// then we will have a segment duration that is accurate.
if (!segment.preload) {
return segment.duration;
}
// otherwise we have to add up parts and preload hints
// to get an up to date duration.
let result = 0;
(segment.parts || []).forEach(function(p) {
result += p.duration;
});
// for preload hints we have to use partTargetDuration
// as they won't even have a duration yet.
(segment.preloadHints || []).forEach(function(p) {
if (p.type === 'PART') {
result += playlist.partTargetDuration;
}
});
return result;
};
/**
* A function to get a combined list of parts and segments with durations
* and indexes.
@ -117,7 +154,7 @@ const backwardDuration = function(playlist, endSequence) {
return { result: result + segment.end, precise: true };
}
result += segment.duration;
result += segmentDurationWithParts(playlist, segment);
if (typeof segment.start !== 'undefined') {
return { result: result + segment.start, precise: true };
@ -149,7 +186,7 @@ const forwardDuration = function(playlist, endSequence) {
};
}
result += segment.duration;
result += segmentDurationWithParts(playlist, segment);
if (typeof segment.end !== 'undefined') {
return {
@ -321,7 +358,7 @@ export const playlistEnd = function(playlist, expired, useSafeLiveEnd, liveEdgeP
expired = expired || 0;
let lastSegmentTime = intervalDuration(
let lastSegmentEndTime = intervalDuration(
playlist,
playlist.mediaSequence + playlist.segments.length,
expired
@ -329,11 +366,11 @@ export const playlistEnd = function(playlist, expired, useSafeLiveEnd, liveEdgeP
if (useSafeLiveEnd) {
liveEdgePadding = typeof liveEdgePadding === 'number' ? liveEdgePadding : liveEdgeDelay(null, playlist);
lastSegmentTime -= liveEdgePadding;
lastSegmentEndTime -= liveEdgePadding;
}
// don't return a time less than zero
return Math.max(0, lastSegmentTime);
return Math.max(0, lastSegmentEndTime);
};
/**
@ -737,5 +774,6 @@ export default {
estimateSegmentRequestTime,
isLowestEnabledRendition,
isAudioOnly,
playlistMatch
playlistMatch,
segmentDurationWithParts
};

74
src/segment-loader.js

@ -22,8 +22,7 @@ import {
import { gopsSafeToAlignWith, removeGopBuffer, updateGopBuffer } from './util/gops';
import shallowEqual from './util/shallow-equal.js';
import { QUOTA_EXCEEDED_ERR } from './error-codes';
import { timeRangesToArray } from './ranges';
import {lastBufferedEnd} from './ranges.js';
import {timeRangesToArray, lastBufferedEnd, timeAheadOf} from './ranges.js';
import {getKnownPartCount} from './playlist.js';
/**
@ -135,7 +134,7 @@ export const safeBackBufferTrimTime = (seekable, currentTime, targetDuration) =>
return Math.min(maxTrimTime, trimTime);
};
const segmentInfoString = (segmentInfo) => {
export const segmentInfoString = (segmentInfo) => {
const {
startOfSegment,
duration,
@ -160,6 +159,10 @@ const segmentInfoString = (segmentInfo) => {
selection = 'getSyncSegmentCandidate (isSyncRequest)';
}
if (segmentInfo.independent) {
selection += ` with independent ${segmentInfo.independent}`;
}
const hasPartIndex = typeof partIndex === 'number';
const name = segmentInfo.segment.uri ? 'segment' : 'pre-segment';
const zeroBasedPartCount = hasPartIndex ? getKnownPartCount({preloadSegment: segment}) - 1 : 0;
@ -1024,9 +1027,20 @@ export default class SegmentLoader extends videojs.EventTarget {
if (!oldPlaylist || oldPlaylist.uri !== newPlaylist.uri) {
if (this.mediaIndex !== null) {
// we must "resync" the segment loader when we switch renditions and
// we must reset/resync the segment loader when we switch renditions and
// the segment loader is already synced to the previous rendition
this.resyncLoader();
// on playlist changes we want it to be possible to fetch
// at the buffer for vod but not for live. So we use resetLoader
// for live and resyncLoader for vod. We want this because
// if a playlist uses independent and non-independent segments/parts the
// buffer may not accurately reflect the next segment that we should try
// downloading.
if (!newPlaylist.endList) {
this.resetLoader();
} else {
this.resyncLoader();
}
}
this.currentMediaInfo_ = void 0;
this.trigger('playlistupdate');
@ -1366,8 +1380,9 @@ export default class SegmentLoader extends videojs.EventTarget {
* @return {Object} a request object that describes the segment/part to load
*/
chooseNextRequest_() {
const bufferedEnd = lastBufferedEnd(this.buffered_()) || 0;
const bufferedTime = Math.max(0, bufferedEnd - this.currentTime_());
const buffered = this.buffered_();
const bufferedEnd = lastBufferedEnd(buffered) || 0;
const bufferedTime = timeAheadOf(buffered, this.currentTime_());
const preloaded = !this.hasPlayed_() && bufferedTime >= 1;
const haveEnoughBuffer = bufferedTime >= this.goalBufferLength_();
const segments = this.playlist_.segments;
@ -1420,14 +1435,15 @@ export default class SegmentLoader extends videojs.EventTarget {
startTime: this.syncPoint_.time
});
next.getMediaInfoForTime = this.fetchAtBuffer_ ? 'bufferedEnd' : 'currentTime';
next.getMediaInfoForTime = this.fetchAtBuffer_ ?
`bufferedEnd ${bufferedEnd}` : `currentTime ${this.currentTime_()}`;
next.mediaIndex = segmentIndex;
next.startOfSegment = startTime;
next.partIndex = partIndex;
}
const nextSegment = segments[next.mediaIndex];
const nextPart = nextSegment &&
let nextPart = nextSegment &&
typeof next.partIndex === 'number' &&
nextSegment.parts &&
nextSegment.parts[next.partIndex];
@ -1442,6 +1458,28 @@ export default class SegmentLoader extends videojs.EventTarget {
// Set partIndex to 0
if (typeof next.partIndex !== 'number' && nextSegment.parts) {
next.partIndex = 0;
nextPart = nextSegment.parts[0];
}
// if we have no buffered data then we need to make sure
// that the next part we append is "independent" if possible.
// So we check if the previous part is independent, and request
// it if it is.
if (!bufferedTime && nextPart && !nextPart.independent) {
if (next.partIndex === 0) {
const lastSegment = segments[next.mediaIndex - 1];
const lastSegmentLastPart = lastSegment.parts && lastSegment.parts.length && lastSegment.parts[lastSegment.parts.length - 1];
if (lastSegmentLastPart && lastSegmentLastPart.independent) {
next.mediaIndex -= 1;
next.partIndex = lastSegment.parts.length - 1;
next.independent = 'previous segment';
}
} else if (nextSegment.parts[next.partIndex - 1].independent) {
next.partIndex -= 1;
next.independent = 'previous part';
}
}
const ended = this.mediaSource_ && this.mediaSource_.readyState === 'ended';
@ -1459,6 +1497,7 @@ export default class SegmentLoader extends videojs.EventTarget {
generateSegmentInfo_(options) {
const {
independent,
playlist,
mediaIndex,
startOfSegment,
@ -1499,7 +1538,8 @@ export default class SegmentLoader extends videojs.EventTarget {
byteLength: 0,
transmuxer: this.transmuxer_,
// type of getMediaInfoForTime that was used to get this segment
getMediaInfoForTime
getMediaInfoForTime,
independent
};
const overrideCheck =
@ -1991,7 +2031,7 @@ export default class SegmentLoader extends videojs.EventTarget {
this.setTimeMapping_(segmentInfo.timeline);
// for tracking overall stats
this.updateMediaSecondsLoaded_(segmentInfo.segment);
this.updateMediaSecondsLoaded_(segmentInfo.part || segmentInfo.segment);
// Note that the state isn't changed from loading to appending. This is because abort
// logic may change behavior depending on the state, and changing state too early may
@ -2995,15 +3035,19 @@ export default class SegmentLoader extends videojs.EventTarget {
// and attempt to resync when the post-update seekable window and live
// point would mean that this was the perfect segment to fetch
this.trigger('syncinfoupdate');
const segment = segmentInfo.segment;
const part = segmentInfo.part;
const badSegmentGuess = segment.end &&
this.currentTime_() - segment.end > segmentInfo.playlist.targetDuration * 3;
const badPartGuess = part &&
part.end && this.currentTime_() - part.end > segmentInfo.playlist.partTargetDuration * 3;
// If we previously appended a segment that ends more than 3 targetDurations before
// If we previously appended a segment/part that ends more than 3 part/targetDurations before
// the currentTime_ that means that our conservative guess was too conservative.
// In that case, reset the loader state so that we try to use any information gained
// from the previous request to create a new, more accurate, sync-point.
if (segment.end &&
this.currentTime_() - segment.end > segmentInfo.playlist.targetDuration * 3) {
if (badSegmentGuess || badPartGuess) {
this.logger_(`bad ${badSegmentGuess ? 'segment' : 'part'} ${segmentInfoString(segmentInfo)}`);
this.resetEverything();
return;
}

42
src/sync-controller.js

@ -53,35 +53,33 @@ export const syncPointStrategies = [
const datetimeMapping =
syncController.timelineToDatetimeMappings[segment.timeline];
if (!datetimeMapping) {
if (!datetimeMapping || !segment.dateTimeObject) {
continue;
}
if (segment.dateTimeObject) {
const segmentTime = segment.dateTimeObject.getTime() / 1000;
let start = segmentTime + datetimeMapping;
const segmentTime = segment.dateTimeObject.getTime() / 1000;
let start = segmentTime + datetimeMapping;
// take part duration into account.
if (segment.parts && typeof partAndSegment.partIndex === 'number') {
for (let z = 0; z < partAndSegment.partIndex; z++) {
start += segment.parts[z].duration;
}
}
const distance = Math.abs(currentTime - start);
// Once the distance begins to increase, or if distance is 0, we have passed
// currentTime and can stop looking for better candidates
if (lastDistance !== null && (distance === 0 || lastDistance < distance)) {
break;
// take part duration into account.
if (segment.parts && typeof partAndSegment.partIndex === 'number') {
for (let z = 0; z < partAndSegment.partIndex; z++) {
start += segment.parts[z].duration;
}
}
const distance = Math.abs(currentTime - start);
lastDistance = distance;
syncPoint = {
time: start,
segmentIndex: partAndSegment.segmentIndex,
partIndex: partAndSegment.partIndex
};
// Once the distance begins to increase, or if distance is 0, we have passed
// currentTime and can stop looking for better candidates
if (lastDistance !== null && (distance === 0 || lastDistance < distance)) {
break;
}
lastDistance = distance;
syncPoint = {
time: start,
segmentIndex: partAndSegment.segmentIndex,
partIndex: partAndSegment.partIndex
};
}
return syncPoint;
}

111
test/loader-common.js

@ -835,6 +835,88 @@ export const LoaderCommonFactory = ({
});
});
QUnit.test('live rendition switch uses resetLoader', function(assert) {
return this.setupMediaSource(loader.mediaSource_, loader.sourceUpdater_).then(() => {
loader.playlist(playlistWithDuration(50, {
mediaSequence: 0,
endList: false
}));
loader.load();
loader.mediaIndex = 0;
let resyncCalled = false;
let resetCalled = false;
const origReset = loader.resetLoader;
const origResync = loader.resyncLoader;
loader.resetLoader = function() {
resetCalled = true;
return origReset.call(loader);
};
loader.resyncLoader = function() {
resyncCalled = true;
return origResync.call(loader);
};
const newPlaylist = playlistWithDuration(50, {
mediaSequence: 0,
endList: false
});
newPlaylist.uri = 'playlist2.m3u8';
loader.playlist(newPlaylist);
assert.true(resetCalled, 'reset was called');
assert.true(resyncCalled, 'resync was called');
return Promise.resolve();
});
});
QUnit.test('vod rendition switch uses resyncLoader', function(assert) {
return this.setupMediaSource(loader.mediaSource_, loader.sourceUpdater_).then(() => {
loader.playlist(playlistWithDuration(50, {
mediaSequence: 0,
endList: true
}));
loader.load();
loader.mediaIndex = 0;
let resyncCalled = false;
let resetCalled = false;
const origReset = loader.resetLoader;
const origResync = loader.resyncLoader;
loader.resetLoader = function() {
resetCalled = true;
return origReset.call(loader);
};
loader.resyncLoader = function() {
resyncCalled = true;
return origResync.call(loader);
};
const newPlaylist = playlistWithDuration(50, {
mediaSequence: 0,
endList: true
});
newPlaylist.uri = 'playlist2.m3u8';
loader.playlist(newPlaylist);
assert.true(resyncCalled, 'resync was called');
assert.false(resetCalled, 'reset was not called');
return Promise.resolve();
});
});
// only main/fmp4 segment loaders use async appends and parts/partIndex
if (usesAsyncAppends) {
let testFn = 'test';
@ -1275,6 +1357,35 @@ export const LoaderCommonFactory = ({
}
);
QUnit.test('chooses the previous part if not buffered and current is not independent', function(assert) {
loader.buffered_ = () => videojs.createTimeRanges();
const playlist = playlistWithDuration(50, {llhls: true});
loader.hasPlayed_ = () => true;
loader.syncPoint_ = null;
loader.playlist(playlist);
loader.load();
// force segmentIndex 4 and part 2 to be choosen
loader.currentTime_ = () => 46;
// make the previous part indepenent so we go back to it
playlist.segments[4].parts[1].independent = true;
const segmentInfo = loader.chooseNextRequest_();
assert.equal(segmentInfo.partIndex, 1, 'still chooses partIndex 1');
assert.equal(segmentInfo.mediaIndex, 4, 'same segment');
// force segmentIndex 4 and part 0 to be choosen
loader.currentTime_ = () => 42;
// make the previous part independent
playlist.segments[3].parts[4].independent = true;
const segmentInfo2 = loader.chooseNextRequest_();
assert.equal(segmentInfo2.partIndex, 4, 'previous part');
assert.equal(segmentInfo2.mediaIndex, 3, 'previous segment');
});
QUnit.test('processing segment reachable even after playlist update removes it', function(assert) {
const handleAppendsDone_ = loader.handleAppendsDone_.bind(loader);
let expectedURI = '0.ts';

19
test/master-playlist-controller.test.js

@ -6085,6 +6085,25 @@ QUnit.test('false without nextPlaylist', function(assert) {
this.env.log.warn.callCount = 0;
});
QUnit.test('false if llhls playlist and no buffered', function(assert) {
const mpc = this.masterPlaylistController;
mpc.masterPlaylistLoader_.media = () => ({id: 'foo', endList: false, partTargetDuration: 5});
const nextPlaylist = {id: 'bar', endList: false, partTargetDuration: 5};
assert.notOk(mpc.shouldSwitchToMedia_(nextPlaylist), 'should not switch when nothing is buffered');
});
QUnit.test('true if llhls playlist and we have buffered', function(assert) {
const mpc = this.masterPlaylistController;
mpc.tech_.buffered = () => videojs.createTimeRange([[0, 10]]);
mpc.masterPlaylistLoader_.media = () => ({id: 'foo', endList: false, partTargetDuration: 5});
const nextPlaylist = {id: 'bar', endList: false, partTargetDuration: 5};
assert.ok(mpc.shouldSwitchToMedia_(nextPlaylist), 'should switch if buffered');
});
QUnit.module('MasterPlaylistController blacklistCurrentPlaylist', sharedHooks);
QUnit.test("don't exclude only playlist unless it was excluded forever", function(assert) {

55
test/playback-watcher.test.js

@ -1207,6 +1207,61 @@ QUnit.test('dispose stops bad seek handling', function(assert) {
assert.equal(seeks.length, 0, 'no seeks');
});
QUnit.test('part target duration is used for append verification', function(assert) {
// target duration is 10 for this manifest
this.player.src({
src: 'liveStart30sBefore.m3u8',
type: 'application/vnd.apple.mpegurl'
});
// start playback normally
this.player.tech_.triggerReady();
this.clock.tick(1);
standardXHRResponse(this.requests.shift());
openMediaSource(this.player, this.clock);
this.player.tech_.trigger('play');
this.player.tech_.trigger('playing');
this.clock.tick(1);
const playbackWatcher = this.player.tech_.vhs.playbackWatcher_;
const seeks = [];
let currentTime;
let buffered;
playbackWatcher.seekable = () => videojs.createTimeRanges([[10, 100]]);
playbackWatcher.tech_ = {
off: () => {},
seeking: () => true,
setCurrentTime: (time) => {
seeks.push(time);
},
currentTime: () => currentTime,
buffered: () => buffered
};
Object.assign(playbackWatcher.masterPlaylistController_.sourceUpdater_, {
videoBuffer: true,
videoBuffered: () => buffered
});
this.player.tech(true).vhs.setCurrentTime = (time) => seeks.push(time);
const media = playbackWatcher.media();
media.partTargetDuration = 1.1;
playbackWatcher.media = () => media;
currentTime = 40;
buffered = videojs.createTimeRanges([[41, 42.1]]);
assert.ok(
playbackWatcher.fixesBadSeeks_(),
'acts when close enough to, and enough, buffer'
);
assert.equal(seeks.length, 1, 'seeked');
assert.equal(seeks[0], 41.1, 'player seeked to the start of the closer buffer');
});
const loaderTypes = ['audio', 'main', 'subtitle'];
const EXCLUDE_APPEND_COUNT = 10;

147
test/playlist-loader.test.js

@ -172,6 +172,7 @@ QUnit.module('Playlist Loader', function(hooks) {
}]
}]
};
const media = {
mediaSequence: 1,
attributes: {
@ -648,6 +649,152 @@ QUnit.module('Playlist Loader', function(hooks) {
);
});
QUnit.test('updateMaster detects preload segment changes', function(assert) {
const master = {
playlists: [{
mediaSequence: 0,
attributes: {
BANDWIDTH: 9
},
uri: 'playlist-0-uri',
id: 'playlist-0-uri',
resolvedUri: urlTo('playlist-0-uri'),
segments: [{
duration: 10,
uri: 'segment-0-uri',
resolvedUri: urlTo('segment-0-uri')
}],
preloadSegment: {
parts: [
{uri: 'part-0-uri'}
]
}
}]
};
const media = {
mediaSequence: 0,
attributes: {
BANDWIDTH: 9
},
uri: 'playlist-0-uri',
id: 'playlist-0-uri',
segments: [{
duration: 10,
uri: 'segment-0-uri'
}],
preloadSegment: {
parts: [
{uri: 'part-0-uri'},
{uri: 'part-1-uri'}
]
}
};
master.playlists['playlist-0-uri'] = master.playlists[0];
const result = updateMaster(master, media);
master.playlists[0].preloadSegment = media.preloadSegment;
assert.deepEqual(result, master, 'playlist updated');
});
QUnit.test('updateMaster detects preload segment addition', function(assert) {
const master = {
playlists: [{
mediaSequence: 0,
attributes: {
BANDWIDTH: 9
},
uri: 'playlist-0-uri',
id: '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',
id: 'playlist-0-uri',
segments: [{
duration: 10,
uri: 'segment-0-uri'
}],
preloadSegment: {
parts: [
{uri: 'part-0-uri'},
{uri: 'part-1-uri'}
]
}
};
master.playlists['playlist-0-uri'] = master.playlists[0];
const result = updateMaster(master, media);
master.playlists[0].preloadSegment = media.preloadSegment;
assert.deepEqual(result, master, 'playlist updated');
});
QUnit.test('updateMaster detects preload segment removal', function(assert) {
const master = {
playlists: [{
mediaSequence: 0,
attributes: {
BANDWIDTH: 9
},
uri: 'playlist-0-uri',
id: 'playlist-0-uri',
resolvedUri: urlTo('playlist-0-uri'),
segments: [{
duration: 10,
uri: 'segment-0-uri',
resolvedUri: urlTo('segment-0-uri')
}],
preloadSegment: {
parts: [
{uri: 'part-0-uri'},
{uri: 'part-1-uri'}
]
}
}]
};
const media = {
mediaSequence: 0,
attributes: {
BANDWIDTH: 9
},
uri: 'playlist-0-uri',
id: 'playlist-0-uri',
segments: [{
duration: 10,
uri: 'segment-1-uri',
parts: [
{uri: 'part-0-uri'},
{uri: 'part-1-uri'}
]
}]
};
master.playlists['playlist-0-uri'] = master.playlists[0];
const result = updateMaster(master, media);
master.playlists[0].preloadSegment = media.preloadSegment;
assert.deepEqual(result, master, 'playlist updated');
});
QUnit.test('uses last segment duration for refresh delay', function(assert) {
const media = { targetDuration: 7, segments: [] };

137
test/playlist.test.js

@ -282,6 +282,96 @@ QUnit.module('Playlist', function() {
assert.equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero');
});
QUnit.test('accounts for preload segment part durations', function(assert) {
const duration = Playlist.duration({
mediaSequence: 10,
endList: true,
segments: [{
duration: 10,
uri: '0.ts'
}, {
duration: 10,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
duration: 10,
uri: '3.ts'
}, {
preload: true,
parts: [
{duration: 2},
{duration: 2},
{duration: 2}
]
}]
});
assert.equal(duration, 46, 'includes segments and parts');
});
QUnit.test('accounts for preload segment part and preload hint durations', function(assert) {
const duration = Playlist.duration({
mediaSequence: 10,
endList: true,
partTargetDuration: 2,
segments: [{
duration: 10,
uri: '0.ts'
}, {
duration: 10,
uri: '1.ts'
}, {
duration: 10,
uri: '2.ts'
}, {
duration: 10,
uri: '3.ts'
}, {
preload: true,
parts: [
{duration: 2},
{duration: 2},
{duration: 2}
],
preloadHints: [
{type: 'PART'},
{type: 'MAP'}
]
}]
});
assert.equal(duration, 48, 'includes segments, parts, and hints');
});
QUnit.test('looks forward for llhls durations', function(assert) {
const playlist = {
mediaSequence: 12,
partTargetDuration: 3,
segments: [{
duration: 10,
uri: '0.ts'
}, {
duration: 9,
uri: '1.ts'
}, {
end: 40,
preload: true,
parts: [
{duration: 3}
],
preloadHints: [
{type: 'PART'}
]
}]
};
const duration = Playlist.duration(playlist, playlist.mediaSequence);
assert.equal(duration, 15, 'used llhls part/preload durations');
});
QUnit.module('Seekable');
QUnit.test('calculates seekable time ranges from available segments', function(assert) {
@ -1613,4 +1703,51 @@ QUnit.module('Playlist', function() {
});
});
QUnit.module('segmentDurationWithParts');
QUnit.test('uses normal segment duration', function(assert) {
const duration = Playlist.segmentDurationWithParts(
{},
{duration: 5}
);
assert.equal(duration, 5, 'duration as expected');
});
QUnit.test('preload segment without parts or preload hints', function(assert) {
const duration = Playlist.segmentDurationWithParts(
{partTargetDuration: 1},
{preload: true}
);
assert.equal(duration, 0, 'duration as expected');
});
QUnit.test('preload segment with parts only', function(assert) {
const duration = Playlist.segmentDurationWithParts(
{partTargetDuration: 1},
{preload: true, parts: [{duration: 1}, {duration: 1}]}
);
assert.equal(duration, 2, 'duration as expected');
});
QUnit.test('preload segment with preload hints only', function(assert) {
const duration = Playlist.segmentDurationWithParts(
{partTargetDuration: 1},
{preload: true, preloadHints: [{type: 'PART'}, {type: 'PART'}, {type: 'MAP'}]}
);
assert.equal(duration, 2, 'duration as expected');
});
QUnit.test('preload segment with preload hints and parts', function(assert) {
const duration = Playlist.segmentDurationWithParts(
{partTargetDuration: 1},
{preload: true, parts: [{duration: 1}], preloadHints: [{type: 'PART'}, {type: 'PART'}, {type: 'MAP'}]}
);
assert.equal(duration, 3, 'duration as expected');
});
});

248
test/segment-loader.test.js

@ -8,7 +8,8 @@ import {
segmentTooLong,
mediaDuration,
getTroublesomeSegmentDurationMessage,
getSyncSegmentCandidate
getSyncSegmentCandidate,
segmentInfoString
} from '../src/segment-loader';
import videojs from 'video.js';
import mp4probe from 'mux.js/lib/mp4/probe';
@ -779,6 +780,251 @@ QUnit.test('info segment is bit too long', function(assert) {
);
});
QUnit.module('segmentInfoString');
QUnit.test('all possible information', function(assert) {
const segment = {
uri: 'foo',
parts: [
{start: 0, end: 1, duration: 1},
{start: 1, end: 2, duration: 1},
{start: 2, end: 3, duration: 1},
{start: 4, end: 5, duration: 1},
{start: 5, end: 6, duration: 1}
],
start: 0,
end: 6
};
const segmentInfo = {
startOfSegment: 1,
duration: 5,
segment,
part: segment.parts[0],
playlist: {
mediaSequence: 0,
id: 'playlist-id',
segments: [segment]
},
mediaIndex: 0,
partIndex: 0,
timeline: 0,
independent: 'previous part',
getMediaInfoForTime: 'bufferedEnd 0'
};
const expected =
'segment [0/0] ' +
'part [0/4] ' +
'segment start/end [0 => 6] ' +
'part start/end [0 => 1] ' +
'startOfSegment [1] ' +
'duration [5] ' +
'timeline [0] ' +
'selected by [getMediaInfoForTime (bufferedEnd 0) with independent previous part] ' +
'playlist [playlist-id]';
assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value');
});
QUnit.test('mediaIndex selection', function(assert) {
const segment = {
uri: 'foo',
parts: [
{start: 0, end: 1, duration: 1},
{start: 1, end: 2, duration: 1},
{start: 2, end: 3, duration: 1},
{start: 4, end: 5, duration: 1},
{start: 5, end: 6, duration: 1}
],
start: 0,
end: 6
};
const segmentInfo = {
startOfSegment: 1,
duration: 5,
segment,
part: segment.parts[0],
playlist: {
mediaSequence: 0,
id: 'playlist-id',
segments: [segment]
},
mediaIndex: 0,
partIndex: 0,
timeline: 0
};
const expected =
'segment [0/0] ' +
'part [0/4] ' +
'segment start/end [0 => 6] ' +
'part start/end [0 => 1] ' +
'startOfSegment [1] ' +
'duration [5] ' +
'timeline [0] ' +
'selected by [mediaIndex/partIndex increment] ' +
'playlist [playlist-id]';
assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value');
});
QUnit.test('sync request selection', function(assert) {
const segment = {
uri: 'foo',
parts: [
{start: 0, end: 1, duration: 1},
{start: 1, end: 2, duration: 1},
{start: 2, end: 3, duration: 1},
{start: 4, end: 5, duration: 1},
{start: 5, end: 6, duration: 1}
],
start: 0,
end: 6
};
const segmentInfo = {
startOfSegment: 1,
duration: 5,
segment,
part: segment.parts[0],
playlist: {
mediaSequence: 0,
id: 'playlist-id',
segments: [segment]
},
mediaIndex: 0,
partIndex: 0,
timeline: 0,
isSyncRequest: true
};
const expected =
'segment [0/0] ' +
'part [0/4] ' +
'segment start/end [0 => 6] ' +
'part start/end [0 => 1] ' +
'startOfSegment [1] ' +
'duration [5] ' +
'timeline [0] ' +
'selected by [getSyncSegmentCandidate (isSyncRequest)] ' +
'playlist [playlist-id]';
assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value');
});
QUnit.test('preload segment', function(assert) {
const segment = {
parts: [
{start: 0, end: 1, duration: 1},
{start: 1, end: 2, duration: 1},
{start: 2, end: 3, duration: 1},
{start: 4, end: 5, duration: 1},
{start: 5, end: 6, duration: 1}
],
start: 0,
end: 6
};
const segmentInfo = {
startOfSegment: 1,
duration: 5,
segment,
part: segment.parts[0],
playlist: {
mediaSequence: 0,
id: 'playlist-id',
segments: [segment]
},
mediaIndex: 0,
partIndex: 0,
timeline: 0
};
const expected =
'pre-segment [0/0] ' +
'part [0/4] ' +
'segment start/end [0 => 6] ' +
'part start/end [0 => 1] ' +
'startOfSegment [1] ' +
'duration [5] ' +
'timeline [0] ' +
'selected by [mediaIndex/partIndex increment] ' +
'playlist [playlist-id]';
assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value');
});
QUnit.test('without parts', function(assert) {
const segment = {
start: 0,
end: 6
};
const segmentInfo = {
startOfSegment: 1,
duration: 5,
segment,
playlist: {
mediaSequence: 0,
id: 'playlist-id',
segments: [segment]
},
mediaIndex: 0,
timeline: 0
};
const expected =
'pre-segment [0/0] ' +
'segment start/end [0 => 6] ' +
'startOfSegment [1] ' +
'duration [5] ' +
'timeline [0] ' +
'selected by [mediaIndex/partIndex increment] ' +
'playlist [playlist-id]';
assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value');
});
QUnit.test('unknown start/end', function(assert) {
const segment = {
uri: 'foo',
parts: [
{start: null, end: null, duration: 1},
{start: null, end: null, duration: 1},
{start: null, end: null, duration: 1},
{start: null, end: null, duration: 1},
{start: null, end: null, duration: 1}
],
start: null,
end: null
};
const segmentInfo = {
startOfSegment: 1,
duration: 5,
segment,
part: segment.parts[0],
playlist: {
mediaSequence: 0,
id: 'playlist-id',
segments: [segment]
},
mediaIndex: 0,
partIndex: 0,
timeline: 0
};
const expected =
'segment [0/0] ' +
'part [0/4] ' +
'segment start/end [null => null] ' +
'part start/end [null => null] ' +
'startOfSegment [1] ' +
'duration [5] ' +
'timeline [0] ' +
'selected by [mediaIndex/partIndex increment] ' +
'playlist [playlist-id]';
assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value');
});
QUnit.module('SegmentLoader', function(hooks) {
hooks.beforeEach(LoaderCommonHooks.beforeEach);
hooks.afterEach(LoaderCommonHooks.afterEach);

Loading…
Cancel
Save