diff --git a/README.md b/README.md index 73f3f3f824e..b60af71aae8 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,11 @@ hls.js is written in [ECMAScript6], and transpiled in ECMAScript5 using [Babel]. ## Demo -[http://video-dev.github.io/hls.js/demo](http://video-dev.github.io/hls.js/demo) +### Latest Release +[https://video-dev.github.io/hls.js/demo](https://video-dev.github.io/hls.js/demo) + +### Canary +[https://video-dev.github.io/hls.js/demo?canary=true](https://video-dev.github.io/hls.js/demo?canary=true) ## Getting Started @@ -84,7 +88,8 @@ HTMLVideoElement control and events could be used seamlessly. |[](https://www.snapstream.com/)|[](https://www.streamamg.com/)|[](https://streamshark.io/)|[](http://my.tablotv.com/)| |[](https://www.streamroot.io/)|[](https://www.ted.com/)|[](https://twitter.com/)|[](http://vwflow.com)| |[](https://www.viacom.com/)|[](https://vk.com/)|[](https://www.jwplayer.com)|[](https://www.france.tv)| -|[](https://tech.showmax.com)|[](https://www.1tv.ru/) | | | +|[](https://tech.showmax.com)|[](https://www.1tv.ru/) | [](https://www.zdf.de) | | + @@ -100,6 +105,7 @@ hls.js is (being) integrated in the following players: - [Videojs](http://videojs.com) through [Videojs-hlsjs](https://github.com/benjipott/videojs-hlsjs) - [Videojs](http://videojs.com) through [videojs-hls.js](https://github.com/streamroot/videojs-hls.js). hls.js is integrated as a SourceHandler -- new feature in Video.js 5. - [Videojs](http://videojs.com) through [videojs-contrib-hls.js](https://github.com/Peer5/videojs-contrib-hls.js). Production ready plug-in with full fallback compatibility built-in. + - [Fluid Player](https://www.fluidplayer.com) ## Chrome/Firefox integration diff --git a/demo/index.html b/demo/index.html index 3022d3b5f7d..051f6179c7a 100644 --- a/demo/index.html +++ b/demo/index.html @@ -12,10 +12,11 @@ + + - @@ -135,7 +136,7 @@


-      
+
       
@@ -289,10 +290,27 @@

Buffer & Statistics

- - - - - + diff --git a/docs/API.md b/docs/API.md index f81d2a7d2ea..4c50798498f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -560,7 +560,7 @@ When set, use this level as the default hls.startLevel. Keep in mind that the st ### `fragLoadingTimeOut` / `manifestLoadingTimeOut` / `levelLoadingTimeOut` -(default: 60000ms for fragment / 10000ms for level and manifest) +(default: 20000ms for fragment / 10000ms for level and manifest) URL Loader timeout. A timeout callback will be triggered if loading duration exceeds this timeout. diff --git a/scripts/travis.sh b/scripts/travis.sh index b773e61aa0b..72efe902b77 100755 --- a/scripts/travis.sh +++ b/scripts/travis.sh @@ -49,6 +49,8 @@ elif [ "${TRAVIS_MODE}" = "releaseCanary" ]; then echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc npm publish --tag canary echo "Published canary." + curl https://purge.jsdelivr.net/npm/hls.js@canary + echo "Cleared jsdelivr cache." else echo "Canary already published." fi diff --git a/src/controller/audio-stream-controller.js b/src/controller/audio-stream-controller.js index 5b06c919f5f..5fd9544a571 100644 --- a/src/controller/audio-stream-controller.js +++ b/src/controller/audio-stream-controller.js @@ -62,11 +62,13 @@ class AudioStreamController extends TaskLoop { onHandlerDestroying () { this.stopLoad(); + super.onHandlerDestroying(); } onHandlerDestroyed () { this.state = State.STOPPED; this.fragmentTracker = null; + super.onHandlerDestroyed(); } // Signal that video PTS was found diff --git a/src/controller/fragment-finders.js b/src/controller/fragment-finders.js new file mode 100644 index 00000000000..aeb93edcb8b --- /dev/null +++ b/src/controller/fragment-finders.js @@ -0,0 +1,115 @@ +import BinarySearch from '../utils/binary-search'; + +/** + * Calculates the PDT of the next load position. + * bufferEnd in this function is usually the position of the playhead. + * @param {number} [start = 0] - The PTS of the first fragment within the level + * @param {number} [bufferEnd = 0] - The end of the contiguous buffered range the playhead is currently within + * @param {*} levelDetails - An object containing the parsed and computed properties of the currently playing level + * @returns {number} nextPdt - The computed PDT + */ +export function calculateNextPDT (start = 0, bufferEnd = 0, levelDetails) { + let pdt = 0; + if (levelDetails.programDateTime) { + const parsedDateInt = Date.parse(levelDetails.programDateTime); + if (!isNaN(parsedDateInt)) { + pdt = (bufferEnd * 1000) + parsedDateInt - (1000 * start); + } + } + return pdt; +} + +/** + * Finds the first fragment whose endPDT value exceeds the given PDT. + * @param {Array} fragments - The array of candidate fragments + * @param {number|null} [PDTValue = null] - The PDT value which must be exceeded + * @returns {*|null} fragment - The best matching fragment + */ +export function findFragmentByPDT (fragments, PDTValue = null) { + if (!Array.isArray(fragments) || !fragments.length || PDTValue === null) { + return null; + } + + // if less than start + let firstSegment = fragments[0]; + + if (PDTValue < firstSegment.pdt) { + return null; + } + + let lastSegment = fragments[fragments.length - 1]; + + if (PDTValue >= lastSegment.endPdt) { + return null; + } + + for (let seg = 0; seg < fragments.length; ++seg) { + let frag = fragments[seg]; + if (PDTValue < frag.endPdt) { + return frag; + } + } + return null; +} + +/** + * Finds a fragment based on the SN of the previous fragment; or based on the needs of the current buffer. + * This method compensates for small buffer gaps by applying a tolerance to the start of any candidate fragment, thus + * breaking any traps which would cause the same fragment to be continuously selected within a small range. + * @param {*} fragPrevious - The last frag successfully appended + * @param {Array} fragments - The array of candidate fragments + * @param {number} [bufferEnd = 0] - The end of the contiguous buffered range the playhead is currently within + * @param {number} [end = 0] - The computed end time of the stream + * @param {number} maxFragLookUpTolerance - The amount of time that a fragment's start can be within in order to be considered contiguous + * @returns {*} foundFrag - The best matching fragment + */ +export function findFragmentBySN (fragPrevious, fragments, bufferEnd = 0, end = 0, maxFragLookUpTolerance = 0) { + let foundFrag; + const fragNext = fragPrevious ? fragments[fragPrevious.sn - fragments[0].sn + 1] : null; + if (bufferEnd < end) { + if (bufferEnd > end - maxFragLookUpTolerance) { + maxFragLookUpTolerance = 0; + } + + // Prefer the next fragment if it's within tolerance + if (fragNext && !fragmentWithinToleranceTest(bufferEnd, maxFragLookUpTolerance, fragNext)) { + foundFrag = fragNext; + } else { + foundFrag = BinarySearch.search(fragments, fragmentWithinToleranceTest.bind(null, bufferEnd, maxFragLookUpTolerance)); + } + } + return foundFrag; +} + +/** + * The test function used by the findFragmentBySn's BinarySearch to look for the best match to the current buffer conditions. + * @param {*} candidate - The fragment to test + * @param {number} [bufferEnd = 0] - The end of the current buffered range the playhead is currently within + * @param {number} [maxFragLookUpTolerance = 0] - The amount of time that a fragment's start can be within in order to be considered contiguous + * @returns {number} - 0 if it matches, 1 if too low, -1 if too high + */ +export function fragmentWithinToleranceTest (bufferEnd = 0, maxFragLookUpTolerance = 0, candidate) { + // offset should be within fragment boundary - config.maxFragLookUpTolerance + // this is to cope with situations like + // bufferEnd = 9.991 + // frag[Ø] : [0,10] + // frag[1] : [10,20] + // bufferEnd is within frag[0] range ... although what we are expecting is to return frag[1] here + // frag start frag start+duration + // |-----------------------------| + // <---> <---> + // ...--------><-----------------------------><---------.... + // previous frag matching fragment next frag + // return -1 return 0 return 1 + // logger.log(`level/sn/start/end/bufEnd:${level}/${candidate.sn}/${candidate.start}/${(candidate.start+candidate.duration)}/${bufferEnd}`); + // Set the lookup tolerance to be small enough to detect the current segment - ensures we don't skip over very small segments + let candidateLookupTolerance = Math.min(maxFragLookUpTolerance, candidate.duration + (candidate.deltaPTS ? candidate.deltaPTS : 0)); + if (candidate.start + candidate.duration - candidateLookupTolerance <= bufferEnd) { + return 1; + } else if (candidate.start - candidateLookupTolerance > bufferEnd && candidate.start) { + // if maxFragLookUpTolerance will have negative value then don't return -1 for first element + return -1; + } + + return 0; +} diff --git a/src/controller/fragment-tracker.js b/src/controller/fragment-tracker.js index 47dcd190a0b..812ef128203 100644 --- a/src/controller/fragment-tracker.js +++ b/src/controller/fragment-tracker.js @@ -151,7 +151,7 @@ export class FragmentTracker extends EventHandler { } getFragmentKey (fragment) { - return `${fragment.type}_${fragment.level}_${fragment.sn}`; + return `${fragment.type}_${fragment.level}_${fragment.urlId}_${fragment.sn}`; } /** diff --git a/src/controller/stream-controller.js b/src/controller/stream-controller.js index 9f867962e95..1600850365b 100644 --- a/src/controller/stream-controller.js +++ b/src/controller/stream-controller.js @@ -15,6 +15,7 @@ import { ErrorTypes, ErrorDetails } from '../errors'; import { logger } from '../utils/logger'; import { alignDiscontinuities } from '../utils/discontinuities'; import TaskLoop from '../task-loop'; +import { calculateNextPDT, findFragmentByPDT, findFragmentBySN, fragmentWithinToleranceTest } from './fragment-finders'; export const State = { STOPPED: 'STOPPED', @@ -60,11 +61,13 @@ class StreamController extends TaskLoop { onHandlerDestroying () { this.stopLoad(); + super.onHandlerDestroying(); } onHandlerDestroyed () { this.state = State.STOPPED; this.fragmentTracker = null; + super.onHandlerDestroyed(); } startLoad (startPosition) { @@ -377,7 +380,7 @@ class StreamController extends TaskLoop { } } } else { // Relies on PDT in order to switch bitrates (Support EXT-X-DISCONTINUITY without EXT-X-DISCONTINUITY-SEQUENCE) - frag = this._findFragmentByPDT(fragments, fragPrevious.endPdt + 1); + frag = findFragmentByPDT(fragments, fragPrevious.endPdt + 1); } } if (!frag) { @@ -391,90 +394,24 @@ class StreamController extends TaskLoop { return frag; } - _findFragmentByPDT (fragments, PDTValue) { - if (!fragments || PDTValue === undefined) { - return null; - } - - // if less than start - let firstSegment = fragments[0]; - - if (PDTValue < firstSegment.pdt) { - return null; - } - - let lastSegment = fragments[fragments.length - 1]; - - if (PDTValue >= lastSegment.endPdt) { - return null; - } - - for (let seg = 0; seg < fragments.length; ++seg) { - let frag = fragments[seg]; - if (PDTValue < frag.endPdt) { - return frag; - } - } - return null; - } - - _findFragmentBySN (fragPrevious, fragments, bufferEnd, end) { - const config = this.hls.config; - let foundFrag; - let maxFragLookUpTolerance = config.maxFragLookUpTolerance; - const fragNext = fragPrevious ? fragments[fragPrevious.sn - fragments[0].sn + 1] : undefined; - let fragmentWithinToleranceTest = (candidate) => { - // offset should be within fragment boundary - config.maxFragLookUpTolerance - // this is to cope with situations like - // bufferEnd = 9.991 - // frag[Ø] : [0,10] - // frag[1] : [10,20] - // bufferEnd is within frag[0] range ... although what we are expecting is to return frag[1] here - // frag start frag start+duration - // |-----------------------------| - // <---> <---> - // ...--------><-----------------------------><---------.... - // previous frag matching fragment next frag - // return -1 return 0 return 1 - // logger.log(`level/sn/start/end/bufEnd:${level}/${candidate.sn}/${candidate.start}/${(candidate.start+candidate.duration)}/${bufferEnd}`); - // Set the lookup tolerance to be small enough to detect the current segment - ensures we don't skip over very small segments - let candidateLookupTolerance = Math.min(maxFragLookUpTolerance, candidate.duration + (candidate.deltaPTS ? candidate.deltaPTS : 0)); - if (candidate.start + candidate.duration - candidateLookupTolerance <= bufferEnd) { - return 1; - } else if (candidate.start - candidateLookupTolerance > bufferEnd && candidate.start) { - // if maxFragLookUpTolerance will have negative value then don't return -1 for first element - return -1; - } - - return 0; - }; - - if (bufferEnd < end) { - if (bufferEnd > end - maxFragLookUpTolerance) { - maxFragLookUpTolerance = 0; - } - - // Prefer the next fragment if it's within tolerance - if (fragNext && !fragmentWithinToleranceTest(fragNext)) { - foundFrag = fragNext; - } else { - foundFrag = BinarySearch.search(fragments, fragmentWithinToleranceTest); - } - } - return foundFrag; - } - _findFragment (start, fragPrevious, fragLen, fragments, bufferEnd, end, levelDetails) { const config = this.hls.config; + const fragBySN = () => findFragmentBySN(fragPrevious, fragments, bufferEnd, end, config.maxFragLookUpTolerance); let frag; let foundFrag; if (bufferEnd < end) { if (!levelDetails.programDateTime) { // Uses buffer and sequence number to calculate switch segment (required if using EXT-X-DISCONTINUITY-SEQUENCE) - foundFrag = this._findFragmentBySN(fragPrevious, fragments, bufferEnd, end); - } else { // Relies on PDT in order to switch bitrates (Support EXT-X-DISCONTINUITY without EXT-X-DISCONTINUITY-SEQUENCE) - // compute PDT of bufferEnd: PDT(bufferEnd) = 1000*bufferEnd + PDT(start) = 1000*bufferEnd + PDT(level) - level sliding - foundFrag = this._findFragmentByPDT(fragments, (bufferEnd * 1000) + (levelDetails.programDateTime ? Date.parse(levelDetails.programDateTime) : 0) - 1000 * start); + foundFrag = findFragmentBySN(fragPrevious, fragments, bufferEnd, end, config.maxFragLookUpTolerance); + } else { + // Relies on PDT in order to switch bitrates (Support EXT-X-DISCONTINUITY without EXT-X-DISCONTINUITY-SEQUENCE) + foundFrag = findFragmentByPDT(fragments, calculateNextPDT(start, bufferEnd, levelDetails)); + if (!foundFrag || fragmentWithinToleranceTest(bufferEnd, config.maxFragLookUpTolerance, foundFrag)) { + // Fall back to SN order if finding by PDT returns a frag which won't fit within the stream + // fragmentWithToleranceTest returns 0 if the frag is within tolerance; 1 or -1 otherwise + logger.warn('Frag found by PDT search did not fit within tolerance; falling back to finding by SN'); + foundFrag = fragBySN(); + } } } else { // reach end of playlist @@ -630,7 +567,7 @@ class StreamController extends TaskLoop { media decode error, check this, to avoid seeking back to wrong position after a media decode error */ - if (currentTime > video.playbackRate * this.lastCurrentTime) { + if (currentTime > this.lastCurrentTime) { this.lastCurrentTime = currentTime; } @@ -1423,6 +1360,7 @@ class StreamController extends TaskLoop { */ _checkBuffer () { const { config, media } = this; + const stallDebounceInterval = 1000; if (!media || media.readyState === 0) { // Exit early if we don't have media or if the media hasn't bufferd anything yet (readyState 0) return; @@ -1454,13 +1392,16 @@ class StreamController extends TaskLoop { } else if (expectedPlaying) { // The playhead isn't moving but it should be // Allow some slack time to for small stalls to resolve themselves + const stalledDuration = tnow - this.stalled; + const bufferInfo = BufferHelper.bufferInfo(media, currentTime, config.maxBufferHole); if (!this.stalled) { this.stalled = tnow; return; + } else if (stalledDuration >= stallDebounceInterval) { + // Report stalling after trying to fix + this._reportStall(bufferInfo.len); } - const bufferInfo = BufferHelper.bufferInfo(media, currentTime, config.maxBufferHole); - const stalledDuration = tnow - this.stalled; this._tryFixBufferStall(bufferInfo, stalledDuration); } } @@ -1512,7 +1453,6 @@ class StreamController extends TaskLoop { const currentTime = media.currentTime; const jumpThreshold = 0.5; // tolerance needed as some browsers stalls playback before reaching buffered range end - this._reportStall(bufferInfo.len); const partial = this.fragmentTracker.getPartialFragment(currentTime); if (partial) { // Try to skip over the buffer hole caused by a partial fragment diff --git a/src/loader/m3u8-parser.js b/src/loader/m3u8-parser.js index afad3f422f2..03f900c7de5 100644 --- a/src/loader/m3u8-parser.js +++ b/src/loader/m3u8-parser.js @@ -27,6 +27,8 @@ const LEVEL_PLAYLIST_REGEX_FAST = new RegExp([ const LEVEL_PLAYLIST_REGEX_SLOW = /(?:(?:#(EXTM3U))|(?:#EXT-X-(PLAYLIST-TYPE):(.+))|(?:#EXT-X-(MEDIA-SEQUENCE): *(\d+))|(?:#EXT-X-(TARGETDURATION): *(\d+))|(?:#EXT-X-(KEY):(.+))|(?:#EXT-X-(START):(.+))|(?:#EXT-X-(ENDLIST))|(?:#EXT-X-(DISCONTINUITY-SEQ)UENCE:(\d+))|(?:#EXT-X-(DIS)CONTINUITY))|(?:#EXT-X-(VERSION):(\d+))|(?:#EXT-X-(MAP):(.+))|(?:(#)(.*):(.*))|(?:(#)(.*))(?:.*)\r?\n?/; +const MP4_REGEX_SUFFIX = /\.(mp4|m4s|m4v|m4a)$/i; + export default class M3U8Parser { static findGroup (groups, mediaGroupId) { if (!groups) { @@ -142,7 +144,7 @@ export default class M3U8Parser { return medias; } - static parseLevelPlaylist (string, baseurl, id, type) { + static parseLevelPlaylist (string, baseurl, id, type, levelUrlId) { let currentSN = 0, totalduration = 0, level = { type: null, version: null, url: baseurl, fragments: [], live: true, startSN: 0 }, @@ -172,6 +174,7 @@ export default class M3U8Parser { frag.sn = sn; frag.level = id; frag.cc = cc; + frag.urlId = levelUrlId; frag.baseurl = baseurl; // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 frag.relurl = (' ' + result[3]).slice(1); @@ -313,9 +316,7 @@ export default class M3U8Parser { // this is a bit lurky but HLS really has no other way to tell us // if the fragments are TS or MP4, except if we download them :/ // but this is to be able to handle SIDX. - // FIXME: replace string test by a regex that matches - // also `m4s` `m4a` `m4v` and other popular extensions - if (level.fragments.every((frag) => frag.relurl.endsWith('.mp4'))) { + if (level.fragments.every((frag) => MP4_REGEX_SUFFIX.test(frag.relurl))) { logger.warn('MP4 fragments found but no init segment (probably no MAP, incomplete M3U8), trying to fetch SIDX'); frag = new Fragment(); diff --git a/src/loader/playlist-loader.js b/src/loader/playlist-loader.js index 5b935a09727..772748913e9 100644 --- a/src/loader/playlist-loader.js +++ b/src/loader/playlist-loader.js @@ -337,10 +337,11 @@ class PlaylistLoader extends EventHandler { const url = PlaylistLoader.getResponseUrl(response, context); - const levelId = !isNaN(level) ? level : !isNaN(id) ? id : 0; // level -> id -> 0 + const levelUrlId = isNaN(id) ? 0 : id; + const levelId = isNaN(level) ? levelUrlId : level; // level -> id -> 0 const levelType = PlaylistLoader.mapContextToLevelType(context); - const levelDetails = M3U8Parser.parseLevelPlaylist(response.data, url, levelId, levelType); + const levelDetails = M3U8Parser.parseLevelPlaylist(response.data, url, levelId, levelType, levelUrlId); // set stats on level structure levelDetails.tload = stats.tload; diff --git a/tests/functional/auto/setup.js b/tests/functional/auto/setup.js index 6945601d5bd..06433acd814 100644 --- a/tests/functional/auto/setup.js +++ b/tests/functional/auto/setup.js @@ -110,7 +110,9 @@ describe('testing hls.js playback in the browser on "' + browserDescription + '" this.browser = new webdriver.Builder(); } this.browser = this.browser.withCapabilities(capabilities).build(); - this.browser.manage().timeouts().setScriptTimeout(75000); + this.browser.manage().setTimeouts({ script: 75000 }).catch(function (err) { + console.log('setTimeouts: ' + err); + }); console.log('Retrieving web driver session...'); return this.browser.getSession().then(function (session) { console.log('Web driver session id: ' + session.getId()); diff --git a/tests/mocks/data.js b/tests/mocks/data.js new file mode 100644 index 00000000000..66e837f43d4 --- /dev/null +++ b/tests/mocks/data.js @@ -0,0 +1,48 @@ +export const mockFragments = [ + { + pdt: 1505502661523, + endPdt: 1505502666523, + level: 2, + duration: 5.000, + start: 0, + sn: 0, + cc: 0 + }, + // Discontinuity with PDT 1505502671523 which does not exist in level 1 as per fragPrevious + { + pdt: 1505502671523, + endPdt: 1505502676523, + level: 2, + duration: 5.000, + start: 5.000, + sn: 1, + cc: 1 + }, + { + pdt: 1505502676523, + endPdt: 1505502681523, + level: 2, + duration: 5.000, + start: 10.000, + sn: 2, + cc: 1 + }, + { + pdt: 1505502681523, + endPdt: 1505502686523, + level: 2, + duration: 5.000, + start: 15.000, + sn: 3, + cc: 1 + }, + { + pdt: 1505502686523, + endPdt: 1505502691523, + level: 2, + duration: 5.000, + start: 20.000, + sn: 4, + cc: 1 + } +]; diff --git a/tests/test-streams.js b/tests/test-streams.js index 47a0c212845..1926bf64cf9 100644 --- a/tests/test-streams.js +++ b/tests/test-streams.js @@ -153,5 +153,21 @@ module.exports = { 'description': 'Multiple non-alternate audio levels', 'live': false, 'abr': false + }, + pdtDuplicate: { + url: 'https://playertest.longtailvideo.com/adaptive/artbeats/manifest.m3u8', + description: 'Stream with duplicate sequential PDT values' + }, + pdtLargeGap: { + url: 'https://playertest.longtailvideo.com/adaptive/boxee/playlist.m3u8', + description: 'PDTs with large gaps following discontinuities' + }, + pdtBadValues: { + url: 'https://playertest.longtailvideo.com/adaptive/progdatime/playlist2.m3u8', + description: 'PDTs with bad values' + }, + pdtOneValue: { + url: 'https://playertest.longtailvideo.com/adaptive/aviion/manifest.m3u8', + description: 'One PDT, no discontinuities' } }; diff --git a/tests/unit/controller/check-buffer.js b/tests/unit/controller/check-buffer.js index f3c14ca7530..6538234cdf1 100644 --- a/tests/unit/controller/check-buffer.js +++ b/tests/unit/controller/check-buffer.js @@ -11,6 +11,8 @@ describe('checkBuffer', function () { let config; let media; let triggerSpy; + const sandbox = sinon.sandbox.create(); + beforeEach(function () { media = document.createElement('video'); const hls = new Hls({}); @@ -21,6 +23,10 @@ describe('checkBuffer', function () { triggerSpy = sinon.spy(hls, 'trigger'); }); + afterEach(function () { + sandbox.restore(); + }); + describe('_tryNudgeBuffer', function () { it('should increment the currentTime by a multiple of nudgeRetry and the configured nudge amount', function () { for (let i = 1; i < config.nudgeMaxRetry; i++) { @@ -66,52 +72,42 @@ describe('checkBuffer', function () { }); describe('_tryFixBufferStall', function () { - let reportStallSpy; - beforeEach(function () { - reportStallSpy = sinon.spy(streamController, '_reportStall'); - }); - it('should nudge when stalling close to the buffer end', function () { const mockBufferInfo = { len: 1 }; const mockStallDuration = (config.highBufferWatchdogPeriod + 1) * 1000; - const nudgeStub = sinon.stub(streamController, '_tryNudgeBuffer'); + const nudgeStub = sandbox.stub(streamController, '_tryNudgeBuffer'); streamController._tryFixBufferStall(mockBufferInfo, mockStallDuration); assert(nudgeStub.calledOnce); - assert(reportStallSpy.calledOnce); }); it('should not nudge when briefly stalling close to the buffer end', function () { const mockBufferInfo = { len: 1 }; const mockStallDuration = (config.highBufferWatchdogPeriod / 2) * 1000; - const nudgeStub = sinon.stub(streamController, '_tryNudgeBuffer'); + const nudgeStub = sandbox.stub(streamController, '_tryNudgeBuffer'); streamController._tryFixBufferStall(mockBufferInfo, mockStallDuration); assert(nudgeStub.notCalled); - assert(reportStallSpy.calledOnce); }); it('should not nudge when too far from the buffer end', function () { const mockBufferInfo = { len: 0.25 }; const mockStallDuration = (config.highBufferWatchdogPeriod + 1) * 1000; - const nudgeStub = sinon.stub(streamController, '_tryNudgeBuffer'); + const nudgeStub = sandbox.stub(streamController, '_tryNudgeBuffer'); streamController._tryFixBufferStall(mockBufferInfo, mockStallDuration); assert(nudgeStub.notCalled); - assert(reportStallSpy.calledOnce); }); it('should try to jump partial fragments when detected', function () { - sinon.stub(streamController.fragmentTracker, 'getPartialFragment').returns({}); - const skipHoleStub = sinon.stub(streamController, '_trySkipBufferHole'); + sandbox.stub(streamController.fragmentTracker, 'getPartialFragment').returns({}); + const skipHoleStub = sandbox.stub(streamController, '_trySkipBufferHole'); streamController._tryFixBufferStall({ len: 0 }); assert(skipHoleStub.calledOnce); - assert(reportStallSpy.calledOnce); }); it('should not try to jump partial fragments when none are detected', function () { - sinon.stub(streamController.fragmentTracker, 'getPartialFragment').returns(null); - const skipHoleStub = sinon.stub(streamController, '_trySkipBufferHole'); + sandbox.stub(streamController.fragmentTracker, 'getPartialFragment').returns(null); + const skipHoleStub = sandbox.stub(streamController, '_trySkipBufferHole'); streamController._tryFixBufferStall({ len: 0 }); assert(skipHoleStub.notCalled); - assert(reportStallSpy.calledOnce); }); }); @@ -132,6 +128,7 @@ describe('checkBuffer', function () { describe('_checkBuffer', function () { let mockMedia; + let reportStallSpy; beforeEach(function () { mockMedia = { readyState: 1, @@ -140,9 +137,12 @@ describe('checkBuffer', function () { } }; streamController.media = mockMedia; + reportStallSpy = sandbox.spy(streamController, '_reportStall'); }); function setExpectedPlaying () { + streamController.loadedmetadata = true; + streamController.immediateSwitch = false; mockMedia.paused = false; mockMedia.readyState = 4; mockMedia.currentTime = 4; @@ -155,14 +155,14 @@ describe('checkBuffer', function () { }); it('should seek to start pos when metadata has not yet been loaded', function () { - const seekStub = sinon.stub(streamController, '_seekToStartPos'); + const seekStub = sandbox.stub(streamController, '_seekToStartPos'); streamController._checkBuffer(); assert(seekStub.calledOnce); assert(streamController.loadedmetadata); }); it('should not seek to start pos when metadata has been loaded', function () { - const seekStub = sinon.stub(streamController, '_seekToStartPos'); + const seekStub = sandbox.stub(streamController, '_seekToStartPos'); streamController.loadedmetadata = true; streamController._checkBuffer(); assert(seekStub.notCalled); @@ -170,7 +170,7 @@ describe('checkBuffer', function () { }); it('should not seek to start pos when nothing has been buffered', function () { - const seekStub = sinon.stub(streamController, '_seekToStartPos'); + const seekStub = sandbox.stub(streamController, '_seekToStartPos'); mockMedia.buffered.length = 0; streamController._checkBuffer(); assert(seekStub.notCalled); @@ -178,7 +178,7 @@ describe('checkBuffer', function () { }); it('should complete the immediate switch if signalled', function () { - const levelSwitchStub = sinon.stub(streamController, 'immediateLevelSwitchEnd'); + const levelSwitchStub = sandbox.stub(streamController, 'immediateLevelSwitchEnd'); streamController.loadedmetadata = true; streamController.immediateSwitch = true; streamController._checkBuffer(); @@ -186,9 +186,7 @@ describe('checkBuffer', function () { }); it('should try to fix a stall if expected to be playing', function () { - streamController.loadedmetadata = true; - streamController.immediateSwitch = false; - const fixStallStub = sinon.stub(streamController, '_tryFixBufferStall'); + const fixStallStub = sandbox.stub(streamController, '_tryFixBufferStall'); setExpectedPlaying(); streamController._checkBuffer(); @@ -206,7 +204,7 @@ describe('checkBuffer', function () { streamController.nudgeRetry = 1; streamController.stalled = 4200; streamController.lastCurrentTime = 1; - const fixStallStub = sinon.stub(streamController, '_tryFixBufferStall'); + const fixStallStub = sandbox.stub(streamController, '_tryFixBufferStall'); streamController._checkBuffer(); assert.strictEqual(streamController.stalled, null); @@ -214,5 +212,17 @@ describe('checkBuffer', function () { assert.strictEqual(streamController.stallReported, false); assert(fixStallStub.notCalled); }); + + it('should trigger reportStall when stalling for 1 second or longer', function () { + setExpectedPlaying(); + const clock = sandbox.useFakeTimers(0); + clock.tick(1000); + streamController.stalled = 1; + streamController._checkBuffer(); + assert(reportStallSpy.notCalled); + clock.tick(1001); + streamController._checkBuffer(); + assert(reportStallSpy.calledOnce); + }); }); }); diff --git a/tests/unit/controller/fragment-finders.js b/tests/unit/controller/fragment-finders.js new file mode 100644 index 00000000000..c2c36fae4a8 --- /dev/null +++ b/tests/unit/controller/fragment-finders.js @@ -0,0 +1,178 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import { calculateNextPDT, findFragmentByPDT, findFragmentBySN, fragmentWithinToleranceTest } from '../../../src/controller/fragment-finders'; +import { mockFragments } from '../../mocks/data'; +import BinarySearch from '../../../src/utils/binary-search'; + +describe('Fragment finders', function () { + const sandbox = sinon.sandbox.create(); + afterEach(function () { + sandbox.restore(); + }); + + let fragPrevious = { + pdt: 1505502671523, + endPdt: 1505502676523, + duration: 5.000, + level: 1, + start: 10.000, + sn: 2, // Fragment with PDT 1505502671523 in level 1 does not have the same sn as in level 2 where cc is 1 + cc: 0 + }; + const bufferEnd = fragPrevious.start + fragPrevious.duration; + const end = mockFragments[mockFragments.length - 1].start + mockFragments[mockFragments.length - 1].duration; + + describe('findFragmentBySN', function () { + let tolerance = 0.25; + let binarySearchSpy; + beforeEach(function () { + binarySearchSpy = sandbox.spy(BinarySearch, 'search'); + }); + + it('finds a fragment with SN sequential to the previous fragment', function () { + const foundFragment = findFragmentBySN(fragPrevious, mockFragments, bufferEnd, end, tolerance); + const resultSN = foundFragment ? foundFragment.sn : -1; + assert.equal(foundFragment, mockFragments[3], 'Expected sn 3, found sn segment ' + resultSN); + assert(binarySearchSpy.notCalled); + }); + + it('chooses the fragment with the next SN if its contiguous with the end of the buffer', function () { + const actual = findFragmentBySN(mockFragments[0], mockFragments, mockFragments[0].duration, end, tolerance); + assert.strictEqual(mockFragments[1], actual, `expected sn ${mockFragments[1].sn}, but got sn ${actual ? actual.sn : null}`); + assert(binarySearchSpy.notCalled); + }); + + it('uses BinarySearch to find a fragment if the subsequent one is not within tolerance', function () { + const fragments = [mockFragments[0], mockFragments[(mockFragments.length - 1)]]; + findFragmentBySN(fragments[0], fragments, bufferEnd, end, tolerance); + assert(binarySearchSpy.calledOnce); + }); + }); + + describe('fragmentWithinToleranceTest', function () { + let tolerance = 0.25; + it('returns 0 if the fragment range is equal to the end of the buffer', function () { + const frag = { + start: 5, + duration: 5 - tolerance + }; + const actual = fragmentWithinToleranceTest(5, tolerance, frag); + assert.strictEqual(0, actual); + }); + + it('returns 0 if the fragment range is greater than end of the buffer', function () { + const frag = { + start: 5, + duration: 5 + }; + const actual = fragmentWithinToleranceTest(5, tolerance, frag); + assert.strictEqual(0, actual); + }); + + it('returns 1 if the fragment range is less than the end of the buffer', function () { + const frag = { + start: 0, + duration: 5 + }; + const actual = fragmentWithinToleranceTest(5, tolerance, frag); + assert.strictEqual(1, actual); + }); + + it('returns -1 if the fragment range is greater than the end of the buffer', function () { + const frag = { + start: 6, + duration: 5 + }; + const actual = fragmentWithinToleranceTest(5, tolerance, frag); + assert.strictEqual(-1, actual); + }); + + it('does not skip very small fragments', function () { + const frag = { + start: 0.2, + duration: 0.1, + deltaPTS: 0.1 + }; + const actual = fragmentWithinToleranceTest(frag, 0, 1); + assert.strictEqual(0, actual); + }); + }); + + describe('findFragmentByPDT', function () { + it('finds a fragment with endPdt greater than the reference PDT', function () { + const foundFragment = findFragmentByPDT(mockFragments, fragPrevious.endPdt + 1); + const resultSN = foundFragment ? foundFragment.sn : -1; + assert.strictEqual(foundFragment, mockFragments[2], 'Expected sn 2, found sn segment ' + resultSN); + }); + + it('returns null when the reference pdt is outside of the pdt range of the fragment array', function () { + let foundFragment = findFragmentByPDT(mockFragments, mockFragments[0].pdt - 1); + let resultSN = foundFragment ? foundFragment.sn : -1; + assert.strictEqual(foundFragment, null, 'Expected sn -1, found sn segment ' + resultSN); + + foundFragment = findFragmentByPDT(mockFragments, mockFragments[mockFragments.length - 1].endPdt + 1); + resultSN = foundFragment ? foundFragment.sn : -1; + assert.strictEqual(foundFragment, null, 'Expected sn -1, found sn segment ' + resultSN); + }); + + it('is able to find the first fragment', function () { + const foundFragment = findFragmentByPDT(mockFragments, mockFragments[0].pdt); + const resultSN = foundFragment ? foundFragment.sn : -1; + assert.strictEqual(foundFragment, mockFragments[0], 'Expected sn 0, found sn segment ' + resultSN); + }); + + it('is able to find the last fragment', function () { + const foundFragment = findFragmentByPDT(mockFragments, mockFragments[mockFragments.length - 1].pdt); + const resultSN = foundFragment ? foundFragment.sn : -1; + assert.strictEqual(foundFragment, mockFragments[4], 'Expected sn 4, found sn segment ' + resultSN); + }); + + it('is able to find a fragment if the PDT value is 0', function () { + const fragments = [ + { + pdt: 0, + endPdt: 1 + }, + { + pdt: 1, + endPdt: 2 + } + ]; + const actual = findFragmentByPDT(fragments, 0); + assert.strictEqual(fragments[0], actual); + }); + + it('returns null when passed undefined arguments', function () { + assert.strictEqual(findFragmentByPDT(mockFragments), null); + assert.strictEqual(findFragmentByPDT(undefined, 9001), null); + assert.strictEqual(findFragmentByPDT(), null); + }); + + it('returns null when passed an empty frag array', function () { + assert.strictEqual(findFragmentByPDT([], 9001), null); + }); + }); + + describe('calculateNextPDT', function () { + const levelDetails = { + programDateTime: '2012-12-06T19:10:03+00:00' + }; + + it('calculates based on levelDetails', function () { + const expected = 1354821003000 + (10 * 1000) - (1000 * 5); + const actual = calculateNextPDT(5, 10, levelDetails); + assert.strictEqual(expected, actual); + }); + + it('returns 0 if levelDetails does not have programDateTime', function () { + const actual = calculateNextPDT(5, 10, {}); + assert.strictEqual(0, actual); + }); + + it('returns 0 if the parsed PDT would be NaN', function () { + levelDetails.programDateTime = 'foo'; + const actual = calculateNextPDT(5, 10, levelDetails); + assert.strictEqual(0, actual); + }); + }); +}); diff --git a/tests/unit/controller/stream-controller.js b/tests/unit/controller/stream-controller.js index 0e02d541ede..2259a1cb415 100644 --- a/tests/unit/controller/stream-controller.js +++ b/tests/unit/controller/stream-controller.js @@ -5,6 +5,7 @@ import Event from '../../../src/events'; import { FragmentTracker, FragmentState } from '../../../src/controller/fragment-tracker'; import StreamController, { State } from '../../../src/controller/stream-controller'; import M3U8Parser from '../../../src/loader/m3u8-parser'; +import { mockFragments } from '../../mocks/data'; import Fragment from '../../../src/loader/fragment'; describe('StreamController tests', function () { @@ -88,152 +89,67 @@ describe('StreamController tests', function () { cc: 0 }; - let fragments = [ - { - pdt: 1505502661523, - endPdt: 1505502666523, - level: 2, - duration: 5.000, - start: 0, - sn: 0, - cc: 0 - }, - // Discontinuity with PDT 1505502671523 which does not exist in level 1 as per fragPrevious - { - pdt: 1505502671523, - endPdt: 1505502676523, - level: 2, - duration: 5.000, - start: 5.000, - sn: 1, - cc: 1 - }, - { - pdt: 1505502676523, - endPdt: 1505502681523, - level: 2, - duration: 5.000, - start: 10.000, - sn: 2, - cc: 1 - }, - { - pdt: 1505502681523, - endPdt: 1505502686523, - level: 2, - duration: 5.000, - start: 15.000, - sn: 3, - cc: 1 - }, - { - pdt: 1505502686523, - endPdt: 1505502691523, - level: 2, - duration: 5.000, - start: 20.000, - sn: 4, - cc: 1 - } - ]; - - let fragLen = fragments.length; + let fragLen = mockFragments.length; let levelDetails = { - startSN: fragments[0].sn, - endSN: fragments[fragments.length - 1].sn, - programDateTime: undefined // If this field is undefined SN search is used by default, if set is PDT + startSN: mockFragments[0].sn, + endSN: mockFragments[mockFragments.length - 1].sn, + programDateTime: null // If this field is null SN search is used by default, if set is PDT }; let bufferEnd = fragPrevious.start + fragPrevious.duration; - let end = fragments[fragments.length - 1].start + fragments[fragments.length - 1].duration; + let end = mockFragments[mockFragments.length - 1].start + mockFragments[mockFragments.length - 1].duration; it('SN search choosing wrong fragment (3 instead of 2) after level loaded', function () { - let config = {}; - let hls = { - config: config, - on: function () {} - }; - levelDetails.programDateTime = undefined; - - let streamController = new StreamController(hls); - let foundFragment = streamController._findFragment(0, fragPrevious, fragLen, fragments, bufferEnd, end, levelDetails); + levelDetails.programDateTime = null; + let foundFragment = streamController._findFragment(0, fragPrevious, fragLen, mockFragments, bufferEnd, end, levelDetails); let resultSN = foundFragment ? foundFragment.sn : -1; - assert.equal(foundFragment, fragments[3], 'Expected sn 3, found sn segment ' + resultSN); + assert.equal(foundFragment, mockFragments[3], 'Expected sn 3, found sn segment ' + resultSN); }); // TODO: This test fails if using a real instance of Hls it('SN search choosing the right segment if fragPrevious is not available', function () { - let config = {}; - let hls = { - config: config, - on: function () {} - }; - levelDetails.programDateTime = undefined; + levelDetails.programDateTime = null; - let streamController = new StreamController(hls); - let foundFragment = streamController._findFragment(0, null, fragLen, fragments, bufferEnd, end, levelDetails); + let foundFragment = streamController._findFragment(0, null, fragLen, mockFragments, bufferEnd, end, levelDetails); let resultSN = foundFragment ? foundFragment.sn : -1; - assert.equal(foundFragment, fragments[2], 'Expected sn 2, found sn segment ' + resultSN); + assert.equal(foundFragment, mockFragments[3], 'Expected sn 2, found sn segment ' + resultSN); }); it('PDT search choosing fragment after level loaded', function () { levelDetails.programDateTime = PDT;// If programDateTime contains a date then PDT is used - let foundFragment = streamController._findFragment(0, fragPrevious, fragLen, fragments, bufferEnd, end, levelDetails); + let foundFragment = streamController._findFragment(0, fragPrevious, fragLen, mockFragments, bufferEnd, end, levelDetails); let resultSN = foundFragment ? foundFragment.sn : -1; - assert.equal(foundFragment, fragments[2], 'Expected sn 2, found sn segment ' + resultSN); + assert.equal(foundFragment, mockFragments[3], 'Expected sn 3, found sn segment ' + resultSN); }); it('PDT search choosing fragment after starting/seeking to a new position (bufferEnd used)', function () { levelDetails.programDateTime = PDT;// If programDateTime contains a date then PDT is used let mediaSeekingTime = 17.00; - let foundFragment = streamController._findFragment(0, null, fragLen, fragments, mediaSeekingTime, end, levelDetails); + let foundFragment = streamController._findFragment(0, null, fragLen, mockFragments, mediaSeekingTime, end, levelDetails); let resultSN = foundFragment ? foundFragment.sn : -1; - assert.equal(foundFragment, fragments[2], 'Expected sn 2, found sn segment ' + resultSN); + assert.equal(foundFragment, mockFragments[3], 'Expected sn 3, found sn segment ' + resultSN); }); it('PDT serch hitting empty discontinuity', function () { levelDetails.programDateTime = PDT;// If programDateTime contains a date then PDT is used let discontinuityPDTHit = 6.00; - let foundFragment = streamController._findFragment(0, null, fragLen, fragments, discontinuityPDTHit, end, levelDetails); - let resultSN = foundFragment ? foundFragment.sn : -1; - assert.equal(foundFragment, fragments[1], 'Expected sn 1, found sn segment ' + resultSN); - }); - - it('Unit test _findFragmentBySN', function () { - let foundFragment = streamController._findFragmentBySN(fragPrevious, fragments, bufferEnd, end); + let foundFragment = streamController._findFragment(0, null, fragLen, mockFragments, discontinuityPDTHit, end, levelDetails); let resultSN = foundFragment ? foundFragment.sn : -1; - assert.equal(foundFragment, fragments[3], 'Expected sn 3, found sn segment ' + resultSN); + assert.equal(foundFragment, mockFragments[1], 'Expected sn 1, found sn segment ' + resultSN); }); - it('Unit test _findFragmentByPDT usual behaviour', function () { - let foundFragment = streamController._findFragmentByPDT(fragments, fragPrevious.endPdt + 1); - let resultSN = foundFragment ? foundFragment.sn : -1; - assert.equal(foundFragment, fragments[2], 'Expected sn 2, found sn segment ' + resultSN); - }); + it('finds the next fragment by SN if finding by PDT returns a frag out of tolerance', function () { + levelDetails.programDateTime = PDT; + const fragments = [mockFragments[0], mockFragments[0], mockFragments[1]]; + const bufferEnd = fragments[1].start + fragments[1].duration; + const end = fragments[2].start + fragments[2].duration; - it('Unit test _findFragmentByPDT beyond limits', function () { - let foundFragment = streamController._findFragmentByPDT(fragments, fragments[0].pdt - 1); - let resultSN = foundFragment ? foundFragment.sn : -1; - assert.equal(foundFragment, null, 'Expected sn -1, found sn segment ' + resultSN); - - foundFragment = streamController._findFragmentByPDT(fragments, fragments[fragments.length - 1].endPdt + 1); - resultSN = foundFragment ? foundFragment.sn : -1; - assert.equal(foundFragment, null, 'Expected sn -1, found sn segment ' + resultSN); - }); - - it('Unit test _findFragmentByPDT at the beginning', function () { - let foundFragment = streamController._findFragmentByPDT(fragments, fragments[0].pdt); - let resultSN = foundFragment ? foundFragment.sn : -1; - assert.equal(foundFragment, fragments[0], 'Expected sn 0, found sn segment ' + resultSN); - }); - - it('Unit test _findFragmentByPDT for last segment', function () { - let foundFragment = streamController._findFragmentByPDT(fragments, fragments[fragments.length - 1].pdt); - let resultSN = foundFragment ? foundFragment.sn : -1; - assert.equal(foundFragment, fragments[4], 'Expected sn 4, found sn segment ' + resultSN); + const expected = fragments[2]; + const actual = streamController._findFragment(0, fragments[1], fragments.length, fragments, bufferEnd, end, levelDetails); + assert.strictEqual(expected, actual); }); });