Files
shaka-player/test/media/streaming_engine_unit.js
T
Joey Parrish e8ac57f36c Move drmInfos array to Stream
Period-flattening will concatenate Stream objects, so this information
should be available per-Stream instead of at the Variant level.

Issue #1339

Change-Id: I96195fea48cab1e4a349b2ab0b16064a443e928a
2020-04-09 19:22:16 +00:00

2979 lines
103 KiB
JavaScript

/** @license
* 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.<shaka.util.ManifestParserUtils.ContentType,
* !Array.<number>>}
*/
const initSegmentRanges = {};
initSegmentRanges[ContentType.AUDIO] = [100, 1000];
initSegmentRanges[ContentType.VIDEO] = [200, 2000];
/** @type {!Object.<shaka.util.ManifestParserUtils.ContentType, number>} */
const segmentSizes = {};
segmentSizes[ContentType.AUDIO] = 1000;
segmentSizes[ContentType.VIDEO] = 10000;
segmentSizes[ContentType.TEXT] = 500;
/** @type {!Object.<string, shaka.test.FakeMediaSourceEngine.SegmentData>} */
let segmentData;
/** @type {number} */
let presentationTimeInSeconds;
/** @type {boolean} */
let playing;
/** @type {!shaka.test.FakeMediaSourceEngine} */
let mediaSourceEngine;
let netEngine;
let timeline;
let audioStream;
let videoStream;
let variant;
let textStream;
let alternateVariant;
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);
timeline = shaka.test.StreamingEngineUtil.createFakePresentationTimeline(
/* segmentAvailabilityStart= */ 0,
/* segmentAvailabilityEnd= */ 40,
/* 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
// timeline.segmentAvailabilityStart in the test setup.
timeline = shaka.test.StreamingEngineUtil.createFakePresentationTimeline(
/* segmentAvailabilityStart= */ 90,
/* segmentAvailabilityEnd= */ 140,
/* 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.
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];
});
}
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('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.
netEngine.delays.text = 0.1;
netEngine.delays.audio = 1.0;
netEngine.delays.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;
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);
});
});
initialVariant = manifest.variants[0];
sameAudioVariant = manifest.variants[1];
sameVideoVariant = manifest.variants[2];
initialTextStream = manifest.textStreams[0];
// For these tests, we don't care about specific data appended.
// Just return any old ArrayBuffer for any requested segment.
netEngine = {
request: (requestType, request) => {
const buffer = new ArrayBuffer(0);
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();
});
});
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();
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 () => {
timeline.segmentAvailabilityStart = 90;
timeline.segmentAvailabilityEnd = 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(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(timeline.getSegmentAvailabilityStart()).toBe(100);
expect(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 () => {
// Don't use returnValue with Promise.reject, or it may be detected as an
// unhandled Promise rejection.
videoStream.createSegmentIndex.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);
// Don't use returnValue with Promise.reject, or it may be detected as an
// unhandled Promise rejection.
alternateVideoStream.createSegmentIndex.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';
const originalNetEngine = netEngine;
netEngine = {
request: jasmine.createSpy('request'),
};
netEngine.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 originalNetEngine.request(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(netEngine.attempts).toBeGreaterThan(1);
expect(mediaSourceEngine.endOfStream).toHaveBeenCalledTimes(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(netEngine.attempts).toBe(1);
expect(mediaSourceEngine.endOfStream).not.toHaveBeenCalled();
});
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 = null;
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.<string>} */
let requestUris;
/** @type {!Array.<shaka.util.PublicPromise>} */
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: (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: (requestType, request) => {
const buffer = new ArrayBuffer(0);
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();
});
/**
* Slides the segment availability window forward by 1 second.
*/
function slideSegmentAvailabilityWindow() {
timeline.segmentAvailabilityStart++;
timeline.segmentAvailabilityEnd++;
}
/**
* @param {!Object} netEngine A NetworkingEngine look-alike from
* shaka.test.StreamingEngineUtil.createFakeNetworkingEngine()
* @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);
netEngine.attempts = 0;
netEngine.request = jasmine.createSpy('request').and.callFake(
(requestType, request) => {
if (request.uris[0] == targetUri) {
if (++netEngine.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;
};
}
});