/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ describe('StreamingEngine', () => { const Util = shaka.test.Util; const ContentType = shaka.util.ManifestParserUtils.ContentType; const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; // Dummy byte ranges and sizes for initialization and media segments. // Create empty object first and initialize the fields through // [] to allow field names to be expressions. /** * @type {!Object.>} */ const initSegmentRanges = {}; initSegmentRanges[ContentType.AUDIO] = [100, 1000]; initSegmentRanges[ContentType.VIDEO] = [200, 2000]; /** @type {!Object.} */ const segmentSizes = {}; segmentSizes[ContentType.AUDIO] = 1000; segmentSizes[ContentType.VIDEO] = 10000; segmentSizes[ContentType.TEXT] = 500; /** @type {!Object.} */ let segmentData; /** @type {number} */ let presentationTimeInSeconds; /** @type {boolean} */ let playing; /** @type {!shaka.test.FakeMediaSourceEngine} */ let mediaSourceEngine; /** @type {{audio: number, video: number, text: number}} */ let netEngineDelays; /** @type {!shaka.test.FakeNetworkingEngine} */ let netEngine; /** @type {{start: number, end: number}} */ let segmentAvailability; /** @type {!shaka.test.FakePresentationTimeline} */ let timeline; /** @type {?shaka.extern.Stream} */ let audioStream; /** @type {?shaka.extern.Stream} */ let videoStream; /** @type {shaka.extern.Variant} */ let variant; /** @type {shaka.extern.Stream} */ let textStream; /** @type {shaka.extern.Variant} */ let alternateVariant; /** @type {shaka.extern.Stream} */ let alternateVideoStream; /** @type {shaka.extern.Manifest} */ let manifest; /** @type {!jasmine.Spy} */ let onError; /** @type {!jasmine.Spy} */ let onEvent; /** @type {!jasmine.Spy} */ let onManifestUpdate; /** @type {!jasmine.Spy} */ let onSegmentAppended; /** @type {!jasmine.Spy} */ let getBandwidthEstimate; /** @type {!shaka.media.StreamingEngine} */ let streamingEngine; /** * Runs the fake event loop. * @param {function()=} callback An optional callback that is executed * each time the clock ticks. */ async function runTest(callback) { async function onTick(currentTime) { if (callback) { await callback(); } if (playing) { presentationTimeInSeconds++; } } // No test should require more than 60 seconds of simulated time. await Util.fakeEventLoop(60, onTick); } beforeAll(() => { jasmine.clock().install(); jasmine.clock().mockDate(); }); /** @param {boolean=} trickMode */ function setupVod(trickMode) { // For VOD, we fake a presentation that has 2 Periods of equal duration // (20 seconds), where each Period has 1 Variant and 1 text stream. // // There are 4 initialization segments: 1 audio and 1 video for the // first Period, and 1 audio and 1 video for the second Period. // // There are 12 media segments: 2 audio, 2 video, and 2 text for the // first Period, and 2 audio, 2 video, and 2 text for the second Period. // All media segments are (by default) 10 seconds long. // Create SegmentData map for FakeMediaSourceEngine. const initSegmentSizeAudio = initSegmentRanges[ContentType.AUDIO][1] - initSegmentRanges[ContentType.AUDIO][0] + 1; const initSegmentSizeVideo = initSegmentRanges[ContentType.VIDEO][1] - initSegmentRanges[ContentType.VIDEO][0] + 1; const makeBuffer = (size) => new ArrayBuffer(size); segmentData = { audio: { initSegments: [ makeBuffer(initSegmentSizeAudio), makeBuffer(initSegmentSizeAudio), ], segments: [ makeBuffer(segmentSizes[ContentType.AUDIO]), makeBuffer(segmentSizes[ContentType.AUDIO]), makeBuffer(segmentSizes[ContentType.AUDIO]), makeBuffer(segmentSizes[ContentType.AUDIO]), ], segmentStartTimes: [0, 10, 20, 30], segmentDuration: 10, }, video: { initSegments: [ makeBuffer(initSegmentSizeVideo), makeBuffer(initSegmentSizeVideo), ], segments: [ makeBuffer(segmentSizes[ContentType.VIDEO]), makeBuffer(segmentSizes[ContentType.VIDEO]), makeBuffer(segmentSizes[ContentType.VIDEO]), makeBuffer(segmentSizes[ContentType.VIDEO]), ], segmentStartTimes: [0, 10, 20, 30], segmentDuration: 10, }, text: { initSegments: [], segments: [ makeBuffer(segmentSizes[ContentType.TEXT]), makeBuffer(segmentSizes[ContentType.TEXT]), makeBuffer(segmentSizes[ContentType.TEXT]), makeBuffer(segmentSizes[ContentType.TEXT]), ], segmentStartTimes: [0, 10, 20, 30], segmentDuration: 10, }, }; if (trickMode) { segmentData.trickvideo = { initSegments: [ makeBuffer(initSegmentSizeVideo), makeBuffer(initSegmentSizeVideo), ], segments: [ makeBuffer(segmentSizes[ContentType.VIDEO]), makeBuffer(segmentSizes[ContentType.VIDEO]), makeBuffer(segmentSizes[ContentType.VIDEO]), makeBuffer(segmentSizes[ContentType.VIDEO]), ], segmentStartTimes: [0, 10, 20, 30], segmentDuration: 10, }; } presentationTimeInSeconds = 0; playing = false; setupNetworkingEngine( /* segmentsInFirstPeriod= */ 2, /* segmentsInSecondPeriod= */ 2); segmentAvailability = { start: 0, end: 40, }; timeline = shaka.test.StreamingEngineUtil.createFakePresentationTimeline( segmentAvailability, /* presentationDuration= */ 40, /* maxSegmentDuration= */ 10, /* isLive= */ false); setupManifest( /* firstPeriodStartTime= */ 0, /* secondPeriodStartTime= */ 20, /* presentationDuration= */ 40); } function setupLive() { // For live, we fake a presentation that has 2 Periods of different // durations (120 seconds and 20 seconds respectively), where each Period // has 1 Variant and 1 text stream. // // There are 4 initialization segments: 1 audio and 1 video for the // first Period, and 1 audio and 1 video for the second Period. // // There are 14 media segments: 12 audio, 12 video, and 12 text for the // first Period, and 2 audio, 2 video, and 2 text for the second Period. // All media segments are (by default) 10 seconds long. // // The segment availability window starts at t=100 (segment 11) and extends // to t=120 (segment 13). // Create SegmentData map for FakeMediaSourceEngine. const initSegmentSizeAudio = initSegmentRanges[ContentType.AUDIO][1] - initSegmentRanges[ContentType.AUDIO][0] + 1; const initSegmentSizeVideo = initSegmentRanges[ContentType.VIDEO][1] - initSegmentRanges[ContentType.VIDEO][0] + 1; const makeBuffer = (size) => new ArrayBuffer(size); segmentData = { audio: { initSegments: [makeBuffer(initSegmentSizeAudio), makeBuffer(initSegmentSizeAudio)], segments: [], segmentStartTimes: [], segmentDuration: 10, }, video: { initSegments: [makeBuffer(initSegmentSizeVideo), makeBuffer(initSegmentSizeVideo)], segments: [], segmentStartTimes: [], segmentDuration: 10, }, text: { initSegments: [], segments: [], segmentStartTimes: [], segmentDuration: 10, }, }; const segmentsInFirstPeriod = 12; for (const i of shaka.util.Iterables.range(segmentsInFirstPeriod)) { segmentData[ContentType.AUDIO].segments.push( makeBuffer(segmentSizes[ContentType.AUDIO])); segmentData[ContentType.VIDEO].segments.push( makeBuffer(segmentSizes[ContentType.VIDEO])); segmentData[ContentType.TEXT].segments.push( makeBuffer(segmentSizes[ContentType.TEXT])); segmentData[ContentType.AUDIO].segmentStartTimes.push(i * 10); segmentData[ContentType.VIDEO].segmentStartTimes.push(i * 10); segmentData[ContentType.TEXT].segmentStartTimes.push(i * 10); } const segmentsInSecondPeriod = 2; for (const i of shaka.util.Iterables.range(segmentsInSecondPeriod)) { segmentData[ContentType.AUDIO].segments.push( makeBuffer(segmentSizes[ContentType.AUDIO])); segmentData[ContentType.VIDEO].segments.push( makeBuffer(segmentSizes[ContentType.VIDEO])); segmentData[ContentType.TEXT].segments.push( makeBuffer(segmentSizes[ContentType.TEXT])); segmentData[ContentType.AUDIO].segmentStartTimes.push( (segmentsInFirstPeriod + i) * 10); segmentData[ContentType.VIDEO].segmentStartTimes.push( (segmentsInFirstPeriod + i) * 10); segmentData[ContentType.TEXT].segmentStartTimes.push( (segmentsInFirstPeriod + i) * 10); } presentationTimeInSeconds = 110; playing = false; setupNetworkingEngine( /* segmentsInFirstPeriod= */ 12, /* segmentsInSecondPeriod= */ 2); // NOTE: Many tests here start playback at 100, so the availability start is // 90. This allows the async index creation processes to complete before // the window moves, which gives us the startup conditions the tests expect. // Keep in mind that the fake event loop in the tests ticks in whole // seconds, so real async processes may take a surprising amount of fake // time to complete. To test actual boundary conditions, you can change // segmentAvailability.start in the test setup. segmentAvailability = { start: 90, end: 140, }; timeline = shaka.test.StreamingEngineUtil.createFakePresentationTimeline( segmentAvailability, /* presentationDuration= */ 140, /* maxSegmentDuration= */ 10, /* isLive= */ true); setupManifest( /* firstPeriodStartTime= */ 0, /* secondPeriodStartTime= */ 120, /* presentationDuration= */ 140); } function setupNetworkingEngine( segmentsInFirstPeriod, segmentsInSecondPeriod) { // Create the fake NetworkingEngine. Note: the StreamingEngine should never // request a segment that does not exist. netEngineDelays = { audio: 0, video: 0, text: 0, }; netEngine = shaka.test.StreamingEngineUtil.createFakeNetworkingEngine( // Init segment generator: (type, periodIndex) => { expect((periodIndex == 0) || (periodIndex == 1)); return segmentData[type].initSegments[periodIndex]; }, // Media segment generator: (type, periodIndex, position) => { expect(position).toBeGreaterThan(-1); expect((periodIndex == 0 && position <= segmentsInFirstPeriod) || (periodIndex == 1 && position <= segmentsInSecondPeriod)); return segmentData[type].segments[position]; }, /* delays= */ netEngineDelays); } function setupManifest( firstPeriodStartTime, secondPeriodStartTime, presentationDuration) { const segmentDurations = { audio: segmentData[ContentType.AUDIO].segmentDuration, video: segmentData[ContentType.VIDEO].segmentDuration, text: segmentData[ContentType.TEXT].segmentDuration, }; if (segmentData['trickvideo']) { segmentDurations['trickvideo'] = segmentData['trickvideo'].segmentDuration; } manifest = shaka.test.StreamingEngineUtil.createManifest( /** @type {!shaka.media.PresentationTimeline} */(timeline), [firstPeriodStartTime, secondPeriodStartTime], presentationDuration, segmentDurations, initSegmentRanges); audioStream = manifest.variants[0].audio; videoStream = manifest.variants[0].video; variant = manifest.variants[0]; textStream = manifest.textStreams[0]; // This Stream is only used to verify that StreamingEngine can setup // Streams correctly. alternateVideoStream = shaka.test.StreamingEngineUtil.createMockVideoStream(8); alternateVariant = { audio: audioStream, video: /** @type {shaka.extern.Stream} */ (alternateVideoStream), id: 0, language: 'und', primary: false, bandwidth: 0, allowedByApplication: true, allowedByKeySystem: true, }; manifest.variants.push(alternateVariant); } /** * Creates the StreamingEngine. ** * @param {shaka.extern.StreamingConfiguration=} config Optional * configuration object which overrides the default one. */ function createStreamingEngine(config) { onError = jasmine.createSpy('onError'); onError.and.callFake(fail); onEvent = jasmine.createSpy('onEvent'); onManifestUpdate = jasmine.createSpy('onManifestUpdate'); onSegmentAppended = jasmine.createSpy('onSegmentAppended'); getBandwidthEstimate = jasmine.createSpy('getBandwidthEstimate'); getBandwidthEstimate.and.returnValue(1e3); if (!config) { config = shaka.util.PlayerConfiguration.createDefault().streaming; config.rebufferingGoal = 2; config.bufferingGoal = 5; config.bufferBehind = Infinity; } goog.asserts.assert( presentationTimeInSeconds != undefined, 'All tests should have defined an initial presentation time by now!'); const playerInterface = { getPresentationTime: () => presentationTimeInSeconds, getBandwidthEstimate: Util.spyFunc(getBandwidthEstimate), mediaSourceEngine: mediaSourceEngine, netEngine: /** @type {!shaka.net.NetworkingEngine} */(netEngine), onError: Util.spyFunc(onError), onEvent: Util.spyFunc(onEvent), onManifestUpdate: Util.spyFunc(onManifestUpdate), onSegmentAppended: Util.spyFunc(onSegmentAppended), }; streamingEngine = new shaka.media.StreamingEngine( /** @type {shaka.extern.Manifest} */(manifest), playerInterface); streamingEngine.configure(config); } afterEach(() => { streamingEngine.destroy().catch(fail); }); afterAll(() => { jasmine.clock().uninstall(); }); // This test initializes the StreamingEngine (SE) and allows it to play // through both Periods. // // After construction of StreamingEngine, the following should occur: // 1. The owner should immediately call switchVariant() with the initial // variant. // 2. The owner should call start(). // 3. SE should setup each of the initial Streams. // 4. SE should start appending the initial Streams' segments. // 5. SE should call MediaSourceEngine.endOfStream() after it has appended // both segments from the second Period. At this point, the playhead // should not be at the end of the presentation, but the test will be // effectively over since SE will have nothing else to do. it('initializes and plays VOD', async () => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; // Verify buffers. 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], }); const expectedMseInit = new Map(); expectedMseInit.set(ContentType.AUDIO, audioStream); expectedMseInit.set(ContentType.VIDEO, videoStream); expectedMseInit.set(ContentType.TEXT, textStream); expect(mediaSourceEngine.init).toHaveBeenCalledWith(expectedMseInit, false); expect(mediaSourceEngine.init).toHaveBeenCalledTimes(1); expect(mediaSourceEngine.setDuration).toHaveBeenCalledTimes(1); expect(mediaSourceEngine.setDuration).toHaveBeenCalledWith(40); await runTest(); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); // Verify buffers. 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: [true, true, true, true], }); netEngine.expectRangeRequest( '0_audio_init', initSegmentRanges[ContentType.AUDIO][0], initSegmentRanges[ContentType.AUDIO][1]); netEngine.expectRangeRequest( '0_video_init', initSegmentRanges[ContentType.VIDEO][0], initSegmentRanges[ContentType.VIDEO][1]); netEngine.expectRangeRequest( '1_audio_init', initSegmentRanges[ContentType.AUDIO][0], initSegmentRanges[ContentType.AUDIO][1]); netEngine.expectRangeRequest( '1_video_init', initSegmentRanges[ContentType.VIDEO][0], initSegmentRanges[ContentType.VIDEO][1]); const segmentType = shaka.net.NetworkingEngine.RequestType.SEGMENT; netEngine.expectRequest('0_audio_0', segmentType); netEngine.expectRequest('0_video_0', segmentType); netEngine.expectRequest('0_text_0', segmentType); netEngine.expectRequest('0_audio_1', segmentType); netEngine.expectRequest('0_video_1', segmentType); netEngine.expectRequest('0_text_1', segmentType); netEngine.expectRequest('1_audio_2', segmentType); netEngine.expectRequest('1_video_2', segmentType); netEngine.expectRequest('1_text_2', segmentType); netEngine.expectRequest('1_audio_3', segmentType); netEngine.expectRequest('1_video_3', segmentType); netEngine.expectRequest('1_text_3', segmentType); }); describe('unloadTextStream', () => { it('doesn\'t send requests for text after calling unload', async () => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; // Verify that after unloading text stream, no network request for text // is sent. await runTest(() => { const segmentType = shaka.net.NetworkingEngine.RequestType.SEGMENT; if (presentationTimeInSeconds == 1) { netEngine.expectRequest('0_text_0', segmentType); netEngine.request.calls.reset(); streamingEngine.unloadTextStream(); } else if (presentationTimeInSeconds == 35) { netEngine.expectNoRequest('0_text_0', segmentType); netEngine.expectNoRequest('0_text_1', segmentType); netEngine.expectNoRequest('1_text_2', segmentType); netEngine.expectNoRequest('1_text_3', segmentType); } }); }); it('sets the current text stream to null', async () => { createStreamingEngine(); streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); expect(streamingEngine.getCurrentTextStream()).not.toBe(null); await streamingEngine.start(); playing = true; streamingEngine.unloadTextStream(); expect(streamingEngine.getCurrentTextStream()).toBe(null); }); }); it('initializes and plays live', async () => { setupLive(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); // Here we go! presentationTimeInSeconds = 100; streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(slideSegmentAvailabilityWindow); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); // Verify buffers. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], text: [], }); // Since we started playback from segment 11, segments 10 through 14 // should be buffered. Those segment numbers are 1-based, and this array // is 0-based, so we expect i >= 9 to be downloaded. const segments = mediaSourceEngine.segments; for (const i of shaka.util.Iterables.range(14)) { expect(segments[ContentType.AUDIO][i]).withContext(i).toBe(i >= 9); expect(segments[ContentType.VIDEO][i]).withContext(i).toBe(i >= 9); expect(segments[ContentType.TEXT][i]).withContext(i).toBe(i >= 9); } }); it('plays when a small gap is present at the beginning', async () => { const drift = 0.050; // 50 ms setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData, drift); createStreamingEngine(); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); }); it('plays with no chosen text streams', async () => { setupVod(); manifest.textStreams = []; mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); // Here we go! streamingEngine.switchVariant(variant); // Don't call switchTextStream. await streamingEngine.start(); playing = true; await runTest(); expect(mediaSourceEngine.segments).toEqual({ audio: [true, true, true, true], video: [true, true, true, true], text: [false, false, false, false], }); }); it('updates the timeline duration to match media duration', async () => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); mediaSourceEngine.endOfStream.and.callFake(() => { expect(mediaSourceEngine.setDuration).toHaveBeenCalledWith(40); expect(mediaSourceEngine.setDuration).toHaveBeenCalledTimes(1); mediaSourceEngine.setDuration.calls.reset(); // Simulate the media ending BEFORE the expected (manifest) duration. mediaSourceEngine.getDuration.and.returnValue(35); return Promise.resolve(); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); expect(timeline.setDuration).toHaveBeenCalledWith(35); }); // https://github.com/google/shaka-player/issues/979 it('does not expand the timeline duration', async () => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); mediaSourceEngine.endOfStream.and.callFake(() => { expect(mediaSourceEngine.setDuration).toHaveBeenCalledWith(40); expect(mediaSourceEngine.setDuration).toHaveBeenCalledTimes(1); mediaSourceEngine.setDuration.calls.reset(); // Simulate the media ending AFTER the expected (manifest) duration. mediaSourceEngine.getDuration.and.returnValue(41); return Promise.resolve(); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); expect(timeline.setDuration).not.toHaveBeenCalled(); }); // https://github.com/google/shaka-player/issues/1967 it('does not change duration when 0', async () => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); // The duration can spuriously be set to 0, so we should ignore this and not // update the duration. mediaSourceEngine.getDuration.and.returnValue(0); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); expect(timeline.setDuration).not.toHaveBeenCalled(); }); it('applies fudge factors for append window', async () => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); // The second Period starts at 20, so we should set the appendWindowStart to // 20, but reduced by a small fudge factor. const lt20 = { asymmetricMatch: (val) => val >= 19.9 && val < 20, }; const gt40 = { asymmetricMatch: (val) => val > 40 && val <= 40.1, }; expect(mediaSourceEngine.setStreamProperties) .toHaveBeenCalledWith('video', 0, lt20, gt40); }); it('does not buffer one media type ahead of another', async () => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); const config = shaka.util.PlayerConfiguration.createDefault().streaming; config.bufferingGoal = 60; config.failureCallback = () => streamingEngine.retry(); createStreamingEngine(config); // Make requests for different types take different amounts of time. // This would let some media types buffer faster than others if unchecked. netEngineDelays.text = 0.1; netEngineDelays.audio = 1.0; netEngineDelays.video = 10.0; mediaSourceEngine.appendBuffer.and.callFake((type, data, start, end) => { // Call to the underlying implementation. const p = mediaSourceEngine.appendBufferImpl(type, data, start, end); // Validate that no one media type got ahead of any other. let minBuffered = Infinity; let maxBuffered = 0; for (const t of ['audio', 'video', 'text']) { const buffered = mediaSourceEngine.bufferedAheadOfImpl(t, 0); minBuffered = Math.min(minBuffered, buffered); maxBuffered = Math.max(maxBuffered, buffered); } // Simulated playback doesn't start until some of each is buffered. This // realism is important to the test passing. if (minBuffered > 0) { playing = true; } // Sanity check. expect(maxBuffered).not.toBeLessThan(minBuffered); // Proof that we didn't get too far ahead (10s == 1 segment). expect(maxBuffered - minBuffered).not.toBeGreaterThan(10); return p; }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); // Simulated playback is started in the appendBuffer fake when some of each // type is buffered. This realism is important to the test passing. playing = false; await runTest(); // Make sure appendBuffer was called, so that we know that we executed the // checks in our fake above. expect(mediaSourceEngine.appendBuffer).toHaveBeenCalled(); }); describe('switchVariant/switchTextStream', () => { let initialVariant; let sameAudioVariant; let sameVideoVariant; let initialTextStream; let newTextStream; beforeEach(() => { // Set up a manifest with multiple variants and a text stream. manifest = shaka.test.ManifestGenerator.generate((manifest) => { manifest.addVariant(0, (variant) => { variant.addAudio(10, (stream) => { stream.useSegmentTemplate('audio-10-%d.mp4', 10); }); variant.addVideo(11, (stream) => { stream.useSegmentTemplate('video-11-%d.mp4', 10); }); }); manifest.addVariant(1, (variant) => { variant.addExistingStream(10); // audio variant.addVideo(12, (stream) => { stream.useSegmentTemplate('video-12-%d.mp4', 10); }); }); manifest.addVariant(2, (variant) => { variant.addAudio(13, (stream) => { stream.useSegmentTemplate('audio-13-%d.mp4', 10); }); variant.addExistingStream(12); // video }); manifest.addTextStream(20, (stream) => { stream.useSegmentTemplate('text-20-%d.mp4', 10); }); manifest.addTextStream(21, (stream) => { stream.useSegmentTemplate('text-21-%d.mp4', 10); }); }); initialVariant = manifest.variants[0]; sameAudioVariant = manifest.variants[1]; sameVideoVariant = manifest.variants[2]; initialTextStream = manifest.textStreams[0]; newTextStream = manifest.textStreams[1]; // For these tests, we don't care about specific data appended. // Just return any old ArrayBuffer for any requested segment. netEngine.request.and.callFake((requestType, request) => { const buffer = new ArrayBuffer(0); /** @type {shaka.extern.Response} */ const response = {uri: request.uris[0], data: buffer, headers: {}}; return shaka.util.AbortableOperation.completed(response); }); // For these tests, we also don't need FakeMediaSourceEngine to verify // its input data. mediaSourceEngine = new shaka.test.FakeMediaSourceEngine({}); mediaSourceEngine.clear.and.returnValue(Promise.resolve()); mediaSourceEngine.bufferedAheadOf.and.returnValue(0); mediaSourceEngine.bufferStart.and.returnValue(0); mediaSourceEngine.setStreamProperties.and.returnValue(Promise.resolve()); mediaSourceEngine.remove.and.returnValue(Promise.resolve()); const bufferEnd = {audio: 0, video: 0, text: 0}; mediaSourceEngine.appendBuffer.and.callFake( (type, data, start, end) => { bufferEnd[type] = end; return Promise.resolve(); }); mediaSourceEngine.bufferEnd.and.callFake((type) => { return bufferEnd[type]; }); mediaSourceEngine.bufferedAheadOf.and.callFake((type, start) => { return Math.max(0, bufferEnd[type] - start); }); mediaSourceEngine.isBuffered.and.callFake((type, time) => { return time >= 0 && time < bufferEnd[type]; }); playing = false; presentationTimeInSeconds = 0; createStreamingEngine(); streamingEngine.switchVariant(initialVariant); streamingEngine.switchTextStream(initialTextStream); }); it('will not clear buffers if streams have not changed', async () => { streamingEngine.start().catch(fail); playing = true; await Util.fakeEventLoop(1); mediaSourceEngine.clear.calls.reset(); streamingEngine.switchVariant(sameAudioVariant, /* clearBuffer= */ true); await Util.fakeEventLoop(1); expect(mediaSourceEngine.clear).not.toHaveBeenCalledWith('audio'); expect(mediaSourceEngine.clear).toHaveBeenCalledWith('video'); expect(mediaSourceEngine.clear).not.toHaveBeenCalledWith('text'); mediaSourceEngine.clear.calls.reset(); streamingEngine.switchVariant(sameVideoVariant, /* clearBuffer= */ true); await Util.fakeEventLoop(1); expect(mediaSourceEngine.clear).toHaveBeenCalledWith('audio'); expect(mediaSourceEngine.clear).not.toHaveBeenCalledWith('video'); expect(mediaSourceEngine.clear).not.toHaveBeenCalledWith('text'); mediaSourceEngine.clear.calls.reset(); streamingEngine.switchTextStream(initialTextStream); await Util.fakeEventLoop(1); expect(mediaSourceEngine.clear).not.toHaveBeenCalled(); }); it('will not reset caption parser when text streams change', async () => { await streamingEngine.start(); playing = true; mediaSourceEngine.clear.calls.reset(); streamingEngine.switchTextStream(newTextStream); await Util.fakeEventLoop(1); expect(mediaSourceEngine.clear).toHaveBeenCalled(); expect(mediaSourceEngine.resetCaptionParser).not.toHaveBeenCalled(); }); }); describe('handles seeks (VOD)', () => { /** @type {!jasmine.Spy} */ let onTick; beforeEach(() => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); onTick = jasmine.createSpy('onTick'); onTick.and.stub(); }); it('into buffered regions', async () => { // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; let seekComplete = false; await runTest(() => { if (presentationTimeInSeconds == 6 && !seekComplete) { // Seek backwards to a buffered region in the first Period. presentationTimeInSeconds -= 5; streamingEngine.seeked(); seekComplete = true; } }); // Verify buffers. 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: [true, true, true, true], }); }); it('into partially buffered regions in the same period', async () => { // When seeking into a region within the same period, or changing // resolution, and after the seek some states are buffered and some // are unbuffered, StreamingEngine should only clear the unbuffered // states. mediaSourceEngine.endOfStream.and.callFake(() => { // Should have the first Period entirely buffered. 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: [true, true, true, true], }); // Fake the audio buffer being removed. mediaSourceEngine.segments[ContentType.AUDIO] = [true, true, false, false]; // Seek back into the second Period. presentationTimeInSeconds -= 5; expect(presentationTimeInSeconds).toBeGreaterThan(19); streamingEngine.seeked(); mediaSourceEngine.endOfStream.and.returnValue(Promise.resolve()); return Promise.resolve(); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); // When seeking within the same period, clear the buffer of the // unbuffered streams. expect(mediaSourceEngine.clear).toHaveBeenCalledWith('audio'); expect(mediaSourceEngine.clear).not.toHaveBeenCalledWith('video'); expect(mediaSourceEngine.clear).not.toHaveBeenCalledWith('text'); // Verify buffers. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [false, false, true, true], video: [true, true, true, true], text: [true, true, true, true], }); }); it('into buffered regions across Periods', async () => { mediaSourceEngine.endOfStream.and.callFake(() => { // Seek backwards to a buffered region in the first Period. presentationTimeInSeconds -= 20; expect(presentationTimeInSeconds).toBeLessThan(20); streamingEngine.seeked(); // Verify that buffers are not cleared. expect(mediaSourceEngine.clear).not.toHaveBeenCalled(); return Promise.resolve(); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); // Verify buffers. 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: [true, true, true, true], }); }); it('into unbuffered regions', async () => { onTick.and.callFake(() => { if (presentationTimeInSeconds == 6) { // Note that since the buffering goal is 5 seconds and each segment is // 10 seconds long, the second segment of this Period will be required // at 6 seconds. // Verify that all buffers have been cleared. expect(mediaSourceEngine.clear) .toHaveBeenCalledWith(ContentType.AUDIO); expect(mediaSourceEngine.clear) .toHaveBeenCalledWith(ContentType.VIDEO); expect(mediaSourceEngine.clear) .toHaveBeenCalledWith(ContentType.TEXT); // Verify buffers. The first segment is present because we start // off-by-one after a seek. expect(mediaSourceEngine.initSegments).toEqual({ audio: [true, false], video: [true, false], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [false, true, false, false], video: [false, true, false, false], text: [false, true, false, false], }); } }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; // Seek forward to an unbuffered region in the first Period. expect(presentationTimeInSeconds).toBe(0); presentationTimeInSeconds += 15; streamingEngine.seeked(); await runTest(Util.spyFunc(onTick)); // Verify buffers. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [false, true, true, true], video: [false, true, true, true], text: [false, true, true, true], }); }); it('into unbuffered regions across Periods', async () => { // Start from the second Period. presentationTimeInSeconds = 25; mediaSourceEngine.endOfStream.and.callFake(() => { // Verify buffers. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [false, false, true, true], video: [false, false, true, true], text: [false, false, true, true], }); // Seek backwards to an unbuffered region in the first Period. presentationTimeInSeconds -= 20; expect(presentationTimeInSeconds).toBeLessThan(20); streamingEngine.seeked(); expect(mediaSourceEngine.resetCaptionParser).toHaveBeenCalled(); onTick.and.callFake(() => { // Verify that all buffers have been cleared. expect(mediaSourceEngine.clear) .toHaveBeenCalledWith(ContentType.AUDIO); expect(mediaSourceEngine.clear) .toHaveBeenCalledWith(ContentType.VIDEO); expect(mediaSourceEngine.clear) .toHaveBeenCalledWith(ContentType.TEXT); onTick.and.stub(); }); mediaSourceEngine.endOfStream.and.returnValue(Promise.resolve()); return Promise.resolve(); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(Util.spyFunc(onTick)); // Verify buffers. 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: [true, true, true, true], }); }); it('into unbuffered regions when nothing is buffered', async () => { // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; // Nothing is buffered yet. expect(mediaSourceEngine.segments).toEqual({ audio: [false, false, false, false], video: [false, false, false, false], text: [false, false, false, false], }); // Seek forward to an unbuffered region in the first Period. presentationTimeInSeconds = 15; streamingEngine.seeked(); onTick.and.callFake(() => { // Nothing should have been cleared. expect(mediaSourceEngine.clear).not.toHaveBeenCalled(); onTick.and.stub(); }); await runTest(Util.spyFunc(onTick)); // Verify buffers. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [false, true, true, true], video: [false, true, true, true], text: [false, true, true, true], }); }); it('into unbuffered regions near segment start', async () => { // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; // Seek forward to an unbuffered region in the first Period. presentationTimeInSeconds = 11; streamingEngine.seeked(); onTick.and.callFake(() => { // Nothing should have been cleared. expect(mediaSourceEngine.clear).not.toHaveBeenCalled(); onTick.and.stub(); }); await runTest(Util.spyFunc(onTick)); // Verify buffers. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], text: [], }); // Should buffer previous segment despite being inside segment 2. expect(mediaSourceEngine.segments).toEqual({ audio: [true, true, true, true], video: [true, true, true, true], text: [true, true, true, true], }); }); // If we seek back into an unbuffered region but do not called seeked(), // StreamingEngine should wait for seeked() to be called. it('back into unbuffered regions without seeked()', async () => { // Start from the second segment in the second Period. presentationTimeInSeconds = 30; mediaSourceEngine.endOfStream.and.callFake(() => { // Seek backwards to an unbuffered region in the second Period. Do not // call seeked(). presentationTimeInSeconds = 20; return Promise.resolve(); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); // Verify buffers. Segment 3 should not be buffered since we never // called seeked(). expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [false, false, true, true], video: [false, false, true, true], text: [false, false, true, true], }); }); // If we seek forward into an unbuffered region but do not called seeked(), // StreamingEngine should continue buffering. This test also exercises the // case where the playhead moves past the end of the buffer, which may // occur on some browsers depending on the playback rate. it('forward into unbuffered regions without seeked()', async () => { // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; let seekStarted = false; await runTest(() => { if (!seekStarted) { // Seek forward to an unbuffered region in the first Period. Do not // call seeked(). presentationTimeInSeconds += 15; seekStarted = true; } }); // Verify buffers. 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: [true, true, true, true], }); }); it('into partially buffered regions across periods', async () => { // Seeking into a region where some buffers (text) are buffered and some // are not should work. mediaSourceEngine.endOfStream.and.callFake(() => { // Should have the first Period entirely buffered. 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: [true, true, true, true], }); // Fake the audio/video buffers being removed. // Now only text is buffered from the first period. mediaSourceEngine.segments[ContentType.AUDIO] = [false, false, true, true]; mediaSourceEngine.segments[ContentType.VIDEO] = [false, false, true, true]; // Seek back into the first Period. presentationTimeInSeconds -= 20; expect(presentationTimeInSeconds).toBeLessThan(29); streamingEngine.seeked(); // Only the unbuffered streams were cleared. expect(mediaSourceEngine.clear).toHaveBeenCalledWith('audio'); expect(mediaSourceEngine.clear).toHaveBeenCalledWith('video'); expect(mediaSourceEngine.clear).not.toHaveBeenCalledWith('text'); mediaSourceEngine.endOfStream.and.returnValue(Promise.resolve()); return Promise.resolve(); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); // Verify buffers. 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: [true, true, true, true], }); }); }); describe('handles seeks (live)', () => { beforeEach(() => { setupLive(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData, 0); createStreamingEngine(); presentationTimeInSeconds = 100; }); it('outside segment availability window', async () => { segmentAvailability.start = 90; segmentAvailability.end = 110; presentationTimeInSeconds = 90; // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; // Seek forward to an unbuffered and unavailable region in the second // Period; set playing to false since the playhead can't move at the // seek target. expect(Util.invokeSpy(timeline.getSegmentAvailabilityEnd)) .toBeLessThan(125); presentationTimeInSeconds = 125; playing = false; streamingEngine.seeked(); // Eventually StreamingEngine should request the first segment (since // it needs the second segment) of the second Period when it becomes // available. const originalAppendBuffer = // eslint-disable-next-line no-restricted-syntax shaka.test.FakeMediaSourceEngine.prototype.appendBufferImpl; mediaSourceEngine.appendBuffer.and.callFake( (type, data, startTime, endTime) => { expect(presentationTimeInSeconds).toBe(125); if (startTime >= 100) { // Ignore a possible call for the first Period. expect(Util.invokeSpy(timeline.getSegmentAvailabilityStart)) .toBe(100); expect(Util.invokeSpy(timeline.getSegmentAvailabilityEnd)) .toBe(120); playing = true; mediaSourceEngine.appendBuffer.and.callFake( originalAppendBuffer); } // eslint-disable-next-line no-restricted-syntax return originalAppendBuffer.call( mediaSourceEngine, type, data, startTime, endTime); }); await runTest(slideSegmentAvailabilityWindow); // Verify buffers. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], text: [], }); // Since we performed an unbuffered seek into the second Period, the // first 12 segments should not be buffered. for (const i of shaka.util.Iterables.range(14)) { expect(mediaSourceEngine.segments[ContentType.AUDIO][i]).toBe(i >= 12); expect(mediaSourceEngine.segments[ContentType.VIDEO][i]).toBe(i >= 12); expect(mediaSourceEngine.segments[ContentType.TEXT][i]).toBe(i >= 12); } }); }); describe('handles errors', () => { beforeEach(() => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); }); it('from Stream setup', async () => { const createSegmentIndexSpy = Util.funcSpy( videoStream.createSegmentIndex); // Don't use returnValue with Promise.reject, or it may be detected as an // unhandled Promise rejection. createSegmentIndexSpy.and.callFake(() => Promise.reject('FAKE_ERROR')); onError.and.callFake((error) => { expect(error).toBe('FAKE_ERROR'); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(videoStream.createSegmentIndex).toHaveBeenCalled(); expect(onError).toHaveBeenCalled(); }); it('from Stream setup on switch', async () => { const expectedError = new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.NETWORK, shaka.util.Error.Code.HTTP_ERROR); const createSegmentIndexSpy = Util.funcSpy( alternateVideoStream.createSegmentIndex); // Don't use returnValue with Promise.reject, or it may be detected as an // unhandled Promise rejection. createSegmentIndexSpy.and.callFake(() => Promise.reject(expectedError)); onError.and.callFake((error) => { expect(error).toBe(expectedError); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; streamingEngine.switchVariant( alternateVariant, /* clear_buffer= */ true, /* safe_margin= */ 0); await runTest(); expect(alternateVideoStream.createSegmentIndex).toHaveBeenCalled(); expect(onError).toHaveBeenCalled(); }); it('from failed init segment append during startup', async () => { const expectedError = new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_FAILED); onError.and.callFake((error) => { Util.expectToEqualError(error, expectedError); }); const originalAppendBuffer = // eslint-disable-next-line no-restricted-syntax shaka.test.FakeMediaSourceEngine.prototype.appendBufferImpl; mediaSourceEngine.appendBuffer.and.callFake( (type, data, startTime, endTime) => { // Reject the first video init segment. if (data == segmentData[ContentType.VIDEO].initSegments[0]) { return Promise.reject(expectedError); } else { // eslint-disable-next-line no-restricted-syntax return originalAppendBuffer.call( mediaSourceEngine, type, data, startTime, endTime); } }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onError).toHaveBeenCalled(); }); it('from failed media segment append during startup', async () => { const expectedError = new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_FAILED); onError.and.callFake((error) => { Util.expectToEqualError(error, expectedError); }); const originalAppendBuffer = // eslint-disable-next-line no-restricted-syntax shaka.test.FakeMediaSourceEngine.prototype.appendBufferImpl; mediaSourceEngine.appendBuffer.and.callFake( (type, data, startTime, endTime) => { // Reject the first audio segment. if (data == segmentData[ContentType.AUDIO].segments[0]) { return Promise.reject(expectedError); } else { // eslint-disable-next-line no-restricted-syntax return originalAppendBuffer.call( mediaSourceEngine, type, data, startTime, endTime); } }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onError).toHaveBeenCalled(); }); it('from failed clear in switchVariant', async () => { const expectedError = new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_FAILED); mediaSourceEngine.clear.and.returnValue(Promise.reject(expectedError)); onError.and.stub(); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; streamingEngine.switchVariant( alternateVariant, /* clear_buffer= */ true, /* safe_margin= */ 0); await runTest(); expect(onError).toHaveBeenCalledWith(Util.jasmineError(expectedError)); }); }); describe('handles network errors', () => { it('ignores text stream failures if configured to', async () => { setupVod(); const textUri = '0_text_0'; // eslint-disable-next-line no-restricted-syntax const originalNetEngineRequest = netEngine.request.bind(netEngine); netEngine.request = jasmine.createSpy('request').and.callFake( (requestType, request) => { if (request.uris[0] == textUri) { return shaka.util.AbortableOperation.failed(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.NETWORK, shaka.util.Error.Code.BAD_HTTP_STATUS, textUri, 404)); } return originalNetEngineRequest(requestType, request); }); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); const config = shaka.util.PlayerConfiguration.createDefault().streaming; config.ignoreTextStreamFailures = true; createStreamingEngine(config); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onError).not.toHaveBeenCalled(); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); }); it('retries if configured to', async () => { setupLive(); // Wrap the NetworkingEngine to cause errors. const targetUri = '0_audio_init'; failFirstRequestForTarget(netEngine, targetUri, shaka.util.Error.Code.BAD_HTTP_STATUS); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); const config = shaka.util.PlayerConfiguration.createDefault().streaming; config.failureCallback = () => streamingEngine.retry(); createStreamingEngine(config); presentationTimeInSeconds = 100; onError.and.callFake((error) => { expect(error.severity).toBe(shaka.util.Error.Severity.CRITICAL); expect(error.category).toBe(shaka.util.Error.Category.NETWORK); expect(error.code).toBe(shaka.util.Error.Code.BAD_HTTP_STATUS); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onError).toHaveBeenCalledTimes(1); expect(mediaSourceEngine.endOfStream).toHaveBeenCalledTimes(1); const targetCalls = netEngine.request.calls.all().filter((data) => { const request = data.args[1]; return request.uris[0] == targetUri; }); expect(targetCalls.length).toBeGreaterThan(1); }); it('does not retry if configured not to', async () => { setupLive(); // Wrap the NetworkingEngine to cause errors. const targetUri = '0_audio_init'; failFirstRequestForTarget(netEngine, targetUri, shaka.util.Error.Code.BAD_HTTP_STATUS); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); const config = shaka.util.PlayerConfiguration.createDefault().streaming; config.failureCallback = () => {}; createStreamingEngine(config); presentationTimeInSeconds = 100; onError.and.callFake((error) => { expect(error.severity).toBe(shaka.util.Error.Severity.CRITICAL); expect(error.category).toBe(shaka.util.Error.Category.NETWORK); expect(error.code).toBe(shaka.util.Error.Code.BAD_HTTP_STATUS); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onError).toHaveBeenCalledTimes(1); expect(mediaSourceEngine.endOfStream).not.toHaveBeenCalled(); const targetCalls = netEngine.request.calls.all().filter((data) => { const request = data.args[1]; return request.uris[0] == targetUri; }); expect(targetCalls.length).toBe(1); }); it('does not invoke the callback if the error is handled', async () => { setupLive(); // Wrap the NetworkingEngine to cause errors. const targetUri = '0_audio_init'; failFirstRequestForTarget(netEngine, targetUri, shaka.util.Error.Code.BAD_HTTP_STATUS); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); // Configure with a failure callback const failureCallback = jasmine.createSpy('failureCallback'); const config = shaka.util.PlayerConfiguration.createDefault().streaming; config.failureCallback = shaka.test.Util.spyFunc(failureCallback); createStreamingEngine(config); presentationTimeInSeconds = 100; onError.and.callFake((error) => { error.handled = true; }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onError).toHaveBeenCalledTimes(1); expect(failureCallback).not.toHaveBeenCalled(); }); it('waits to invoke the failure callback', async () => { setupLive(); // Wrap the NetworkingEngine to cause errors. const targetUri = '0_audio_init'; failFirstRequestForTarget(netEngine, targetUri, shaka.util.Error.Code.BAD_HTTP_STATUS); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); // Configure with a failure callback that records the callback time. /** @type {number} */ let callbackTime = 0; const failureCallback = jasmine.createSpy('failureCallback'); failureCallback.and.callFake(() => { callbackTime = Date.now(); }); const config = shaka.util.PlayerConfiguration.createDefault().streaming; config.failureCallback = shaka.test.Util.spyFunc(failureCallback); config.retryParameters.maxAttempts = 2; config.retryParameters.baseDelay = 10000; config.retryParameters.fuzzFactor = 0; createStreamingEngine(config); presentationTimeInSeconds = 100; onError.and.stub(); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; const startTime = Date.now(); await runTest(); expect(failureCallback).toHaveBeenCalled(); // baseDelay == 10000, maybe be longer due to delays in the event loop. expect(callbackTime - startTime).toBeGreaterThanOrEqual(10000); }); }); describe('retry()', () => { it('resumes streaming after failure', async () => { setupVod(); // Wrap the NetworkingEngine to cause errors. const targetUri = '0_audio_init'; const originalNetEngineRequest = netEngine.request; failFirstRequestForTarget(netEngine, targetUri, shaka.util.Error.Code.BAD_HTTP_STATUS); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); onError.and.callFake((error) => { // Restore the original fake request function. netEngine.request = originalNetEngineRequest; netEngine.request.calls.reset(); // Retry streaming. expect(streamingEngine.retry()).toBe(true); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); // We definitely called onError(). expect(onError).toHaveBeenCalledTimes(1); // We reset the request calls in onError() just before retry(), so this // count reflects new calls since retry(). expect(netEngine.request).toHaveBeenCalled(); // The retry worked, so we should have reached the end of the stream. expect(mediaSourceEngine.endOfStream).toHaveBeenCalledTimes(1); }); it('does not resume streaming after quota error', async () => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); onError.and.callFake((error) => { expect(error.code).toBe(shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR); // Retry streaming, which should fail and return false. netEngine.request.calls.reset(); expect(streamingEngine.retry()).toBe(false); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(() => { if (presentationTimeInSeconds == 2) { // Now that we're streaming, throw QuotaExceededError on every segment // to quickly trigger the quota error. const appendBufferSpy = jasmine.createSpy('appendBuffer'); appendBufferSpy.and.callFake((type, data, startTime, endTime) => { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR, type); }); mediaSourceEngine.appendBuffer = appendBufferSpy; } }); // We definitely called onError(). expect(onError).toHaveBeenCalledTimes(1); // We reset the request calls in onError() just before retry(), so this // count reflects new calls since retry(). expect(netEngine.request).not.toHaveBeenCalled(); expect(mediaSourceEngine.endOfStream).not.toHaveBeenCalled(); }); it('does not resume streaming after destruction', async () => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; // Here we go! await runTest(() => { if (presentationTimeInSeconds == 3) { streamingEngine.destroy(); // Retry streaming, which should fail and return false. netEngine.request.calls.reset(); expect(streamingEngine.retry()).toBe(false); } }); // We reset the request calls in onError() just before retry(), so this // count reflects new calls since retry(). expect(netEngine.request).not.toHaveBeenCalled(); expect(mediaSourceEngine.endOfStream).not.toHaveBeenCalled(); }); }); describe('eviction', () => { let config; beforeEach(() => { setupVod(); manifest.minBufferTime = 1; config = shaka.util.PlayerConfiguration.createDefault().streaming; config.rebufferingGoal = 1; config.bufferingGoal = 1; config.bufferBehind = 10; }); it('evicts media to meet the max buffer tail limit', async () => { // Create StreamingEngine. mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(config); const originalRemove = // eslint-disable-next-line no-restricted-syntax shaka.test.FakeMediaSourceEngine.prototype.removeImpl .bind(mediaSourceEngine); mediaSourceEngine.remove.and.callFake((type, start, end) => { expect(presentationTimeInSeconds).toBe(20); expect(start).toBe(0); expect(end).toBe(10); if (mediaSourceEngine.remove.calls.count() == 3) { mediaSourceEngine.remove.and.callFake((type, start, end) => { expect(presentationTimeInSeconds).toBe(30); expect(start).toBe(10); expect(end).toBe(20); return originalRemove(type, start, end); }); } return originalRemove(type, start, end); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; // Since StreamingEngine is free to peform audio, video, and text updates // in any order, there are many valid ways in which StreamingEngine can // evict segments. So, instead of verifying the exact, final buffer // configuration, ensure the byte limit is never exceeded and at least // one segment of each type is buffered at the end of the test. await runTest(); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); expect(mediaSourceEngine.remove) .toHaveBeenCalledWith(ContentType.AUDIO, 0, 10); expect(mediaSourceEngine.remove) .toHaveBeenCalledWith(ContentType.AUDIO, 10, 20); expect(mediaSourceEngine.remove) .toHaveBeenCalledWith(ContentType.VIDEO, 0, 10); expect(mediaSourceEngine.remove) .toHaveBeenCalledWith(ContentType.VIDEO, 10, 20); expect(mediaSourceEngine.remove) .toHaveBeenCalledWith(ContentType.TEXT, 0, 10); expect(mediaSourceEngine.remove) .toHaveBeenCalledWith(ContentType.TEXT, 10, 20); // Verify buffers. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [false, false, true, true], video: [false, false, true, true], text: [false, false, true, true], }); }); it('doesn\'t evict too much when bufferBehind is very low', async () => { // Set the bufferBehind to a value significantly below the segment size. config.bufferBehind = 0.1; // Create StreamingEngine. mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(config); streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(() => { if (presentationTimeInSeconds == 8) { // Run the test until a bit before the end of the first segment. playing = false; } else if (presentationTimeInSeconds == 6) { // Soon before stopping the test, set the buffering goal up way // higher to trigger more segment fetching, to (potentially) trigger // an eviction. config.bufferingGoal = 5; streamingEngine.configure(config); } }); // It should not have removed any segments. expect(mediaSourceEngine.remove).not.toHaveBeenCalled(); }); }); describe('QuotaExceededError', () => { it('does not fail immediately', async () => { setupVod(); manifest.minBufferTime = 1; // Create StreamingEngine. const config = shaka.util.PlayerConfiguration.createDefault().streaming; config.rebufferingGoal = 1; config.bufferingGoal = 1; config.bufferBehind = 10; mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(config); const originalAppendBuffer = // eslint-disable-next-line no-restricted-syntax shaka.test.FakeMediaSourceEngine.prototype.appendBufferImpl; const appendBufferSpy = jasmine.createSpy('appendBuffer'); mediaSourceEngine.appendBuffer = appendBufferSpy; // Throw two QuotaExceededErrors at different times. let numErrorsThrown = 0; appendBufferSpy.and.callFake( (type, data, startTime, endTime) => { const throwError = (numErrorsThrown == 0 && startTime == 10) || (numErrorsThrown == 1 && startTime == 20); if (throwError) { numErrorsThrown++; throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR, type); } else { // eslint-disable-next-line no-restricted-syntax const p = originalAppendBuffer.call( mediaSourceEngine, type, data, startTime, endTime); return p; } }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); // Verify buffers. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [false, false, true, true], video: [false, false, true, true], text: [false, false, true, true], }); }); it('fails after multiple QuotaExceededError', async () => { setupVod(); manifest.minBufferTime = 1; // Create StreamingEngine. const config = shaka.util.PlayerConfiguration.createDefault().streaming; config.rebufferingGoal = 1; config.bufferingGoal = 1; mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(config); const originalAppendBuffer = // eslint-disable-next-line no-restricted-syntax shaka.test.FakeMediaSourceEngine.prototype.appendBufferImpl; const appendBufferSpy = jasmine.createSpy('appendBuffer'); mediaSourceEngine.appendBuffer = appendBufferSpy; // Throw QuotaExceededError multiple times after at least one segment of // each type has been appended. appendBufferSpy.and.callFake( (type, data, startTime, endTime) => { if (startTime >= 10) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR, type); } else { // eslint-disable-next-line no-restricted-syntax const p = originalAppendBuffer.call( mediaSourceEngine, type, data, startTime, endTime); return p; } }); onError.and.callFake((error) => { expect(error.code).toBe(shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR); expect(error.data[0] == ContentType.AUDIO || error.data[0] == ContentType.VIDEO || error.data[0] == ContentType.TEXT).toBe(true); }); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; // Stop the playhead after 10 seconds since will not append any // segments after this time. const stopPlayhead = () => { playing = presentationTimeInSeconds < 10; }; await runTest(stopPlayhead); expect(onError).toHaveBeenCalled(); expect(mediaSourceEngine.endOfStream).not.toHaveBeenCalled(); }); }); describe('VOD drift', () => { beforeEach(() => { setupVod(); }); /** * @param {number} drift */ async function testPositiveDrift(drift) { mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData, drift); createStreamingEngine(); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); // Verify buffers. 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: [true, true, true, true], }); } /** * @param {number} drift */ async function testNegativeDrift(drift) { mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData, drift); createStreamingEngine(); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); // Verify buffers. 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: [true, true, true, true], }); } it('is handled for small + values', () => testPositiveDrift(3)); it('is handled for large + values', () => testPositiveDrift(12)); it('is handled for small - values', () => testNegativeDrift(-3)); }); describe('live drift', () => { beforeEach(() => { setupLive(); }); /** * @param {number} drift */ async function testNegativeDrift(drift) { mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData, drift); createStreamingEngine(); presentationTimeInSeconds = 100; // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(slideSegmentAvailabilityWindow); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); // Verify buffers. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], text: [], }); // Since we started playback from segment 11, segments 10 through 14 // should be buffered. Those segment numbers are 1-based, and this array // is 0-based, so we expect i >= 9 to be downloaded. const segments = mediaSourceEngine.segments; for (const i of shaka.util.Iterables.range(14)) { expect(segments[ContentType.AUDIO][i]).withContext(i).toBe(i >= 9); expect(segments[ContentType.VIDEO][i]).withContext(i).toBe(i >= 9); expect(segments[ContentType.TEXT][i]).withContext(i).toBe(i >= 9); } } it('is handled for large - values', () => testNegativeDrift(-12)); }); describe('setTrickPlay', () => { it('uses trick mode track when requested', async () => { setupVod(/* trickMode= */ true); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); const config = shaka.util.PlayerConfiguration.createDefault().streaming; // Only buffer ahead 1 second to make it easier to set segment // expectations based on playheadTime. config.rebufferingGoal = 1; config.bufferingGoal = 1; createStreamingEngine(config); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(() => { if (presentationTimeInSeconds == 1) { expect(mediaSourceEngine.initSegments).toEqual({ audio: [true, false], video: [true, false], trickvideo: [false, false], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [true, false, false, false], video: [true, false, false, false], trickvideo: [false, false, false, false], text: [true, false, false, false], }); // Engage trick play. streamingEngine.setTrickPlay(true); } else if (presentationTimeInSeconds == 11) { // We're in the second segment, in trick play mode. expect(mediaSourceEngine.initSegments).toEqual({ audio: [true, false], video: [true, false], trickvideo: [true, false], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [true, true, false, false], video: [true, false, false, false], trickvideo: [false, true, false, false], text: [true, true, false, false], }); } else if (presentationTimeInSeconds == 21) { // We've started the second period, still in trick play mode. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [true, false], // no init segment fetched for normal video trickvideo: [false, true], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [true, true, true, false], video: [true, false, false, false], trickvideo: [false, true, true, false], text: [true, true, true, false], }); } else if (presentationTimeInSeconds == 31) { // We've started the final segment, still in trick play mode. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [true, false], // no init segment appended for normal video trickvideo: [false, true], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [true, true, true, true], video: [true, false, false, false], trickvideo: [false, true, true, true], text: [true, true, true, true], }); // Disengage trick play mode, which will clear the video buffer. streamingEngine.setTrickPlay(false); } else if (presentationTimeInSeconds == 39) { // We're 1 second from the end of the stream now. expect(mediaSourceEngine.initSegments).toEqual({ audio: [false, true], video: [false, true], // init segment appended for normal video now trickvideo: [false, true], text: [], }); expect(mediaSourceEngine.segments).toEqual({ audio: [true, true, true, true], video: [false, false, false, true], trickvideo: [false, false, false, false], // cleared text: [true, true, true, true], }); } }); expect(mediaSourceEngine.endOfStream).toHaveBeenCalled(); }); }); describe('embedded emsg boxes', () => { const emsgSegment = Uint8ArrayUtils.fromHex( '0000003b656d736700000000666f6f3a6261723a637573746f6d646174617363' + '68656d6500310000000001000000080000ffff0000000174657374'); const emsgObj = { startTime: 8, endTime: 0xffff + 8, schemeIdUri: 'foo:bar:customdatascheme', value: '1', timescale: 1, presentationTimeDelta: 8, eventDuration: 0xffff, id: 1, messageData: new Uint8Array([0x74, 0x65, 0x73, 0x74]), }; beforeEach(() => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); }); it('raises an event for registered embedded emsg boxes', async () => { segmentData[ContentType.VIDEO].segments[0] = emsgSegment; videoStream.emsgSchemeIdUris = [emsgObj.schemeIdUri]; // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onEvent).toHaveBeenCalledTimes(1); const event = onEvent.calls.argsFor(0)[0]; expect(event.detail).toEqual(emsgObj); }); it('raises multiple events', async () => { const dummyBox = shaka.util.Uint8ArrayUtils.fromHex('0000000c6672656501020304'); segmentData[ContentType.VIDEO].segments[0] = shaka.util.Uint8ArrayUtils.concat(emsgSegment, dummyBox, emsgSegment); videoStream.emsgSchemeIdUris = [emsgObj.schemeIdUri]; // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onEvent).toHaveBeenCalledTimes(2); }); it('won\'t raise an event without stream field set', async () => { segmentData[ContentType.VIDEO].segments[0] = emsgSegment; // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onEvent).not.toHaveBeenCalled(); }); it('won\'t raise an event when no emsg boxes present', async () => { videoStream.emsgSchemeIdUris = [emsgObj.schemeIdUri]; // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onEvent).not.toHaveBeenCalled(); }); it('won\'t raise an event for an unregistered emsg box', async () => { segmentData[ContentType.VIDEO].segments[0] = emsgSegment; // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onEvent).not.toHaveBeenCalled(); }); it('triggers manifest updates', async () => { // This is an 'emsg' box that contains a scheme of // urn:mpeg:dash:event:2012 to indicate a manifest update. segmentData[ContentType.VIDEO].segments[0] = Uint8ArrayUtils .fromHex( '0000003a656d73670000000075726e3a' + '6d7065673a646173683a6576656e743a' + '32303132000000000031000000080000' + '00ff0000000c74657374'); videoStream.emsgSchemeIdUris = ['urn:mpeg:dash:event:2012']; // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(); expect(onEvent).not.toHaveBeenCalled(); expect(onManifestUpdate).toHaveBeenCalled(); }); }); describe('network downgrading', () => { /** @type {shaka.extern.Variant} */ let initialVariant; /** @type {shaka.extern.Variant} */ let newVariant; /** @type {!Array.} */ let requestUris; /** @type {!Array.} */ let delayedRequests; /** @type {shaka.net.NetworkingEngine.PendingRequest} */ let lastPendingRequest; /** @type {boolean} */ let shouldDelayRequests; beforeEach(() => { manifest = shaka.test.ManifestGenerator.generate((manifest) => { manifest.presentationTimeline.setDuration(60); manifest.addVariant(0, (variant) => { variant.bandwidth = 500; variant.addVideo(10, (stream) => { stream.useSegmentTemplate( 'video-10-%d.mp4', /* segmentDuration= */ 10, /* segmentSize= */ 50); }); }); manifest.addVariant(1, (variant) => { variant.bandwidth = 100; variant.addVideo(11, (stream) => { stream.useSegmentTemplate( 'video-11-%d.mp4', /* segmentDuration= */ 10, /* segmentSize= */ 10); }); }); }); initialVariant = manifest.variants[0]; newVariant = manifest.variants[1]; requestUris = []; delayedRequests = []; lastPendingRequest = null; shouldDelayRequests = true; playing = false; presentationTimeInSeconds = 0; // For these tests, we don't care about specific data appended. // Just return any old ArrayBuffer for any requested segment. netEngine.request.and.callFake((requestType, request) => { const buffer = new ArrayBuffer(0); const response = {uri: request.uris[0], data: buffer, headers: {}}; const bytes = new shaka.net.NetworkingEngine.NumBytesRemainingClass(); bytes.setBytes(200); const delay = new shaka.util.PublicPromise(); delayedRequests.push(delay); const run = async () => { shaka.log.v1('new request', request.uris[0]); if (shouldDelayRequests) { shaka.log.v1('delaying request', request.uris[0]); await delay; } // Only add if the segment was appended; if it was aborted this // won't be called. shaka.log.v1('completing request', request.uris[0]); requestUris.push(request.uris[0]); return response; }; const abort = () => { shaka.log.v1('aborting request', request.uris[0]); delay.reject(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.PLAYER, shaka.util.Error.Code.OPERATION_ABORTED)); return Promise.resolve(); }; lastPendingRequest = new shaka.net.NetworkingEngine.PendingRequest( run(), abort, bytes); spyOn(lastPendingRequest, 'abort').and.callThrough(); return lastPendingRequest; }); // For these tests, we also don't need FakeMediaSourceEngine to verify // its input data. mediaSourceEngine = new shaka.test.FakeMediaSourceEngine({}); mediaSourceEngine.bufferStart.and.returnValue(0); mediaSourceEngine.clear.and.returnValue(Promise.resolve()); mediaSourceEngine.remove.and.returnValue(Promise.resolve()); mediaSourceEngine.setStreamProperties.and.returnValue(Promise.resolve()); // Naive buffered range tracking that only tracks the buffer end. const bufferEnd = {audio: 0, video: 0, text: 0}; mediaSourceEngine.appendBuffer.and.callFake((type, data, start, end) => { bufferEnd[type] = end; return Promise.resolve(); }); mediaSourceEngine.bufferEnd.and.callFake((type) => bufferEnd[type]); mediaSourceEngine.bufferedAheadOf.and.callFake( (type, start) => Math.max(0, bufferEnd[type] - start)); mediaSourceEngine.isBuffered.and.callFake( (type, time) => time >= 0 && time < bufferEnd[type]); const config = shaka.util.PlayerConfiguration.createDefault().streaming; config.bufferingGoal = 30; config.retryParameters.maxAttempts = 1; createStreamingEngine(config); getBandwidthEstimate.and.returnValue(1); // very slow by default }); it('aborts pending requests', async () => { await prepareForAbort(); /** @type {shaka.net.NetworkingEngine.PendingRequest} */ const secondRequest = lastPendingRequest; // This should abort the pending request for the second segment. streamingEngine.switchVariant( newVariant, /* clear_buffer= */ false, /* safe_margin= */ 0); await bufferAndCheck(/* didAbort= */ true); expect(secondRequest.abort).toHaveBeenCalled(); }); it('still aborts if previous segment size unknown', async () => { // This should use the "bytes remaining" from the request instead of the // previous stream's size. const videoStream = manifest.variants[0].video; await videoStream.createSegmentIndex(); const segmentIndex = videoStream.segmentIndex; const oldGet = segmentIndex.get; videoStream.segmentIndex.get = (idx) => { // eslint-disable-next-line no-restricted-syntax const seg = oldGet.call(segmentIndex, idx); if (seg) { // With endByte being null, we won't know the segment size. return new shaka.media.SegmentReference( seg.startTime, seg.endTime, seg.getUris, /* startByte= */ 0, /* endByte= */ null, /* initSegmentReference= */ null, /* timestampOffset= */ 0, /* appendWindowStart= */ 0, /* appendWindowEnd= */ Infinity); } else { return seg; } }; await prepareForAbort(); streamingEngine.switchVariant( newVariant, /* clear_buffer= */ false, /* safe_margin= */ 0); await bufferAndCheck(/* didAbort= */ true); }); it('doesn\'t abort if close to finished', async () => { await prepareForAbort(); setBytesRemaining(3); streamingEngine.switchVariant( newVariant, /* clear_buffer= */ false, /* safe_margin= */ 0); await bufferAndCheck(/* didAbort= */ false); }); it('doesn\'t abort if init segment is too large', async () => { const initSegmentReference = new shaka.media.InitSegmentReference(() => ['init-11.mp4'], 0, 500); await newVariant.video.createSegmentIndex(); overrideInitSegment(newVariant.video, initSegmentReference); await prepareForAbort(); streamingEngine.switchVariant( newVariant, /* clear_buffer= */ false, /* safe_margin= */ 0); await bufferAndCheck(/* didAbort= */ false, /* hasInit= */ true); }); it('still aborts with small init segment', async () => { const initSegmentReference = new shaka.media.InitSegmentReference(() => ['init-11.mp4'], 0, 5); await newVariant.video.createSegmentIndex(); overrideInitSegment(newVariant.video, initSegmentReference); await prepareForAbort(); streamingEngine.switchVariant( newVariant, /* clear_buffer= */ false, /* safe_margin= */ 0); await bufferAndCheck(/* didAbort= */ true, /* hasInit= */ true); }); it('aborts if we can finish the new one on time', async () => { // Very large init segment const initSegmentReference = new shaka.media.InitSegmentReference(() => ['init-11.mp4'], 0, 5e6); await newVariant.video.createSegmentIndex(); overrideInitSegment(newVariant.video, initSegmentReference); await prepareForAbort(); setBytesRemaining(3); // not much left getBandwidthEstimate.and.returnValue(1e9); // insanely fast streamingEngine.switchVariant( newVariant, /* clear_buffer= */ false, /* safe_margin= */ 0); await bufferAndCheck(/* didAbort= */ true, /* hasInit= */ true); }); it('aborts pending requests after fetching new segment index', async () => { await prepareForAbort(); /** @type {shaka.net.NetworkingEngine.PendingRequest} */ const secondRequest = lastPendingRequest; // Simulate a segment index which is not yet available. This is // necessary because the fake content used in this test is in the style // of a segment template, where the index is already known. const segmentIndex = newVariant.video.segmentIndex; expect(segmentIndex).not.toBe(null); newVariant.video.segmentIndex = null; newVariant.video.createSegmentIndex = () => { newVariant.video.segmentIndex = segmentIndex; return Promise.resolve(); }; // This should abort the pending request for the second segment. streamingEngine.switchVariant(newVariant); await bufferAndCheck(/* didAbort= */ true); expect(secondRequest.abort).toHaveBeenCalled(); }); it('still aborts if new segment size unknown', async () => { const videoStream = manifest.variants[1].video; videoStream.bandwidth = 10; await videoStream.createSegmentIndex(); const segmentIndex = videoStream.segmentIndex; const oldGet = segmentIndex.get; videoStream.segmentIndex.get = (idx) => { // eslint-disable-next-line no-restricted-syntax const seg = oldGet.call(segmentIndex, idx); if (seg) { // With endByte being null, we won't know the segment size. // Segment size has to be calculated with times and bandwidth. return new shaka.media.SegmentReference( seg.startTime, seg.endTime, seg.getUris, /* startByte= */ 0, /* endByte= */ null, /* initSegmentReference= */ null, /* timestampOffset= */ 0, /* appendWindowStart= */ 0, /* appendWindowEnd= */ Infinity); } else { return seg; } }; await prepareForAbort(); streamingEngine.switchVariant( newVariant, /* clear_buffer= */ false, /* safe_margin= */ 0); await bufferAndCheck(/* didAbort= */ true); }); function flushDelayedRequests() { for (const delay of delayedRequests) { delay.resolve(); } delayedRequests = []; } /** * Creates and starts the StreamingEngine instance. After this returns, * it should be waiting for the second segment request to complete. */ async function prepareForAbort() { streamingEngine.switchVariant(initialVariant); streamingEngine.start().catch(fail); playing = true; await Util.fakeEventLoop(1); // Finish the first segment request. shaka.log.v1('continuing initial request'); flushDelayedRequests(); await Util.fakeEventLoop(10); // We should have buffered the first segment. expect(Util.invokeSpy(mediaSourceEngine.bufferEnd, 'video')).toBe(10); // Confirm that the first segment is buffered. expect(requestUris).toEqual(['video-10-0.mp4']); } /** * Buffers to the buffering goal and checks the correct segment requests * were made. * @param {boolean} didAbort * @param {boolean=} hasInit */ async function bufferAndCheck(didAbort, hasInit) { // Wait long enough for the abort() call take place. await Util.fakeEventLoop(1); shaka.log.v1('continuing delayed request, unblocking future requests'); shouldDelayRequests = false; flushDelayedRequests(); await Util.fakeEventLoop(3); expect(Util.invokeSpy(mediaSourceEngine.bufferEnd, 'video')).toBe(30); const expected = ['video-10-0.mp4']; if (!didAbort) { expected.push('video-10-1.mp4'); } if (hasInit) { expected.push('init-11.mp4'); } if (didAbort) { expected.push('video-11-1.mp4'); } expected.push('video-11-2.mp4'); expect(requestUris).toEqual(expected); } /** * Sets the number of bytes remaining on the pending network request. * * @param {number} bytes * @suppress {accessControls} */ function setBytesRemaining(bytes) { lastPendingRequest.bytesRemaining_.setBytes(bytes); } }); describe('embedded text tracks', () => { /** @type {!jasmine.Spy} */ let onTick; /** @type {shaka.extern.Stream} */ let externalTextStream; /** @type {shaka.extern.Stream} */ let embeddedTextStream; beforeEach(() => { manifest = shaka.test.ManifestGenerator.generate((manifest) => { manifest.addVariant(0, (variant) => { variant.addVideo(1, (stream) => { stream.useSegmentTemplate('video-110-%d.mp4', 10); }); }); manifest.addTextStream(2, (stream) => { stream.useSegmentTemplate('video-120-%d.mp4', 10); }); manifest.addTextStream(3, (stream) => { stream.mimeType = shaka.util.MimeUtils.CLOSED_CAPTION_MIMETYPE; }); }); // Capture the stream objects from the generated manifest by ID. externalTextStream = manifest.textStreams.filter((s) => s.id == 2)[0]; goog.asserts.assert( externalTextStream, 'Should have found external text!'); embeddedTextStream = manifest.textStreams.filter((s) => s.id == 3)[0]; goog.asserts.assert( embeddedTextStream, 'Should have found embedded text!'); // For these tests, we don't care about specific data appended. // Just return any old ArrayBuffer for any requested segment. netEngine.request.and.callFake((requestType, request) => { const buffer = new ArrayBuffer(0); /** @type {shaka.extern.Response} */ const response = {uri: request.uris[0], data: buffer, headers: {}}; return shaka.util.AbortableOperation.completed(response); }); // For these tests, we also don't need FakeMediaSourceEngine to verify // its input data. mediaSourceEngine = new shaka.test.FakeMediaSourceEngine({}); mediaSourceEngine.clear.and.returnValue(Promise.resolve()); mediaSourceEngine.bufferedAheadOf.and.returnValue(0); mediaSourceEngine.bufferStart.and.returnValue(0); mediaSourceEngine.setStreamProperties.and.returnValue(Promise.resolve()); mediaSourceEngine.remove.and.returnValue(Promise.resolve()); mediaSourceEngine.setSelectedClosedCaptionId = /** @type {?} */ (jasmine.createSpy('setSelectedClosedCaptionId')); const bufferEnd = {audio: 0, video: 0, text: 0}; mediaSourceEngine.appendBuffer.and.callFake( (type, data, start, end) => { bufferEnd[type] = end; return Promise.resolve(); }); mediaSourceEngine.bufferEnd.and.callFake((type) => { return bufferEnd[type]; }); mediaSourceEngine.bufferedAheadOf.and.callFake((type, start) => { return Math.max(0, bufferEnd[type] - start); }); mediaSourceEngine.isBuffered.and.callFake((type, time) => { return time >= 0 && time < bufferEnd[type]; }); const config = shaka.util.PlayerConfiguration.createDefault().streaming; config.rebufferingGoal = 20; config.bufferingGoal = 20; config.alwaysStreamText = true; config.ignoreTextStreamFailures = true; playing = false; presentationTimeInSeconds = 0; createStreamingEngine(config); // Each test will switch text streams to simulate various activities, but // all will use this variant. streamingEngine.switchVariant(manifest.variants[0]); onTick = jasmine.createSpy('onTick'); }); it('initializes embedded captions after nothing', async () => { // Start without text. streamingEngine.start().catch(fail); playing = true; onTick.and.callFake(() => { // Switch to embedded text. streamingEngine.switchTextStream(embeddedTextStream); onTick.and.stub(); }); await Util.fakeEventLoop(10, (time) => Util.invokeSpy(onTick)); // We have buffered through the Period transition. expect(Util.invokeSpy(mediaSourceEngine.bufferEnd, 'video')) .toBeGreaterThan(12); expect(mediaSourceEngine.setSelectedClosedCaptionId) .toHaveBeenCalledTimes(1); }); it('initializes embedded captions after external text', async () => { // Start with external text. streamingEngine.switchTextStream(externalTextStream); streamingEngine.start().catch(fail); playing = true; onTick.and.callFake(() => { // Switch to embedded text. streamingEngine.switchTextStream(embeddedTextStream); onTick.and.stub(); }); await Util.fakeEventLoop(10, (time) => Util.invokeSpy(onTick)); // We have buffered through the Period transition. expect(Util.invokeSpy(mediaSourceEngine.bufferEnd, 'video')) .toBeGreaterThan(12); expect(mediaSourceEngine.setSelectedClosedCaptionId) .toHaveBeenCalledTimes(1); }); it('switches to external text after embedded captions', async () => { // Start with embedded text. streamingEngine.switchTextStream(embeddedTextStream); streamingEngine.start().catch(fail); playing = true; onTick.and.callFake(() => { // Switch to external text. streamingEngine.switchTextStream(externalTextStream); onTick.and.stub(); }); await Util.fakeEventLoop(10, (time) => Util.invokeSpy(onTick)); // We have buffered through the Period transition. expect(Util.invokeSpy(mediaSourceEngine.bufferEnd, 'video')) .toBeGreaterThan(12); expect(mediaSourceEngine.setSelectedClosedCaptionId) .toHaveBeenCalledTimes(1); }); it('plays embedded text throughout', async () => { // Start with embedded text. streamingEngine.switchTextStream(embeddedTextStream); streamingEngine.start().catch(fail); playing = true; await Util.fakeEventLoop(10, (time) => Util.invokeSpy(onTick)); // We have buffered through the Period transition. expect(Util.invokeSpy(mediaSourceEngine.bufferEnd, 'video')) .toBeGreaterThan(12); expect(mediaSourceEngine.setSelectedClosedCaptionId) .toHaveBeenCalledTimes(1); }); }); it('calls createSegmentIndex on demand', async () => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); createStreamingEngine(); // None of the streams have been set up yet because we haven't started yet. expect(audioStream.createSegmentIndex).not.toHaveBeenCalled(); expect(videoStream.createSegmentIndex).not.toHaveBeenCalled(); expect(alternateVideoStream.createSegmentIndex).not.toHaveBeenCalled(); // Here we go! streamingEngine.switchVariant(variant); streamingEngine.switchTextStream(textStream); await streamingEngine.start(); playing = true; await runTest(() => { if (presentationTimeInSeconds == 1) { // Once we're streaming, the audio & video streams have been set up. expect(audioStream.createSegmentIndex).toHaveBeenCalled(); expect(videoStream.createSegmentIndex).toHaveBeenCalled(); // But not this alternate video stream. expect(alternateVideoStream.createSegmentIndex).not.toHaveBeenCalled(); } }); // Because we never switched to this stream, it was never set up at any time // during this simulated playback. expect(alternateVideoStream.createSegmentIndex).not.toHaveBeenCalled(); }); describe('destroy', () => { it('aborts pending network operations', async () => { setupVod(); mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); // Track the incoming request and whether it was aborted. let isRequested = false; let isAborted = false; netEngine.request.and.callFake((requestType, request) => { isRequested = true; const abortOp = () => { isAborted = true; return Promise.resolve(); }; // This will never complete, but can be aborted. const hungPromise = new Promise(() => {}); return new shaka.util.AbortableOperation(hungPromise, abortOp); }); // General setup. createStreamingEngine(); streamingEngine.switchVariant(variant); await streamingEngine.start(); playing = true; // Simulate time passing. await Util.fakeEventLoop(1); // By now the request should have fired. expect(isRequested).toBe(true); // Destroy StreamingEngine. await streamingEngine.destroy(); // The request should have been aborted. expect(isAborted).toBe(true); }); }); /** * Slides the segment availability window forward by 1 second. */ function slideSegmentAvailabilityWindow() { segmentAvailability.start++; segmentAvailability.end++; } /** * @param {!shaka.test.FakeNetworkingEngine} netEngine A NetworkingEngine * look-alike. * @param {string} targetUri * @param {shaka.util.Error.Code} errorCode */ function failFirstRequestForTarget(netEngine, targetUri, errorCode) { // eslint-disable-next-line no-restricted-syntax const originalNetEngineRequest = netEngine.request.bind(netEngine); let attempts = 0; netEngine.request = jasmine.createSpy('request').and.callFake( (requestType, request) => { if (request.uris[0] == targetUri) { if (++attempts == 1) { const data = [targetUri]; if (errorCode == shaka.util.Error.Code.BAD_HTTP_STATUS) { data.push(404); data.push(''); } return shaka.util.AbortableOperation.failed(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.NETWORK, errorCode, data)); } } return originalNetEngineRequest(requestType, request); }); } /** * Override the init segment on all segment references for this Stream. * * @param {?shaka.extern.Stream} stream * @param {shaka.media.InitSegmentReference} initSegmentReference */ function overrideInitSegment(stream, initSegmentReference) { if (!stream) { return; } const originalGet = stream.segmentIndex.get; stream.segmentIndex.get = (position) => { // eslint-disable-next-line no-restricted-syntax const segmentReference = originalGet.call(stream.segmentIndex, position); if (segmentReference) { segmentReference.initSegmentReference = initSegmentReference; } return segmentReference; }; } });