From de6abde06f38d802f1f9fb297c284283ca8e4751 Mon Sep 17 00:00:00 2001 From: Tian Shao Date: Tue, 31 Jan 2023 11:08:29 -0800 Subject: [PATCH] feat: Support Parallel Segment Fetching (#4784) closes https://github.com/shaka-project/shaka-player/issues/4658. This solution is inspired by abandoned PR https://github.com/shaka-project/shaka-player/pull/2809, which implements segment prefetching ahead of current play head. ![image](https://user-images.githubusercontent.com/3315733/205465795-75c605d2-c2e3-4d03-90f5-46a72a7189d2.png) --- build/types/core | 1 + demo/common/message_ids.js | 1 + demo/config.js | 4 +- demo/locales/en.json | 3 +- demo/locales/source.json | 4 + docs/tutorials/config.md | 1 + externs/shaka/player.js | 8 +- lib/media/segment_prefetch.js | 193 +++++++++++++++++++ lib/media/streaming_engine.js | 106 +++++++++-- lib/util/player_configuration.js | 1 + test/media/segment_prefetch_unit.js | 243 ++++++++++++++++++++++++ test/media/streaming_engine_unit.js | 132 +++++++++++++ test/test/util/fake_segment_prefetch.js | 81 ++++++++ 13 files changed, 764 insertions(+), 14 deletions(-) create mode 100644 lib/media/segment_prefetch.js create mode 100644 test/media/segment_prefetch_unit.js create mode 100644 test/test/util/fake_segment_prefetch.js diff --git a/build/types/core b/build/types/core index d53ad2a9b1..bd6c2e2421 100644 --- a/build/types/core +++ b/build/types/core @@ -38,6 +38,7 @@ +../../lib/media/segment_reference.js +../../lib/media/stall_detector.js +../../lib/media/streaming_engine.js ++../../lib/media/segment_prefetch.js +../../lib/media/time_ranges_utils.js +../../lib/media/video_wrapper.js +../../lib/media/webm_segment_index_parser.js diff --git a/demo/common/message_ids.js b/demo/common/message_ids.js index 883dce6331..8aa7dc210a 100644 --- a/demo/common/message_ids.js +++ b/demo/common/message_ids.js @@ -283,5 +283,6 @@ shakaDemo.MessageIds = { VIDEO_ROBUSTNESS: 'DEMO_VIDEO_ROBUSTNESS', VNOVA: 'DEMO_VNOVA', XLINK_FAIL_GRACEFULLY: 'DEMO_XLINK_FAIL_GRACEFULLY', + SEGMENT_PREFETCH_LIMIT: 'DEMO_SEGMENT_PREFETCH_LIMIT', }; /* eslint-enable max-len */ diff --git a/demo/config.js b/demo/config.js index 365d75df38..b72ab6293c 100644 --- a/demo/config.js +++ b/demo/config.js @@ -426,7 +426,9 @@ shakaDemo.Config = class { .addBoolInput_(MessageIds.OBSERVE_QUALITY_CHANGES, 'streaming.observeQualityChanges') .addNumberInput_(MessageIds.MAX_DISABLED_TIME, - 'streaming.maxDisabledTime'); + 'streaming.maxDisabledTime') + .addNumberInput_(MessageIds.SEGMENT_PREFETCH_LIMIT, + 'streaming.segmentPrefetchLimit'); if (!shakaDemoMain.getNativeControlsEnabled()) { this.addBoolInput_(MessageIds.ALWAYS_STREAM_TEXT, diff --git a/demo/locales/en.json b/demo/locales/en.json index a597111cd4..ad59d5567d 100644 --- a/demo/locales/en.json +++ b/demo/locales/en.json @@ -261,5 +261,6 @@ "DEMO_WIDEVINE": "Widevine DRM", "DEMO_XLINK": "XLink", "DEMO_XLINK_FAIL_GRACEFULLY": "Xlink Should Fail Gracefully", - "DEMO_XLINK_SEARCH": "Filters for assets that have XLINK tags in their manifests, so that they can be broken into multiple files." + "DEMO_XLINK_SEARCH": "Filters for assets that have XLINK tags in their manifests, so that they can be broken into multiple files.", + "DEMO_SEGMENT_PREFETCH_LIMIT": "Segment Prefetch Limit" } diff --git a/demo/locales/source.json b/demo/locales/source.json index 942c53f00b..fcfdbcc634 100644 --- a/demo/locales/source.json +++ b/demo/locales/source.json @@ -1050,5 +1050,9 @@ "DEMO_XLINK_SEARCH": { "description": "A tooltip for an optional search term.", "message": "Filters for assets that have [JARGON:XLINK] tags in their manifests, so that they can be broken into multiple files." + }, + "DEMO_SEGMENT_PREFETCH_LIMIT": { + "description": "Max number of segments to be prefetched ahead of current time position.", + "message": "Segment Prefetch Limit." } } diff --git a/docs/tutorials/config.md b/docs/tutorials/config.md index 039c3f40e1..b5b7d0450a 100644 --- a/docs/tutorials/config.md +++ b/docs/tutorials/config.md @@ -78,6 +78,7 @@ player.getConfiguration(); retryParameters: Object startAtSegmentBoundary: false safeSeekOffset: 5 + segmentPrefetchLimit: 0 textDisplayFactory: Function diff --git a/externs/shaka/player.js b/externs/shaka/player.js index e9e10ada89..5c901660c9 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -973,7 +973,8 @@ shaka.extern.ManifestConfiguration; * dispatchAllEmsgBoxes: boolean, * observeQualityChanges: boolean, * maxDisabledTime: number, - * parsePrftBox: boolean + * parsePrftBox: boolean, + * segmentPrefetchLimit: number * }} * * @description @@ -1083,6 +1084,11 @@ shaka.extern.ManifestConfiguration; * start date will not change, and would save parsing the segment multiple * times needlessly. * Defaults to false. + * @property {boolean} segmentPrefetchLimit + * The maximum number of segments for each active stream to be prefetched + * ahead of playhead in parallel. + * If 0, the segments will be fetched sequentially. + * Defaults to 0. * @exportDoc */ shaka.extern.StreamingConfiguration; diff --git a/lib/media/segment_prefetch.js b/lib/media/segment_prefetch.js new file mode 100644 index 0000000000..b59e156e8c --- /dev/null +++ b/lib/media/segment_prefetch.js @@ -0,0 +1,193 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.require('goog.asserts'); +goog.require('shaka.net.NetworkingEngine'); +goog.require('shaka.media.InitSegmentReference'); +goog.require('shaka.media.SegmentReference'); +goog.provide('shaka.media.SegmentPrefetch'); +goog.require('shaka.log'); + +/** + * @summary + * This class manages segment prefetch operations. + * Called by StreamingEngine to prefetch next N segments + * ahead of playhead, to reduce the chances of rebuffering. + */ +shaka.media.SegmentPrefetch = class { + /** + * @param {number} prefetchLimit + * @param {shaka.extern.Stream} stream + * @param {shaka.media.SegmentPrefetch.FetchDispatcher} fetchDispatcher + */ + constructor(prefetchLimit, stream, fetchDispatcher) { + /** @private {number} */ + this.prefetchLimit_ = prefetchLimit; + + /** @private {shaka.extern.Stream} */ + this.stream_ = stream; + + /** @private {number} */ + this.prefetchPosTime_ = 0; + + /** @private {shaka.media.SegmentPrefetch.FetchDispatcher} */ + this.fetchDispatcher_ = fetchDispatcher; + + /** + * @private {!Map.} + */ + this.segmentPrefetchMap_ = new Map(); + } + + /** + * Fetch next segments ahead of current segment. + * + * @param {(!shaka.media.SegmentReference)} startReference + * @public + */ + prefetchSegments(startReference) { + goog.asserts.assert(this.prefetchLimit_ > 0, + 'SegmentPrefetch can not be used when prefetchLimit <= 0.'); + + const logPrefix = shaka.media.SegmentPrefetch.logPrefix_(this.stream_); + if (!this.stream_.segmentIndex) { + shaka.log.info(logPrefix, 'missing segmentIndex'); + return; + } + const currTime = startReference.startTime; + const maxTime = Math.max(currTime, this.prefetchPosTime_); + const iterator = this.stream_.segmentIndex.getIteratorForTime(maxTime); + let reference = startReference; + while (this.segmentPrefetchMap_.size < this.prefetchLimit_ && + reference != null) { + if (!this.segmentPrefetchMap_.has(reference)) { + const op = this.fetchDispatcher_(reference, this.stream_); + this.segmentPrefetchMap_.set(reference, op); + } + this.prefetchPosTime_ = reference.startTime; + reference = iterator.next().value; + } + } + + /** + * Get the result of prefetched segment if already exists. + * @param {(!shaka.media.SegmentReference)} reference + * @return {?shaka.net.NetworkingEngine.PendingRequest} op + * @public + */ + getPrefetchedSegment(reference) { + goog.asserts.assert(this.prefetchLimit_ > 0, + 'SegmentPrefetch can not be used when prefetchLimit <= 0.'); + goog.asserts.assert(reference instanceof shaka.media.SegmentReference, + 'getPrefetchedSegment is only used for shaka.media.SegmentReference.'); + + const logPrefix = shaka.media.SegmentPrefetch.logPrefix_(this.stream_); + + if (this.segmentPrefetchMap_.has(reference)) { + const op = this.segmentPrefetchMap_.get(reference); + this.segmentPrefetchMap_.delete(reference); + shaka.log.info( + logPrefix, + 'reused prefetched segment at time:', reference.startTime, + 'mapSize', this.segmentPrefetchMap_.size); + return op; + } else { + shaka.log.info( + logPrefix, + 'missed segment at time:', reference.startTime, + 'mapSize', this.segmentPrefetchMap_.size); + return null; + } + } + + /** + * Clear all segment data. + * @public + */ + clearAll() { + if (this.segmentPrefetchMap_.size === 0) { + return; + } + const logPrefix = shaka.media.SegmentPrefetch.logPrefix_(this.stream_); + for (const reference of this.segmentPrefetchMap_.keys()) { + if (reference) { + this.abortPrefetchedSegment_(reference); + } + } + shaka.log.info(logPrefix, 'cleared all'); + this.prefetchPosTime_ = 0; + } + + /** + * Reset the prefetchLimit and clear all internal states. + * Called by StreamingEngine when configure() was called. + * @param {number} newPrefetchLimit + * @public + */ + resetLimit(newPrefetchLimit) { + goog.asserts.assert(newPrefetchLimit >= 0, + 'The new prefetch limit must be >= 0.'); + this.prefetchLimit_ = newPrefetchLimit; + const keyArr = Array.from(this.segmentPrefetchMap_.keys()); + while (keyArr.length > newPrefetchLimit) { + const reference = keyArr.pop(); + if (reference) { + this.abortPrefetchedSegment_(reference); + } + } + } + + /** + * Called by Streaming Engine when switching variant. + * @param {shaka.extern.Stream} stream + * @public + */ + switchStream(stream) { + if (stream && stream !== this.stream_) { + this.clearAll(); + this.stream_ = stream; + } + } + + /** + * Remove a segment from prefetch map and abort it. + * @param {(!shaka.media.SegmentReference)} reference + * @private + */ + abortPrefetchedSegment_(reference) { + const logPrefix = shaka.media.SegmentPrefetch.logPrefix_(this.stream_); + const operation = this.segmentPrefetchMap_.get(reference); + this.segmentPrefetchMap_.delete(reference); + if (operation) { + operation.abort(); + shaka.log.info( + logPrefix, + 'pop and abort prefetched segment at time:', reference.startTime); + } + } + + /** + * The prefix of the logs that are created in this class. + * @return {string} + * @private + */ + static logPrefix_(stream) { + return 'SegmentPrefetch(' + stream.type + ':' + stream.id + ')'; + } +}; + +/** + * @typedef {function( + * !(shaka.media.InitSegmentReference|shaka.media.SegmentReference), + * shaka.extern.Stream + * ):!shaka.net.NetworkingEngine.PendingRequest} + * + * @description + * A callback function that fetches a segment. + * @export + */ +shaka.media.SegmentPrefetch.FetchDispatcher; diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index 0ba273a3a6..d454f0f99d 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -17,6 +17,7 @@ goog.require('shaka.media.InitSegmentReference'); goog.require('shaka.media.MediaSourceEngine'); goog.require('shaka.media.SegmentIterator'); goog.require('shaka.media.SegmentReference'); +goog.require('shaka.media.SegmentPrefetch'); goog.require('shaka.net.Backoff'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.DelayedTick'); @@ -170,6 +171,21 @@ shaka.media.StreamingEngine = class { const autoReset = true; this.failureCallbackBackoff_ = new shaka.net.Backoff(failureRetryParams, autoReset); + + // Allow configuring the segment prefetch in middle of the playback. + for (const type of this.mediaStates_.keys()) { + const state = this.mediaStates_.get(type); + if (state.segmentPrefetch) { + state.segmentPrefetch.resetLimit(config.segmentPrefetchLimit); + if (!(config.segmentPrefetchLimit > 0)) { + // ResetLimit is still needed in this case, + // to abort existing prefetch operations. + state.segmentPrefetch = null; + } + } else if (config.segmentPrefetchLimit > 0) { + state.segmentPrefetch = this.createSegmentPrefetch_(state.stream); + } + } } @@ -439,6 +455,10 @@ shaka.media.StreamingEngine = class { return; } + if (mediaState.segmentPrefetch) { + mediaState.segmentPrefetch.switchStream(stream); + } + if (stream.type == ContentType.TEXT) { // Mime types are allowed to change for text streams. // Reinitialize the text parser, but only if we are going to fetch the @@ -820,6 +840,7 @@ shaka.media.StreamingEngine = class { stream, type: stream.type, segmentIterator: null, + segmentPrefetch: this.createSegmentPrefetch_(stream), lastSegmentReference: null, lastInitSegmentReference: null, lastTimestampOffset: null, @@ -843,6 +864,29 @@ shaka.media.StreamingEngine = class { }); } + /** + * Creates a media state. + * + * @param {shaka.extern.Stream} stream + * @return {shaka.media.SegmentPrefetch | null} + * @private + */ + createSegmentPrefetch_(stream) { + if ( + stream.type !== shaka.util.ManifestParserUtils.ContentType.VIDEO && + stream.type !== shaka.util.ManifestParserUtils.ContentType.AUDIO + ) { + return null; + } + if (this.config_.segmentPrefetchLimit > 0) { + return new shaka.media.SegmentPrefetch( + this.config_.segmentPrefetchLimit, + stream, + (reference, stream) => this.dispatchFetch_(reference, stream, null), + ); + } + return null; + } /** * Sets the MediaSource's duration. @@ -1097,6 +1141,10 @@ shaka.media.StreamingEngine = class { return this.config_.updateIntervalSeconds; } + if (mediaState.segmentPrefetch && mediaState.segmentIterator) { + mediaState.segmentPrefetch.prefetchSegments(reference); + } + const p = this.fetchAndAppend_(mediaState, presentationTime, reference); p.catch(() => {}); // TODO(#1993): Handle asynchronous errors. return null; @@ -2026,6 +2074,37 @@ shaka.media.StreamingEngine = class { * @suppress {strictMissingProperties} */ async fetch_(mediaState, reference, streamDataCallback) { + let op = null; + if ( + mediaState.segmentPrefetch && + reference instanceof shaka.media.SegmentReference + ) { + op = mediaState.segmentPrefetch.getPrefetchedSegment(reference); + } + if (!op) { + op = this.dispatchFetch_( + reference, mediaState.stream, streamDataCallback, + ); + } + + mediaState.operation = op; + const response = await op.promise; + mediaState.operation = null; + return response.data; + } + + /** + * Fetches the given segment. + * + * @param {!shaka.extern.Stream} stream + * @param {(!shaka.media.InitSegmentReference|!shaka.media.SegmentReference)} + * reference + * @param {?function(BufferSource):!Promise=} streamDataCallback + * + * @return {!shaka.net.NetworkingEngine.PendingRequest} + * @private + */ + dispatchFetch_(reference, stream, streamDataCallback) { const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT; const request = shaka.util.Networking.createSegmentRequest( @@ -2036,28 +2115,25 @@ shaka.media.StreamingEngine = class { streamDataCallback); shaka.log.v2('fetching: reference=', reference); - - const stream = mediaState.stream; + let duration = 0; + if (reference instanceof shaka.media.SegmentReference) { + // start and endTime are not defined in InitSegmentReference + duration = reference.endTime - reference.startTime; + } this.playerInterface_.modifySegmentRequest( request, { type: stream.type, init: reference instanceof shaka.media.InitSegmentReference, - duration: reference.endTime - reference.startTime, + duration: duration, mimeType: stream.mimeType, codecs: stream.codecs, bandwidth: stream.bandwidth, }, ); - - const op = this.playerInterface_.netEngine.request(requestType, request); - mediaState.operation = op; - const response = await op.promise; - mediaState.operation = null; - return response.data; + return this.playerInterface_.netEngine.request(requestType, request); } - /** * Clears the buffer and schedules another update. * The optional parameter safeMargin allows to retain a certain amount @@ -2085,6 +2161,9 @@ shaka.media.StreamingEngine = class { mediaState.segmentIterator = null; shaka.log.debug(logPrefix, 'clearing buffer'); + if (mediaState.segmentPrefetch) { + mediaState.segmentPrefetch.clearAll(); + } if (safeMargin) { const presentationTime = this.playerInterface_.getPresentationTime(); @@ -2100,6 +2179,7 @@ shaka.media.StreamingEngine = class { mediaState.type); } } + this.destroyer_.ensureNotDestroyed(); shaka.log.debug(logPrefix, 'cleared buffer'); @@ -2292,7 +2372,8 @@ shaka.media.StreamingEngine.PlayerInterface; * adaptation: boolean, * recovering: boolean, * hasError: boolean, - * operation: shaka.net.NetworkingEngine.PendingRequest + * operation: shaka.net.NetworkingEngine.PendingRequest, + * segmentPrefetch: shaka.media.SegmentPrefetch * }} * * @description @@ -2349,6 +2430,9 @@ shaka.media.StreamingEngine.PlayerInterface; * updating. * @property {shaka.net.NetworkingEngine.PendingRequest} operation * Operation with the number of bytes to be downloaded. + * @property {?shaka.media.SegmentPrefetch} segmentPrefetch + * A prefetch object for managing prefetching. Null if unneeded + * (if prefetching is disabled, etc). */ shaka.media.StreamingEngine.MediaState_; diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index a7122c392c..0249c13586 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -196,6 +196,7 @@ shaka.util.PlayerConfiguration = class { observeQualityChanges: false, maxDisabledTime: 30, parsePrftBox: false, + segmentPrefetchLimit: 0, }; // WebOS, Tizen, and Chromecast have long hardware pipelines that respond diff --git a/test/media/segment_prefetch_unit.js b/test/media/segment_prefetch_unit.js new file mode 100644 index 0000000000..aeff189d82 --- /dev/null +++ b/test/media/segment_prefetch_unit.js @@ -0,0 +1,243 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + + +describe('SegmentPrefetch', () => { + const Util = shaka.test.Util; + /** @type {shaka.media.SegmentPrefetch} */ + let segmentPrefetch; + + /** @type {!jasmine.Spy} */ + let fetchDispatcher; + + /** @type {jasmine.Spy} */ + let pendingRequestAbort; + + /** @type {shaka.extern.Stream} */ + let stream; + + const references = [ + makeReference(uri('0.10'), 0, 10), + makeReference(uri('10.20'), 10, 20), + makeReference(uri('20.30'), 20, 30), + makeReference(uri('30.40'), 30, 40), + ]; + + beforeEach(() => { + pendingRequestAbort = + jasmine.createSpy('abort').and.returnValue(Promise.resolve()); + const pendingRequestAbortFunc = Util.spyFunc(pendingRequestAbort); + const bytes = new shaka.net.NetworkingEngine.NumBytesRemainingClass(); + bytes.setBytes(200); + stream = createStream(); + stream.segmentIndex = new shaka.media.SegmentIndex(references); + fetchDispatcher = jasmine.createSpy('appendBuffer') + .and.callFake((ref, stream) => + new shaka.net.NetworkingEngine.PendingRequest( + Promise.resolve({ + uri: ref.getUris()[0], + data: new ArrayBuffer(0), + headers: {}, + }), + pendingRequestAbortFunc, + bytes, + ), + ); + segmentPrefetch = new shaka.media.SegmentPrefetch( + 3, stream, Util.spyFunc(fetchDispatcher), + ); + }); + + describe('prefetchSegments', () => { + it('should prefetch next 3 segments', async () => { + segmentPrefetch.prefetchSegments(references[0]); + await expectSegmentsPrefetched(0); + const op = segmentPrefetch.getPrefetchedSegment(references[3]); + expect(op).toBeNull(); + expect(fetchDispatcher).toHaveBeenCalledTimes(3); + }); + + it('prefetch last segment if position is at the end', async () => { + segmentPrefetch.prefetchSegments(references[3]); + const op = segmentPrefetch.getPrefetchedSegment(references[3]); + expect(op).toBeDefined(); + const response = await op.promise; + const startTime = (3 * 10); + expect(response.uri).toBe(uri(startTime + '.' + (startTime + 10))); + + for (let i = 0; i < 3; i++) { + const op = segmentPrefetch.getPrefetchedSegment(references[i]); + expect(op).toBeNull(); + } + expect(fetchDispatcher).toHaveBeenCalledTimes(1); + }); + + it('do not prefetch already fetched segment', async () => { + segmentPrefetch.prefetchSegments(references[1]); + // since 2 was alreay pre-fetched when prefetch 1, expect + // no extra fetch is made. + segmentPrefetch.prefetchSegments(references[2]); + + expect(fetchDispatcher).toHaveBeenCalledTimes(3); + await expectSegmentsPrefetched(1); + }); + }); + + describe('clearAll', () => { + it('clears all prefetched segments', () => { + segmentPrefetch.prefetchSegments(references[0]); + segmentPrefetch.clearAll(); + for (let i = 0; i < 3; i++) { + const op = segmentPrefetch.getPrefetchedSegment(references[i]); + expect(op).toBeNull(); + } + expect(fetchDispatcher).toHaveBeenCalledTimes(3); + }); + + it('resets time pos so prefetch can happen again', () => { + segmentPrefetch.prefetchSegments(references[3]); + segmentPrefetch.clearAll(); + for (let i = 0; i < 3; i++) { + const op = segmentPrefetch.getPrefetchedSegment(references[i]); + expect(op).toBeNull(); + } + + segmentPrefetch.prefetchSegments(references[3]); + for (let i = 0; i < 3; i++) { + const op = segmentPrefetch.getPrefetchedSegment(references[i]); + expect(op).toBeNull(); + } + expect(segmentPrefetch.getPrefetchedSegment(references[3])).toBeDefined(); + expect(fetchDispatcher).toHaveBeenCalledTimes(2); + }); + }); + + describe('switchStream', () => { + it('clears all prefetched segments', () => { + segmentPrefetch.prefetchSegments(references[0]); + segmentPrefetch.switchStream(createStream()); + for (let i = 0; i < 3; i++) { + const op = segmentPrefetch.getPrefetchedSegment(references[i]); + expect(op).toBeNull(); + } + expect(fetchDispatcher).toHaveBeenCalledTimes(3); + }); + + it('do nothing if its same stream', async () => { + segmentPrefetch.prefetchSegments(references[0]); + segmentPrefetch.switchStream(stream); + await expectSegmentsPrefetched(0); + }); + }); + + describe('resetLimit', () => { + it('do nothing if the new limit is larger', async () => { + segmentPrefetch.prefetchSegments(references[0]); + segmentPrefetch.resetLimit(4); + await expectSegmentsPrefetched(0); + }); + + it('do nothing if the new limit is the same', async () => { + segmentPrefetch.prefetchSegments(references[0]); + segmentPrefetch.resetLimit(3); + await expectSegmentsPrefetched(0); + }); + + it('clears all prefetched segments beyond new limit', async () => { + segmentPrefetch.prefetchSegments(references[0]); + segmentPrefetch.resetLimit(1); + // expecting prefetched reference 0 is kept + expectSegmentsPrefetched(0, 1); + // expecting prefetched references 1 and 2 are removd + for (let i = 1; i < 3; i++) { + const op = segmentPrefetch.getPrefetchedSegment(references[i]); + expect(op).toBeNull(); + } + + // clear all to test the new limit by re-fetching. + segmentPrefetch.clearAll(); + // prefetch again. + segmentPrefetch.prefetchSegments(references[0]); + // expect only one is prefetched + await expectSegmentsPrefetched(0, 1); + // only dispatched fetch one more time. + expect(fetchDispatcher).toHaveBeenCalledTimes(3 + 1); + }); + }); + /** + * Creates a URI string. + * + * @param {string} x + * @return {string} + */ + function uri(x) { + return 'http://example.com/video_' + x + '.m4s'; + } + + /** + * Creates a real SegmentReference. + * + * @param {string} uri + * @param {number} startTime + * @param {number} endTime + * @return {shaka.media.SegmentReference} + */ + function makeReference(uri, startTime, endTime) { + return new shaka.media.SegmentReference( + startTime, + endTime, + /* getUris= */ () => [uri], + /* startByte= */ 0, + /* endByte= */ null, + /* initSegmentReference= */ null, + /* timestampOffset= */ 0, + /* appendWindowStart= */ 0, + /* appendWindowEnd= */ Infinity, + /* partialReferences= */ [], + /* tilesLayout= */ undefined, + /* tileDuration= */ undefined, + /* syncTime= */ undefined, + /* status= */ undefined, + /* hlsAes128Key= */ null); + } + + /** + * Creates a stream. + * @return {shaka.extern.Stream} + */ + function createStream() { + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.presentationTimeline.setDuration(60); + manifest.addVariant(0, (variant) => { + variant.addVideo(11, (stream) => { + stream.useSegmentTemplate('video-11-%d.mp4', 10); + }); + }); + }); + + const videoStream = manifest.variants[0].video; + if (!videoStream) { + throw new Error('unexpected stream setup - variant.video is null'); + } + return videoStream; + } + + /** + * Expects segments have been prefetched within given range. + * @param {number} startPos + * @param {number} limit + */ + async function expectSegmentsPrefetched(startPos, limit = 3) { + for (let i = startPos; i < startPos + limit; i++) { + const op = segmentPrefetch.getPrefetchedSegment(references[i]); + expect(op).not.toBeNull(); + /* eslint-disable-next-line no-await-in-loop */ + const response = await op.promise; + const startTime = (i * 10); + expect(response.uri).toBe(uri(startTime + '.' + (startTime + 10))); + } + } +}); diff --git a/test/media/streaming_engine_unit.js b/test/media/streaming_engine_unit.js index 1c02851260..446fc87357 100644 --- a/test/media/streaming_engine_unit.js +++ b/test/media/streaming_engine_unit.js @@ -3703,6 +3703,138 @@ describe('StreamingEngine', () => { }); }); + describe('prefetch segments', () => { + const segmentType = shaka.net.NetworkingEngine.RequestType.SEGMENT; + + beforeEach(() => { + shaka.media.SegmentPrefetch = Util.spyFunc( + jasmine.createSpy('SegmentPrefetch') + .and.callFake((config, stream) => + new shaka.test.FakeSegmentPrefetch(stream, segmentData), + ), + ); + setupVod(); + mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); + createStreamingEngine(); + const config = shaka.util.PlayerConfiguration.createDefault().streaming; + config.segmentPrefetchLimit = 3; + streamingEngine.configure(config); + }); + + it('should use prefetched segment without fetching again', async () => { + streamingEngine.switchVariant(variant); + await streamingEngine.start(); + playing = true; + expectNoBuffer(); + + await runTest(); + + expectHasBuffer(); + expectSegmentRequest(false); + }); + + it('should re-use prefetch segment when force clear buffer', async () => { + streamingEngine.switchVariant(variant); + await streamingEngine.start(); + + playing = true; + expectNoBuffer(); + await runTest(); + expectHasBuffer(); + expectSegmentRequest(false); + + streamingEngine.switchVariant(variant, true, 0, true); + presentationTimeInSeconds = 0; + await runTest(); + expectHasBuffer(); + expectSegmentRequest(false); + }); + + it('should disable prefetch if reset config in middle', async () => { + streamingEngine.switchVariant(variant); + await streamingEngine.start(); + + playing = true; + expectNoBuffer(); + await runTest(); + expectHasBuffer(); + expectSegmentRequest(false); + + const config = shaka.util.PlayerConfiguration.createDefault().streaming; + config.segmentPrefetchLimit = 0; + streamingEngine.configure(config); + streamingEngine.switchVariant(variant, true, 0, true); + presentationTimeInSeconds = 0; + await runTest(); + expectHasBuffer(); + expectSegmentRequest(true); + }); + + it('should disable prefetch when reset config at begining', async () => { + const config = shaka.util.PlayerConfiguration.createDefault().streaming; + config.segmentPrefetchLimit = 0; + streamingEngine.configure(config); + streamingEngine.switchVariant(variant); + await streamingEngine.start(); + playing = true; + expectNoBuffer(); + await runTest(); + expectHasBuffer(); + expectSegmentRequest(true); + }); + + /** + * Expect no buffer has been added to MSE. + */ + function expectNoBuffer() { + expect(mediaSourceEngine.initSegments).toEqual({ + audio: [false, false], + video: [false, false], + text: [], + }); + expect(mediaSourceEngine.segments).toEqual({ + audio: [false, false, false, false], + video: [false, false, false, false], + text: [false, false, false, false], + }); + } + + /** + * Expect buffers have been added to MSE. + */ + function expectHasBuffer() { + expect(mediaSourceEngine.initSegments).toEqual({ + audio: [false, true], + video: [false, true], + text: [], + }); + expect(mediaSourceEngine.segments).toEqual({ + audio: [true, true, true, true], + video: [true, true, true, true], + text: [false, false, false, false], + }); + } + + /** + * @param {?boolean} hasRequest + */ + function expectSegmentRequest(hasRequest) { + const requests = [ + '0_audio_0', '0_video_0', '0_audio_1', + '0_video_1', '1_audio_2', '1_video_2', + '1_audio_3', '1_video_3', + ]; + + for (const request of requests) { + if (hasRequest) { + netEngine.expectRequest(request, segmentType); + } else { + netEngine.expectNoRequest(request, segmentType); + } + } + } + }); + /** * Slides the segment availability window forward by 1 second. */ diff --git a/test/test/util/fake_segment_prefetch.js b/test/test/util/fake_segment_prefetch.js new file mode 100644 index 0000000000..c7ada36947 --- /dev/null +++ b/test/test/util/fake_segment_prefetch.js @@ -0,0 +1,81 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A fake SegmentPrefetch class that is used for testing + * segment prefetching functionality. + * + * @final + * @struct + * @extends {shaka.media.SegmentPrefetch} + */ +shaka.test.FakeSegmentPrefetch = class { + constructor(stream, segmentData) { + /** @private {(Set.)} */ + this.requestedReferences_ = new Set(); + + /** @private {shaka.extern.Stream} */ + this.streamObj_ = stream; + + /** + * @private {!Object.} + */ + this.segmentData_ = segmentData; + } + + /** @override */ + prefetchSegments(reference) { + if (!(reference instanceof shaka.media.SegmentReference)) { + return; + } + this.requestedReferences_.add(reference); + } + + /** @override */ + switchStream(stream) { + if (stream !== this.streamObj_) { + this.requestedReferences_.clear(); + } + } + + /** @override */ + resetLimit(limit) { + this.clearAll(); + } + + /** @override */ + clearAll() { + this.requestedReferences_.clear(); + } + + /** @override */ + getPrefetchedSegment(reference) { + if (!(reference instanceof shaka.media.SegmentReference)) { + return null; + } + /** + * The unit tests assume a segment is already prefetched + * if it was ever passed to prefetchSegments() as param. + * Otherwise return null so the streaming engine being tested + * will do actual fetch. + */ + if (this.requestedReferences_.has(reference)) { + const segmentData = this.segmentData_[this.streamObj_.type]; + return new shaka.net.NetworkingEngine.PendingRequest( + Promise.resolve({ + uri: reference.getUris()[0], + data: segmentData.segments[ + segmentData.segmentStartTimes.indexOf(reference.startTime) + ], + headers: {}, + }), + () => Promise.resolve(null), + new shaka.net.NetworkingEngine.NumBytesRemainingClass(), + ); + } + return null; + } +};