diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index 6529873a5e..a8bf25a448 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -981,7 +981,7 @@ shaka.media.StreamingEngine = class { mediaState.hasError = false; } } catch (error) { - await this.handleStreamingError_(error); + await this.handleStreamingError_(mediaState, error); return; } @@ -1479,7 +1479,7 @@ shaka.media.StreamingEngine = class { mediaState.hasError = true; error.severity = shaka.util.Error.Severity.CRITICAL; - await this.handleStreamingError_(error); + await this.handleStreamingError_(mediaState, error); } } } @@ -2283,17 +2283,31 @@ shaka.media.StreamingEngine = class { * Handle streaming errors by delaying, then notifying the application by * error callback and by streaming failure callback. * + * @param {shaka.media.StreamingEngine.MediaState_} mediaState * @param {!shaka.util.Error} error * @return {!Promise} * @private */ - async handleStreamingError_(error) { + async handleStreamingError_(mediaState, error) { // If we invoke the callback right away, the application could trigger a // rapid retry cycle that could be very unkind to the server. Instead, // use the backoff system to delay and backoff the error handling. await this.failureCallbackBackoff_.attempt(); this.destroyer_.ensureNotDestroyed(); + const maxDisabledTime = this.getDisabledTime_(error); + // Try to recover from network errors + if (error.category === shaka.util.Error.Category.NETWORK && + maxDisabledTime > 0) { + error.handled = this.playerInterface_.disableStream( + mediaState.stream, maxDisabledTime); + + // Decrease the error severity to recoverable + if (error.handled) { + error.severity = shaka.util.Error.Severity.RECOVERABLE; + } + } + // First fire an error event. this.playerInterface_.onError(error); @@ -2304,6 +2318,25 @@ shaka.media.StreamingEngine = class { } } + /** + * @param {!shaka.util.Error} error + * @private + */ + getDisabledTime_(error) { + if (this.config_.maxDisabledTime === 0 && + error.code == shaka.util.Error.Code.SEGMENT_MISSING) { + // Spec: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-6.3.3 + // The client SHOULD NOT attempt to load Media Segments that have been + // marked with an EXT-X-GAP tag, or to load Partial Segments with a + // GAP=YES attribute. Instead, clients are encouraged to look for + // another Variant Stream of the same Rendition which does not have the + // same gap, and play that instead. + return 1; + } + + return this.config_.maxDisabledTime; + } + /** * @param {shaka.media.StreamingEngine.MediaState_} mediaState * @return {string} A log prefix of the form ($CONTENT_TYPE:$STREAM_ID), e.g., @@ -2330,7 +2363,8 @@ shaka.media.StreamingEngine = class { * onInitSegmentAppended: function(!number,!shaka.media.InitSegmentReference), * beforeAppendSegment: function( * shaka.util.ManifestParserUtils.ContentType,!BufferSource):Promise, - * onMetadata: !function(!Array., number, ?number) + * onMetadata: !function(!Array., number, ?number), + * disableStream: function(!shaka.extern.Stream, number):boolean * }} * * @property {function():number} getPresentationTime @@ -2363,6 +2397,9 @@ shaka.media.StreamingEngine = class { * @property * {!function(!Array., number, ?number)} onMetadata * Called when an ID3 is found in a EMSG. + * @property {function(!shaka.extern.Stream, number):boolean} disableStream + * Called to temporarily disable a stream i.e. disabling all variant + * containing said stream. */ shaka.media.StreamingEngine.PlayerInterface; diff --git a/lib/player.js b/lib/player.js index b2278c7abb..cdb0dc8e32 100644 --- a/lib/player.js +++ b/lib/player.js @@ -632,6 +632,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // If the browser comes back online after being offline, then try to play // again. this.globalEventManager_.listen(window, 'online', () => { + this.restoreDisabledVariants_(); this.retryStreaming(); }); @@ -3136,6 +3137,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { onMetadata: (metadata, offset, endTime) => { this.processTimedMetadataMediaSrc_(metadata, offset, endTime); }, + disableStream: (stream, time) => this.disableStream(stream, time), }; return new shaka.media.StreamingEngine(this.manifest_, playerInterface); @@ -5707,9 +5709,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { return false; } - const playableVariants = this.manifest_.variants.filter((variant) => { - return shaka.util.StreamUtils.isPlayable(variant); - }); + const playableVariants = shaka.util.StreamUtils.getPlayableVariants( + this.manifest_.variants); // Update the abr manager with newly filtered variants. const adaptationSet = this.currentAdaptationSetCriteria_.create( @@ -5736,17 +5737,55 @@ shaka.Player = class extends shaka.util.FakeEventTarget { } /** - * Re-apply restrictions to the variants, to re-enable variants that were - * temporarily disabled due to network errors. - * If any variants are enabled this way, a new variant might be chosen for - * playback. + * Checks to re-enable variants that were temporarily disabled due to network + * errors. If any variants are enabled this way, a new variant may be chosen + * for playback. * @private */ checkVariants_() { - const tracksChanged = shaka.util.StreamUtils.applyRestrictions( - this.manifest_.variants, this.config_.restrictions, this.maxHwRes_); - if (tracksChanged) { - this.chooseVariant_(); + goog.asserts.assert(this.manifest_, 'Should have manifest!'); + + const now = Date.now() / 1000; + let hasVariantUpdate = false; + + /** @type {function(shaka.extern.Variant):string} */ + const streamsAsString = (variant) => { + let str = ''; + if (variant.video) { + str += 'video:' + variant.video.id; + } + if (variant.audio) { + str += str ? '&' : ''; + str += 'audio:' + variant.audio.id; + } + return str; + }; + + for (const variant of this.manifest_.variants) { + if (variant.disabledUntilTime > 0 && variant.disabledUntilTime <= now) { + variant.disabledUntilTime = 0; + hasVariantUpdate = true; + + shaka.log.v2('Re-enabled variant with ' + streamsAsString(variant)); + } + } + + const shouldStopTimer = this.manifest_.variants.every((variant) => { + goog.asserts.assert( + variant.disabledUntilTime >= 0, + '|variant.disableTimeUntilTime| must always be >= 0'); + return variant.disabledUntilTime === 0; + }); + + if (shouldStopTimer) { + this.checkVariantsTimer_.stop(); + } + + if (hasVariantUpdate) { + // Reconsider re-enabled variant for ABR switching. + this.chooseVariantAndSwitch_( + /* clearBuffer= */ true, /* safeMargin= */ undefined, + /* force= */ false, /* fromAdaptation= */ false); } } @@ -5781,7 +5820,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { * Defaults to 0 if not provided. Ignored if clearBuffer is false. * @private */ - chooseVariantAndSwitch_(clearBuffer = true, safeMargin = 0) { + chooseVariantAndSwitch_(clearBuffer = true, safeMargin = 0, force = false, + fromAdaptation = true) { goog.asserts.assert(this.config_, 'Must not be destroyed'); // Because we're running this after a config change (manual language @@ -5789,8 +5829,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // here. const chosenVariant = this.chooseVariant_(); if (chosenVariant) { - this.switchVariant_(chosenVariant, /* fromAdaptation= */ true, - clearBuffer, safeMargin); + this.switchVariant_(chosenVariant, fromAdaptation, + clearBuffer, safeMargin, force); } } @@ -5799,9 +5839,11 @@ shaka.Player = class extends shaka.util.FakeEventTarget { * @param {boolean} fromAdaptation * @param {boolean} clearBuffer * @param {number} safeMargin + * @param {boolean=} force * @private */ - switchVariant_(variant, fromAdaptation, clearBuffer, safeMargin) { + switchVariant_(variant, fromAdaptation, clearBuffer, safeMargin, + force = false) { const currentVariant = this.streamingEngine_.getCurrentVariant(); if (variant == currentVariant) { shaka.log.debug('Variant already selected.'); @@ -5818,7 +5860,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // Add entries to the history. this.addVariantToSwitchHistory_(variant, fromAdaptation); this.streamingEngine_.switchVariant( - variant, clearBuffer, safeMargin, /* force= */ undefined, + variant, clearBuffer, safeMargin, force, /* adaptation= */ fromAdaptation); let oldTrack = null; if (currentVariant) { @@ -6084,85 +6126,105 @@ shaka.Player = class extends shaka.util.FakeEventTarget { /** @private */ onAbrStatusChanged_() { + // Restore disabled variants if abr get disabled + if (!this.config_.abr.enabled) { + this.restoreDisabledVariants_(); + } + const data = (new Map()).set('newStatus', this.config_.abr.enabled); this.delayDispatchEvent_(this.makeEvent_( shaka.util.FakeEvent.EventName.AbrStatusChanged, data)); } /** - * Tries to recover from NETWORK HTTP_ERROR, temporary disabling the current - * problematic variant. - * @param {!shaka.util.Error} error - * @return {boolean} + * @param {boolean} updateAbrManager * @private */ - tryToRecoverFromError_(error) { - if (![ - shaka.util.Error.Code.HTTP_ERROR, - shaka.util.Error.Code.SEGMENT_MISSING, - shaka.util.Error.Code.BAD_HTTP_STATUS, - shaka.util.Error.Code.TIMEOUT, - ].includes(error.code) || - error.category != shaka.util.Error.Category.NETWORK) { + restoreDisabledVariants_(updateAbrManager=true) { + goog.asserts.assert(this.manifest_, 'Should have manifest!'); + + shaka.log.v2('Restoring all disabled streams...'); + + this.checkVariantsTimer_.stop(); + + for (const variant of this.manifest_.variants) { + variant.disabledUntilTime = 0; + } + + if (updateAbrManager) { + this.updateAbrManagerVariants_(); + } + } + + /** + * Temporarily disable all variants containing |stream| + * @param {shaka.extern.Stream} stream + * @param {number} disableTime + * @return {boolean} + */ + disableStream(stream, disableTime) { + if (!this.config_.abr.enabled || + this.loadMode_ === shaka.Player.LoadMode.DESTROYED) { return false; } if (!navigator.onLine) { - // Don't restrict variants if we're completely offline, or else we end up + // Don't disable variants if we're completely offline, or else we end up // rapidly restricting all of them. return false; } - let maxDisabledTime = this.config_.streaming.maxDisabledTime; - if (maxDisabledTime == 0) { - if (error.code == shaka.util.Error.Code.SEGMENT_MISSING) { - // Spec: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-6.3.3 - // The client SHOULD NOT attempt to load Media Segments that have been - // marked with an EXT-X-GAP tag, or to load Partial Segments with a - // GAP=YES attribute. Instead, clients are encouraged to look for - // another Variant Stream of the same Rendition which does not have the - // same gap, and play that instead. - maxDisabledTime = 1; - } else { - return false; + // It only makes sense to disable a stream if we have an alternative else we + // end up disabling all variants. + const hasAltStream = this.manifest_.variants.some((variant) => { + const altStream = variant[stream.type]; + + if (altStream && altStream.id !== stream.id) { + if (shaka.util.StreamUtils.isAudio(stream)) { + return stream.language === altStream.language; + } + return true; } - } + return false; + }); - if (error.code == shaka.util.Error.Code.HTTP_ERROR) { - shaka.log.debug('Recoverable NETWORK HTTP_ERROR, trying to recover...'); - } + if (hasAltStream) { + let didDisableStream = false; - // Obtain the active variant and disable it from manifest variants - const activeVariantTrack = this.getVariantTracks().find((t) => t.active); - goog.asserts.assert(activeVariantTrack, 'Active variant should be found'); - const manifest = this.manifest_; - for (const variant of manifest.variants) { - if (variant.id === activeVariantTrack.id) { - variant.disabledUntilTime = (Date.now() / 1000) + maxDisabledTime; + for (const variant of this.manifest_.variants) { + const candidate = variant[stream.type]; + + if (candidate && candidate.id === stream.id) { + variant.disabledUntilTime = (Date.now() / 1000) + disableTime; + didDisableStream = true; + + shaka.log.v2( + 'Disabled stream ' + stream.type + ':' + stream.id + + ' for ' + disableTime + ' seconds...'); + } } - } - // Apply restrictions in order to disable variants - shaka.util.StreamUtils.applyRestrictions( - manifest.variants, this.config_.restrictions, this.maxHwRes_); + goog.asserts.assert(didDisableStream, 'Must have disabled stream'); - // Select for a new variant - const chosenVariant = this.chooseVariant_(); - if (!chosenVariant) { - shaka.log.warning('Not enough variants to recover from error'); - return false; - } + this.checkVariantsTimer_.tickEvery(1); - // Get the safeMargin to ensure a seamless playback - const {video} = this.getBufferedInfo(); - const safeMargin = - video.reduce((size, {start, end}) => size + end - start, 0); + // Get the safeMargin to ensure a seamless playback + const {video} = this.getBufferedInfo(); + const safeMargin = + video.reduce((size, {start, end}) => size + end - start, 0); - this.switchVariant_(chosenVariant, /* fromAdaptation= */ false, - /* clearBuffers= */ true, /* safeMargin= */ safeMargin); + // Update abr manager variants and switch to recover playback + this.chooseVariantAndSwitch_( + /* clearBuffer= */ true, /* safeMargin= */ safeMargin, + /* force= */ true, /* fromAdaptation= */ false); + return true; + } - this.checkVariantsTimer_.tickAfter(maxDisabledTime); - return true; + shaka.log.warning( + 'No alternate stream found for active ' + stream.type + ' stream. ' + + 'Will ignore request to disable stream...'); + + return false; } /** @@ -6178,10 +6240,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget { return; } - - if (this.tryToRecoverFromError_(error)) { - error.handled = true; - return; + // Restore disabled variant if the player experienced a critical error. + if (error.severity === shaka.util.Error.Severity.CRITICAL) { + this.restoreDisabledVariants_(/* updateAbrManager= */ false); } const eventName = shaka.util.FakeEvent.EventName.Error; diff --git a/lib/util/periods.js b/lib/util/periods.js index bb1b027d0e..e6cbe620b0 100644 --- a/lib/util/periods.js +++ b/lib/util/periods.js @@ -200,6 +200,7 @@ shaka.util.PeriodCombiner = class { variants.push({ id, language: stream.language, + disabledUntilTime: 0, primary: stream.primary, audio: stream.type == ContentType.AUDIO ? stream : null, video: stream.type == ContentType.VIDEO ? stream : null, @@ -228,6 +229,7 @@ shaka.util.PeriodCombiner = class { variants.push({ id, language: audio.language, + disabledUntilTime: 0, primary: audio.primary, audio, video, diff --git a/lib/util/stream_utils.js b/lib/util/stream_utils.js index a9d4afb5fb..22d24cfd51 100644 --- a/lib/util/stream_utils.js +++ b/lib/util/stream_utils.js @@ -332,13 +332,6 @@ shaka.util.StreamUtils = class { const video = variant.video; - if (variant.disabledUntilTime != 0) { - if (variant.disabledUntilTime > Date.now() / 1000) { - return false; - } - variant.disabledUntilTime = 0; - } - // |video.width| and |video.height| can be undefined, which breaks // the math, so make sure they are there first. if (video && video.width && video.height) { @@ -1392,7 +1385,9 @@ shaka.util.StreamUtils = class { * @return {boolean} */ static isPlayable(variant) { - return variant.allowedByApplication && variant.allowedByKeySystem; + return variant.allowedByApplication && + variant.allowedByKeySystem && + variant.disabledUntilTime == 0; } diff --git a/test/cast/cast_utils_unit.js b/test/cast/cast_utils_unit.js index 33440cb602..04e1d90012 100644 --- a/test/cast/cast_utils_unit.js +++ b/test/cast/cast_utils_unit.js @@ -32,6 +32,7 @@ describe('CastUtils', () => { 'createPlayhead', 'createMediaSourceEngine', 'createStreamingEngine', + 'disableStream', ]; const castMembers = CastUtils.PlayerVoidMethods diff --git a/test/media/streaming_engine_integration.js b/test/media/streaming_engine_integration.js index 1a0b10a6bf..40982e8e20 100644 --- a/test/media/streaming_engine_integration.js +++ b/test/media/streaming_engine_integration.js @@ -263,6 +263,7 @@ describe('StreamingEngine', () => { onInitSegmentAppended: () => {}, beforeAppendSegment: () => Promise.resolve(), onMetadata: () => {}, + disableStream: (stream, time) => false, }; streamingEngine = new shaka.media.StreamingEngine( /** @type {shaka.extern.Manifest} */(manifest), playerInterface); diff --git a/test/media/streaming_engine_unit.js b/test/media/streaming_engine_unit.js index 97d06035d3..9ea8eacce6 100644 --- a/test/media/streaming_engine_unit.js +++ b/test/media/streaming_engine_unit.js @@ -76,6 +76,8 @@ describe('StreamingEngine', () => { let beforeAppendSegment; /** @type {!jasmine.Spy} */ let onMetadata; + /** @type {!jasmine.Spy} */ + let disableStream; /** @type {function(function(), number)} */ let realSetTimeout; @@ -437,6 +439,8 @@ describe('StreamingEngine', () => { onMetadata = jasmine.createSpy('onMetadata'); getBandwidthEstimate = jasmine.createSpy('getBandwidthEstimate'); getBandwidthEstimate.and.returnValue(1e3); + disableStream = jasmine.createSpy('disableStream'); + disableStream.and.callFake(() => false); beforeAppendSegment.and.callFake((segment) => { return Promise.resolve(); @@ -447,6 +451,7 @@ describe('StreamingEngine', () => { config.rebufferingGoal = 2; config.bufferingGoal = 5; config.bufferBehind = Infinity; + config.maxDisabledTime = 0; // Do not disable stream by default } goog.asserts.assert( @@ -464,6 +469,7 @@ describe('StreamingEngine', () => { onInitSegmentAppended: () => {}, beforeAppendSegment: Util.spyFunc(beforeAppendSegment), onMetadata: Util.spyFunc(onMetadata), + disableStream: Util.spyFunc(disableStream), }; streamingEngine = new shaka.media.StreamingEngine( /** @type {shaka.extern.Manifest} */(manifest), playerInterface); @@ -2175,6 +2181,195 @@ describe('StreamingEngine', () => { // baseDelay == 10000, maybe be longer due to delays in the event loop. expect(callbackTime - startTime).toBeGreaterThanOrEqual(10000); }); + + it('temporarily disables stream if configured to do so', async () => { + setupVod(); + + const targetUri = '0_video_0'; + + failRequestsForTarget(netEngine, targetUri); + + mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); + const config = shaka.util.PlayerConfiguration.createDefault().streaming; + config.maxDisabledTime = 2; + createStreamingEngine(config); + + spyOn(streamingEngine, 'makeAbortDecision_').and.callFake(() => { + return Promise.resolve(); + }); + + onError.and.callFake((error) => { + expect(error.severity).toBe(shaka.util.Error.Severity.RECOVERABLE); + expect(error.category).toBe(shaka.util.Error.Category.NETWORK); + expect(error.code).toBe(shaka.util.Error.Code.BAD_HTTP_STATUS); + }); + + disableStream.and.callFake((stream, time) => { + expect(stream).toBe(variant.video); + expect(time).toBeGreaterThan(0); + + createAlternateSegmentIndex(stream, alternateVideoStream); + + streamingEngine.switchVariant( + alternateVariant, /* clearBuffer= */ true, + /* safeMargin= */ 0, /* force= */ true); + return true; + }); + + // Here we go! + streamingEngine.switchVariant(variant); + streamingEngine.switchTextStream(textStream); + await streamingEngine.start(); + playing = true; + + await runTest(); + expect(disableStream).toHaveBeenCalledTimes(1); + }); + + it('does not temporarily disables stream if not configured to', + async () => { + setupVod(); + + const targetUri = '0_audio_init'; + failRequestsForTarget( + netEngine, targetUri, shaka.util.Error.Code.HTTP_ERROR); + + mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); + const config = + shaka.util.PlayerConfiguration.createDefault().streaming; + config.maxDisabledTime = 0; // Do not disable streams. + createStreamingEngine(config); + + onError.and.callFake((error) => { + expect(error.code).toBe(shaka.util.Error.Code.HTTP_ERROR); + }); + + // Here we go! + streamingEngine.switchVariant(variant); + streamingEngine.switchTextStream(textStream); + await streamingEngine.start(); + playing = true; + + await runTest(); + expect(disableStream).not.toHaveBeenCalled(); + }); + + it('always tries to recover shaka.util.Error.Code.SEGMENT_MISSING', + async () => { + setupVod(); + + const targetUri = '0_video_0'; + failRequestsForTarget( + netEngine, targetUri, shaka.util.Error.Code.SEGMENT_MISSING); + + mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); + const config = + shaka.util.PlayerConfiguration.createDefault().streaming; + config.maxDisabledTime = 0; // Do not disable streams. + createStreamingEngine(config); + + spyOn(streamingEngine, 'makeAbortDecision_').and.callFake(() => { + return Promise.resolve(); + }); + + onError.and.callFake((error) => { + expect(error.severity).toBe(shaka.util.Error.Severity.RECOVERABLE); + expect(error.category).toBe(shaka.util.Error.Category.NETWORK); + expect(error.code).toBe(shaka.util.Error.Code.SEGMENT_MISSING); + }); + + disableStream.and.callFake((stream, time) => { + expect(stream).toBe(variant.video); + expect(time).toBeGreaterThan(0); + + createAlternateSegmentIndex(stream, alternateVideoStream); + + streamingEngine.switchVariant( + alternateVariant, /* clearBuffer= */ true, + /* safeMargin= */ 0, /* force= */ true); + return true; + }); + + // Here we go! + streamingEngine.switchVariant(variant); + streamingEngine.switchTextStream(textStream); + await streamingEngine.start(); + playing = true; + + await runTest(); + expect(disableStream).toHaveBeenCalledTimes(1); + }); + + it('throws recoverable error if try to disable stream succeeded', + async () => { + setupVod(); + + const targetUri = '0_video_init'; + failRequestsForTarget( + netEngine, targetUri, shaka.util.Error.Code.BAD_HTTP_STATUS); + + mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); + const config = + shaka.util.PlayerConfiguration.createDefault().streaming; + config.maxDisabledTime = 2; + createStreamingEngine(config); + + disableStream.and.callFake(() => true); + + onError.and.callFake((error) => { + expect(error).toEqual(jasmine.objectContaining({ + code: shaka.util.Error.Code.BAD_HTTP_STATUS, + category: shaka.util.Error.Category.NETWORK, + severity: shaka.util.Error.Severity.RECOVERABLE, + handled: true, + })); + }); + + // Here we go! + streamingEngine.switchVariant(variant); + streamingEngine.switchTextStream(textStream); + await streamingEngine.start(); + playing = true; + + await runTest(); + expect(disableStream).toHaveBeenCalled(); + expect(onError).toHaveBeenCalled(); + }); + + it('throws critical error if try to disable stream failed', async () => { + setupVod(); + + const targetUri = '0_video_init'; + failRequestsForTarget( + netEngine, targetUri, shaka.util.Error.Code.BAD_HTTP_STATUS); + + mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); + const config = shaka.util.PlayerConfiguration.createDefault().streaming; + config.maxDisabledTime = 2; + createStreamingEngine(config); + + disableStream.and.callFake(() => false); + + onError.and.callFake((error) => { + expect(error).toEqual(jasmine.objectContaining({ + code: shaka.util.Error.Code.BAD_HTTP_STATUS, + category: shaka.util.Error.Category.NETWORK, + severity: shaka.util.Error.Severity.CRITICAL, + handled: false, + })); + }); + + // Here we go! + streamingEngine.switchVariant(variant); + streamingEngine.switchTextStream(textStream); + await streamingEngine.start(); + playing = true; + + await runTest(); + + expect(disableStream).toHaveBeenCalled(); + expect(onError).toHaveBeenCalled(); + }); }); describe('retry()', () => { @@ -3885,7 +4080,8 @@ describe('StreamingEngine', () => { if (request.uris[0] == targetUri) { const data = [targetUri]; - if (errorCode == shaka.util.Error.Code.BAD_HTTP_STATUS) { + if (errorCode == shaka.util.Error.Code.BAD_HTTP_STATUS || + errorCode == shaka.util.Error.Code.SEGMENT_MISSING) { data.push(404); data.push(''); } @@ -3956,4 +4152,40 @@ describe('StreamingEngine', () => { return segmentReference; }; } + + /** + * Create a valid segment index for |alternateStream| based on |baseStream| + * segment index. + * + * @param {shaka.extern.Stream} baseStream + * @param {shaka.extern.Stream} alternateStream + */ + function createAlternateSegmentIndex(baseStream, alternateStream) { + const createSegmentIndexSpy = + Util.funcSpy(alternateStream.createSegmentIndex); + const altSegmentIndex = new shaka.test.FakeSegmentIndex(); + + altSegmentIndex.find.and.callFake( + (time) => baseStream.segmentIndex.find(time)); + + altSegmentIndex.get.and.callFake((pos) => { + const ref = baseStream.segmentIndex.get(pos); + + if (ref) { + const altInitUri = ref.initSegmentReference.getUris()[0] + '_alt'; + const altSegmentUri = ref.getUris()[0] + '_alt'; + + ref.initSegmentReference.getUris = () => [altInitUri]; + ref.getUris = () => [altSegmentUri]; + return ref; + } + + return null; + }); + + createSegmentIndexSpy.and.callFake(() => { + alternateStream.segmentIndex = altSegmentIndex; + return Promise.resolve(); + }); + } }); diff --git a/test/player_unit.js b/test/player_unit.js index 323c1ebcb0..13548fd3d5 100644 --- a/test/player_unit.js +++ b/test/player_unit.js @@ -6,7 +6,6 @@ describe('Player', () => { const ContentType = shaka.util.ManifestParserUtils.ContentType; - const StreamUtils = shaka.util.StreamUtils; const Util = shaka.test.Util; const originalLogError = shaka.log.error; @@ -30,8 +29,6 @@ describe('Player', () => { let player; /** @type {!shaka.test.FakeAbrManager} */ let abrManager; - /** @type {function(!shaka.util.Error)} */ - let onErrorCallback; /** @type {!shaka.test.FakeNetworkingEngine} */ let networkingEngine; @@ -138,8 +135,7 @@ describe('Player', () => { ended: jasmine.createSpy('ended').and.returnValue(false), }; - player.createDrmEngine = ({onError}) => { - onErrorCallback = onError; + player.createDrmEngine = () => { return drmEngine; }; player.createNetworkingEngine = () => networkingEngine; @@ -343,182 +339,376 @@ describe('Player', () => { }); }); - describe('onError and tryToRecoverFromError', () => { - const httpError = new shaka.util.Error( - shaka.util.Error.Severity.RECOVERABLE, - shaka.util.Error.Category.NETWORK, - shaka.util.Error.Code.HTTP_ERROR); - const badHttpStatusError = new shaka.util.Error( - shaka.util.Error.Severity.RECOVERABLE, - shaka.util.Error.Category.NETWORK, - shaka.util.Error.Code.BAD_HTTP_STATUS); - const timeoutError = new shaka.util.Error( - shaka.util.Error.Severity.RECOVERABLE, - shaka.util.Error.Category.NETWORK, - shaka.util.Error.Code.TIMEOUT); - const operationAbortedError = new shaka.util.Error( - shaka.util.Error.Severity.RECOVERABLE, - shaka.util.Error.Category.NETWORK, - shaka.util.Error.Code.OPERATION_ABORTED); + describe('disableStream', () => { + /** @type {function(function(), number)} */ + let realSetTimeout; + /** @type {number} */ + let disableTimeInSeconds; /** @type {?jasmine.Spy} */ - let dispatchEventSpy; + let getBufferedInfoSpy; + + beforeAll(() => { + realSetTimeout = window.setTimeout; + jasmine.clock().install(); + jasmine.clock().mockDate(); + }); + + afterAll(() => { + jasmine.clock().uninstall(); + }); - beforeEach(async () => { + beforeEach(() => { + disableTimeInSeconds = 30; + getBufferedInfoSpy = spyOn(player, 'getBufferedInfo') + .and.returnValue(bufferedInfo); + }); + + async function runTest(variantIndex, streamType, expectedStatus) { + await player.load(fakeManifestUri, 0, fakeMimeType); + + const stream = + /** @type {shaka.extern.Stream} */ + (manifest.variants[variantIndex][streamType]); + const status = player.disableStream(stream, 10); + + expect(status).toBe(expectedStatus); + } + + function multiVariantManifest() { manifest = shaka.test.ManifestGenerator.generate((manifest) => { - manifest.addVariant(11, (variant) => { - variant.addAudio(2); + manifest.addVariant(0, (variant) => { + variant.addAudio(1, (stream) => { + stream.mime('audio/mp4', 'mp4a.40.2'); + stream.language = 'en'; + }); + variant.addVideo(2); + }); + manifest.addVariant(1, (variant) => { + variant.addExistingStream(1); variant.addVideo(3); }); - manifest.addVariant(12, (variant) => { - variant.addAudio(4); - variant.addVideo(5); + manifest.addVariant(2, (variant) => { + variant.addExistingStream(1); + variant.addVideo(4); }); }); + } + + it('disable and restore stream after configured time', async () => { + multiVariantManifest(); + await player.load(fakeManifestUri, 0, fakeMimeType); - dispatchEventSpy = spyOn(player, 'dispatchEvent').and.returnValue(true); - }); - afterEach(() => { - dispatchEventSpy.calls.reset(); - }); + const variant = manifest.variants[0]; + const videoStream = /** @type {shaka.extern.Stream} */ (variant.video); - it('does not handle non recoverable network error', () => { - onErrorCallback(operationAbortedError); - expect(operationAbortedError.handled).toBeFalsy(); - expect(player.dispatchEvent).toHaveBeenCalled(); - }); + player.disableStream(videoStream, disableTimeInSeconds); - describe('when config.streaming.maxDisabledTime is 0', () => { - it('does not handle NETWORK HTTP_ERROR', () => { - player.configure({streaming: {maxDisabledTime: 0}}); - httpError.handled = false; - onErrorCallback(httpError); - expect(httpError.handled).toBeFalsy(); - expect(player.dispatchEvent).toHaveBeenCalled(); - }); + expect(variant.disabledUntilTime).toBeGreaterThan(Date.now()/1000); + + await shaka.test.Util.fakeEventLoop(disableTimeInSeconds); + + expect(variant.disabledUntilTime).toBe(0); }); - describe('when config.streaming.maxDisabledTime is 30', () => { - /** @type {number} */ - const maxDisabledTime = 30; - /** @type {number} */ - const currentTime = 123; - const chosenVariant = { - id: 1, - language: 'es', - disabledUntilTime: 0, - primary: false, - bandwidth: 2100, - allowedByApplication: true, - allowedByKeySystem: true, - decodingInfos: [], - }; - /** @type {?jasmine.Spy} */ - let applyRestrictionsSpy; - /** @type {?jasmine.Spy} */ - let chooseVariantSpy; - /** @type {?jasmine.Spy} */ - let getBufferedInfoSpy; - /** @type {?jasmine.Spy} */ - let switchVariantSpy; - - describe('and there is new variant', () => { - const oldDateNow = Date.now; - beforeEach(() => { - Date.now = () => currentTime * 1000; - chooseVariantSpy = spyOn(player, 'chooseVariant_') - .and.returnValue(chosenVariant); - getBufferedInfoSpy = spyOn(player, 'getBufferedInfo') - .and.returnValue(bufferedInfo); - switchVariantSpy = spyOn(player, 'switchVariant_'); - applyRestrictionsSpy = spyOn(StreamUtils, 'applyRestrictions'); - httpError.handled = false; - dispatchEventSpy.calls.reset(); - player.configure({streaming: {maxDisabledTime}}); - player.setMaxHardwareResolution(123, 456); + it('does not restore stream if disabled time did not elapsed', + async () => { + multiVariantManifest(); + + await player.load(fakeManifestUri, 0, fakeMimeType); + + const variant = manifest.variants[0]; + const videoStream = + /** @type {shaka.extern.Stream} */ (variant.video); + + player.disableStream(videoStream, disableTimeInSeconds); + + expect(variant.disabledUntilTime).toBeGreaterThan(Date.now()/1000); + + await shaka.test.Util.fakeEventLoop(disableTimeInSeconds - 5); + + expect(variant.disabledUntilTime).toBeGreaterThan(Date.now()/1000); }); - afterEach(() => { - Date.now = oldDateNow; - chooseVariantSpy.calls.reset(); - getBufferedInfoSpy.calls.reset(); - switchVariantSpy.calls.reset(); - applyRestrictionsSpy.calls.reset(); + it('updates abrManager and switch after disabling a stream', async () => { + multiVariantManifest(); + + await player.load(fakeManifestUri, 0, fakeMimeType); + + const variantCount = manifest.variants.length; + const variant = manifest.variants[0]; + const videoStream = + /** @type {shaka.extern.Stream} */ (variant.video); + + player.disableStream(videoStream, disableTimeInSeconds); + + // Disabled as expected? + expect(variant.disabledUntilTime).toBeGreaterThan(Date.now()/1000); + expect(abrManager.setVariants).toHaveBeenCalled(); + expect(abrManager.chooseVariant).toHaveBeenCalled(); + expect(streamingEngine.switchVariant).toHaveBeenCalled(); + expect(getBufferedInfoSpy).toHaveBeenCalled(); + + const updatedVariants = + abrManager.setVariants.calls.mostRecent().args[0]; + const alternateVariant = + streamingEngine.switchVariant.calls.mostRecent().args[0]; + const safeMargin = + streamingEngine.switchVariant.calls.mostRecent().args[2]; + const forceSwitch = + streamingEngine.switchVariant.calls.mostRecent().args[3]; + const fromAdaptation = + streamingEngine.switchVariant.calls.mostRecent().args[4]; + + expect(updatedVariants.length).toBe(variantCount - 1); + expect(alternateVariant.video).not.toEqual(variant.video); + expect(safeMargin).toBe(14); + expect(forceSwitch).toBeTruthy(); + expect(fromAdaptation).toBeFalsy(); + }); + + it('updates abrManager and switch after restoring a stream', async () => { + multiVariantManifest(); + + await player.load(fakeManifestUri, 0, fakeMimeType); + + const variantCount = manifest.variants.length; + const variant = manifest.variants[0]; + const videoStream = /** @type {shaka.extern.Stream} */ (variant.video); + + player.disableStream(videoStream, disableTimeInSeconds); + + await shaka.test.Util.fakeEventLoop(disableTimeInSeconds); + + // Restored as expected? + expect(variant.disabledUntilTime).toBe(0); + expect(abrManager.setVariants).toHaveBeenCalled(); + expect(abrManager.chooseVariant).toHaveBeenCalled(); + expect(streamingEngine.switchVariant).toHaveBeenCalled(); + + const updatedVariants = + abrManager.setVariants.calls.mostRecent().args[0]; + const forceSwitch = + streamingEngine.switchVariant.calls.mostRecent().args[3]; + const fromAdaptation = + streamingEngine.switchVariant.calls.mostRecent().args[4]; + + expect(updatedVariants.length).toBe(variantCount); + expect(forceSwitch).toBeFalsy(); + expect(fromAdaptation).toBeFalsy(); + }); + + it('disables all variants containing stream', async () => { + manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.addVariant(0, (variant) => { + variant.addAudio(1, (stream) => { + stream.mime('audio/mp4', 'mp4a.40.2'); + stream.language = 'en'; + stream.bandwidth = 100; + }); + variant.addVideo(2); + }); + manifest.addVariant(1, (variant) => { + variant.addExistingStream(1); + variant.addVideo(3); }); - it('handles HTTP_ERROR', () => { - onErrorCallback(httpError); - expect(httpError.handled).toBeTruthy(); + manifest.addVariant(2, (variant) => { + variant.addAudio(4, (stream) => { + stream.mime('audio/mp4', 'mp4a.40.2'); + stream.language = 'en'; + stream.bandwidth = 200; + }); + variant.addExistingStream(2); + }); + manifest.addVariant(3, (variant) => { + variant.addExistingStream(4); + variant.addExistingStream(3); }); + }); - it('handles BAD_HTTP_STATUS', () => { - onErrorCallback(badHttpStatusError); - expect(badHttpStatusError.handled).toBeTruthy(); + await player.load(fakeManifestUri, 0, fakeMimeType); + + const variantCount = manifest.variants.length; + const variantAffected = 2; + const audioStream = + /** @type {shaka.extern.Stream} */ (manifest.variants[0].audio); + + player.disableStream(audioStream, disableTimeInSeconds); + + await shaka.test.Util.shortDelay(realSetTimeout); + + expect(abrManager.setVariants).toHaveBeenCalled(); + + const updatedVariants = + abrManager.setVariants.calls.mostRecent().args[0]; + + expect(updatedVariants.length).toBe(variantCount - variantAffected); + + for (const variant of updatedVariants) { + expect(variant.audio).not.toEqual(audioStream); + } + }); + + it('does not disable streams if abr is disabled', async () => { + await player.load(fakeManifestUri, 0, fakeMimeType); + + player.configure({abr: {enabled: true}}); + + const videoStream = + /** @type {shaka.extern.Stream} */ (manifest.variants[0].video); + let status = player.disableStream(videoStream, 10); + expect(status).toBe(true); + + player.configure({abr: {enabled: false}}); + + status = player.disableStream(videoStream, 10); + expect(status).toBe(false); + }); + + it('disables stream if have alternate stream', async () => { + // Run test with the default manifest + await runTest(0, 'video', true); + + manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.addVariant(0, (variant) => { + variant.addAudio(1, (stream) => { + stream.mime('audio/mp4', 'mp4a.40.2'); + stream.language = 'en'; + stream.bandwidth = 10; + }); + variant.addVideo(2); }); + manifest.addVariant(1, (variant) => { + variant.addAudio(3, (stream) => { + stream.mime('audio/mp4', 'mp4a.40.2'); + stream.language = 'en'; + stream.bandwidth = 20; + }); + variant.addExistingStream(2); + }); + }); - it('handles TIMEOUT', () => { - onErrorCallback(timeoutError); - expect(timeoutError.handled).toBeTruthy(); + player.configure({abr: {enabled: true}}); + + await runTest(0, 'audio', true); + }); + + describe('does not disable stream if there not alternate stream', () => { + it('single audio multiple videos', async () => { + manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.addVariant(0, (variant) => { + variant.addAudio(1, (stream) => { + stream.mime('audio/mp4', 'mp4a.40.2'); + stream.language = 'en'; + }); + variant.addVideo(2); + }); + manifest.addVariant(1, (variant) => { + variant.addExistingStream(1); + variant.addVideo(3); + }); }); - it('does not dispatch any error', () => { - onErrorCallback(httpError); - expect(dispatchEventSpy).not.toHaveBeenCalled(); + await runTest(0, 'audio', false); + }); + + it('multiple audio different languages', async () => { + manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.addVariant(0, (variant) => { + variant.addAudio(1, (stream) => { + stream.mime('audio/mp4', 'mp4a.40.2'); + stream.language = 'en'; + }); + variant.addVideo(2); + }); + manifest.addVariant(1, (variant) => { + variant.addAudio(3, (stream) => { + stream.mime('audio/mp4', 'mp4a.40.2'); + stream.language = 'de'; + }); + variant.addExistingStream(2); + }); }); - it('disables the current variant and applies restrictions', () => { - onErrorCallback(httpError); - - const foundDisabledVariant = - manifest.variants.some(({disabledUntilTime}) => - disabledUntilTime == currentTime + maxDisabledTime); - expect(foundDisabledVariant).toBeTruthy(); - expect(applyRestrictionsSpy).toHaveBeenCalledWith( - manifest.variants, - player.getConfiguration().restrictions, - {width: 123, height: 456}); + await runTest(1, 'audio', false); + }); + + it('single variant', async () => { + manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.addVariant(0, (variant) => { + variant.addAudio(1); + variant.addVideo(2); + }); }); - it('switches the variant', () => { - onErrorCallback(httpError); + await runTest(0, 'audio', false); + await runTest(0, 'video', false); + }); - expect(chooseVariantSpy).toHaveBeenCalled(); - expect(getBufferedInfoSpy).toHaveBeenCalled(); - expect(switchVariantSpy) - .toHaveBeenCalledWith(chosenVariant, false, true, 14); + it('single video multiple audio', async () => { + manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.addVariant(0, (variant) => { + variant.addAudio(1, (stream) => { + stream.mime('audio/mp4', 'mp4a.40.2'); + stream.language = 'en'; + stream.bandwidth = 10; + }); + variant.addVideo(2); + }); + manifest.addVariant(1, (variant) => { + variant.addAudio(3, (stream) => { + stream.mime('audio/mp4', 'mp4a.40.2'); + stream.language = 'en'; + stream.bandwidth = 20; + }); + variant.addExistingStream(2); + }); }); - describe('but browser is truly offline', () => { - /** @type {!Object} */ - let navigatorOnLineDescriptor; + await runTest(0, 'video', false); + }); - // eslint-disable-next-line no-restricted-syntax - const navigatorPrototype = Navigator.prototype; + describe('or', () => { + /** @type {!Object} */ + let navigatorOnLineDescriptor; - beforeAll(() => { - navigatorOnLineDescriptor = - /** @type {!Object} */(Object.getOwnPropertyDescriptor( - navigatorPrototype, 'onLine')); - }); + // eslint-disable-next-line no-restricted-syntax + const navigatorPrototype = Navigator.prototype; - beforeEach(() => { - // Redefine the property, replacing only the getter. - Object.defineProperty(navigatorPrototype, 'onLine', - Object.assign(navigatorOnLineDescriptor, { - get: () => false, - })); - }); + beforeAll(() => { + navigatorOnLineDescriptor = + /** @type {!Object} */(Object.getOwnPropertyDescriptor( + navigatorPrototype, 'onLine')); + }); - afterEach(() => { - // Restore the original property definition. - Object.defineProperty( - navigatorPrototype, 'onLine', navigatorOnLineDescriptor); - }); + beforeEach(() => { + // Redefine the property, replacing only the getter. + Object.defineProperty(navigatorPrototype, 'onLine', + Object.assign(navigatorOnLineDescriptor, { + get: () => false, + })); + }); - it('does not handle HTTP_ERROR', () => { - onErrorCallback(httpError); - expect(httpError.handled).toBe(false); + afterEach(() => { + // Restore the original property definition. + Object.defineProperty( + navigatorPrototype, 'onLine', navigatorOnLineDescriptor); + }); + + it('browser is truly offline', async () => { + manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.addVariant(11, (variant) => { + variant.addAudio(2); + variant.addVideo(3); + }); + manifest.addVariant(12, (variant) => { + variant.addAudio(4); + variant.addVideo(5); + }); }); + + await runTest(0, 'video', false); }); }); }); diff --git a/test/test/util/streaming_engine_util.js b/test/test/util/streaming_engine_util.js index e8a41e0cae..ba49e67b25 100644 --- a/test/test/util/streaming_engine_util.js +++ b/test/test/util/streaming_engine_util.js @@ -33,7 +33,7 @@ shaka.test.StreamingEngineUtil = class { expect(request.uris.length).toBe(1); const parts = request.uris[0].split('_'); - expect(parts.length).toBe(3); + expect(parts.length).toBeGreaterThanOrEqual(3); const periodIndex = Number(parts[0]); expect(periodIndex).not.toBeNaN(); diff --git a/test/util/stream_utils_unit.js b/test/util/stream_utils_unit.js index da02e5a5e0..527b9d6d30 100644 --- a/test/util/stream_utils_unit.js +++ b/test/util/stream_utils_unit.js @@ -921,111 +921,27 @@ describe('StreamUtils', () => { }); }); - describe('meetsRestrictions', () => { - const oldDateNow = Date.now; - /* @param {shaka.extern.Restrictions} */ - const restrictions = { - minWidth: 10, - maxWidth: 20, - minHeight: 10, - maxHeight: 20, - minPixels: 10, - maxPixels: 20, - minFrameRate: 21, - maxFrameRate: 25, - minBandwidth: 1000, - maxBandwidth: 3000, + describe('isPlayable', () => { + /** @type {shaka.extern.Variant} */ + const variant = { + id: 1, + language: 'es', + disabledUntilTime: 0, + video: null, + audio: null, + primary: false, + bandwidth: 2000, + allowedByApplication: true, + allowedByKeySystem: true, + decodingInfos: [], }; - /* @param {{width: number, height: number}} */ - const maxHwRes = {width: 123, height: 456}; - /* @param {shaka.extern.Variant} */ - let variant; - /* @param {boolean} */ - let meetsRestrictionsResult; - - beforeEach(() => { - Date.now = () => 123 * 1000; - }); - - afterEach(() => { - Date.now = oldDateNow; - }); - - /* - * @param {number} disabledUntilTime - */ - const checkRestrictions = ({disabledUntilTime = 0}) => { - /* @param {shaka.extern.Variant} */ - variant = { - id: 1, - language: 'es', - disabledUntilTime, - video: null, - audio: null, - primary: false, - bandwidth: 2000, - allowedByApplication: true, - allowedByKeySystem: true, - decodingInfos: [], - }; - meetsRestrictionsResult = StreamUtils.meetsRestrictions(variant, - restrictions, maxHwRes); - }; - - describe('when disabledUntilTime > now', () => { - beforeEach(() => { - checkRestrictions({disabledUntilTime: 124}); - }); - - it('does not meet the restrictions', () => { - expect(meetsRestrictionsResult).toBeFalsy(); - }); - - it('does not reset disabledUntilTime', () => { - expect(variant.disabledUntilTime).toBe(124); - }); - }); - - describe('when disabledUntilTime == now', () => { - beforeEach(() => { - checkRestrictions({disabledUntilTime: 123}); - }); - - it('meets the restrictions', () => { - expect(meetsRestrictionsResult).toBeTruthy(); - }); - - it('resets disabledUntilTime', () => { - expect(variant.disabledUntilTime).toBe(0); - }); - }); - describe('when disabledUntilTime == now', () => { - beforeEach(() => { - checkRestrictions({disabledUntilTime: 122}); - }); - - it('meets the restrictions', () => { - expect(meetsRestrictionsResult).toBeTruthy(); - }); + it('returns false if variant is disabled', () => { + variant.allowedByApplication = true; + variant.allowedByKeySystem = true; + variant.disabledUntilTime = 1234; - it('resets disabledUntilTime', () => { - expect(variant.disabledUntilTime).toBe(0); - }); - }); - - describe('when disabledUntilTime == 0', () => { - beforeEach(() => { - checkRestrictions({disabledUntilTime: 0}); - }); - - it('meets the restrictions', () => { - expect(meetsRestrictionsResult).toBeTruthy(); - }); - - it('leaves disabledUntilTime = 0', () => { - expect(variant.disabledUntilTime).toBe(0); - }); + expect(shaka.util.StreamUtils.isPlayable(variant)).toBe(false); }); }); });