Files
hls.js/tests/unit/controller/stream-controller.ts
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Tom Jenkinson
359197f20b chore(deps): update dependency prettier to v3 (#5646)
* chore(deps): update dependency prettier to v3

* run prettier

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Tom Jenkinson <tom@tjenkinson.me>
2023-08-22 11:24:59 +01:00

628 lines
21 KiB
TypeScript

/* eslint-disable dot-notation */
import Hls from '../../../src/hls';
import { Events } from '../../../src/events';
import {
FragmentTracker,
FragmentState,
} from '../../../src/controller/fragment-tracker';
import StreamController from '../../../src/controller/stream-controller';
import { State } from '../../../src/controller/base-stream-controller';
import { mockFragments } from '../../mocks/data';
import { Fragment } from '../../../src/loader/fragment';
import { LevelDetails } from '../../../src/loader/level-details';
import M3U8Parser from '../../../src/loader/m3u8-parser';
import { LoadStats } from '../../../src/loader/load-stats';
import { PlaylistLevelType } from '../../../src/types/loader';
import { AttrList } from '../../../src/utils/attr-list';
import { Level, LevelAttributes } from '../../../src/types/level';
import type { ParsedMultivariantPlaylist } from '../../../src/loader/m3u8-parser';
import sinon from 'sinon';
import chai from 'chai';
import sinonChai from 'sinon-chai';
chai.use(sinonChai);
const expect = chai.expect;
describe('StreamController', function () {
let fake;
let hls: Hls;
let fragmentTracker: FragmentTracker;
let streamController: StreamController;
const attrs: LevelAttributes = new AttrList({});
beforeEach(function () {
fake = sinon.useFakeXMLHttpRequest();
hls = new Hls({
// Enable debug to catch callback errors and enable logging in these tests:
// debug: true,
startFragPrefetch: true,
enableWorker: false,
});
streamController = hls['streamController'];
fragmentTracker = streamController['fragmentTracker'];
streamController['startFragRequested'] = true;
});
this.afterEach(function () {
fake.restore();
hls.destroy();
});
const assertStreamControllerStarted = (streamController) => {
expect(streamController.hasInterval()).to.be.true;
expect(streamController.state).to.equal(
State.IDLE,
"StreamController's state should not be STOPPED",
);
};
const assertStreamControllerStopped = (streamController) => {
expect(streamController.hasInterval()).to.be.false;
expect(streamController.state).to.equal(
State.STOPPED,
"StreamController's state should be STOPPED",
);
};
const loadManifest = (manifest: string): ParsedMultivariantPlaylist => {
const result = M3U8Parser.parseMasterPlaylist(
manifest,
'http://www.example.com',
);
const {
contentSteering,
levels,
sessionData,
sessionKeys,
startTimeOffset,
variableList,
} = result;
hls.trigger(Events.MANIFEST_LOADED, {
levels,
audioTracks: [],
contentSteering,
url: 'http://www.example.com',
stats: new LoadStats(),
networkDetails: {},
sessionData,
sessionKeys,
startTimeOffset,
variableList,
});
return result;
};
describe('StreamController', function () {
it('should be STOPPED when it is initialized', function () {
assertStreamControllerStopped(streamController);
});
it('should not start when controller does not have level data', function () {
streamController.startLoad(1);
assertStreamControllerStopped(streamController);
});
it('should start without level details', function () {
loadManifest(`#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,RESOLUTION=848x360,NAME="480"
http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`);
assertStreamControllerStarted(streamController);
streamController.stopLoad();
assertStreamControllerStopped(streamController);
});
it('should use EXT-X-START from Multivariant Playlist when not overridden by startPosition', function () {
loadManifest(`#EXTM3U
#EXT-X-START:TIME-OFFSET=130.5
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,RESOLUTION=848x360,NAME="480"
http://www.example.com/media.m3u8`);
assertStreamControllerStarted(streamController);
// Trigger Level Loaded
const details = new LevelDetails('');
details.live = false;
details.totalduration = 200;
details.fragments.push({} as any);
hls.trigger(Events.LEVEL_LOADED, {
details,
id: 0,
level: 0,
networkDetails: {},
stats: new LoadStats(),
deliveryDirectives: null,
});
expect(streamController['startPosition']).to.equal(130.5);
expect(streamController['nextLoadPosition']).to.equal(130.5);
expect(streamController['lastCurrentTime']).to.equal(130.5);
});
it('should use EXT-X-START from Multivariant Playlist when not overridden by startPosition with live playlists', function () {
const result = loadManifest(`#EXTM3U
#EXT-X-START:TIME-OFFSET=-12.0
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,RESOLUTION=848x360,NAME="480"
http://www.example.com/media.m3u8`);
const {
contentSteering,
levels,
sessionData,
sessionKeys,
startTimeOffset,
variableList,
} = result;
hls.trigger(Events.MANIFEST_LOADED, {
levels,
audioTracks: [],
contentSteering,
url: 'http://www.example.com',
stats: new LoadStats(),
networkDetails: {},
sessionData,
sessionKeys,
startTimeOffset,
variableList,
});
assertStreamControllerStarted(streamController);
// Trigger Level Loaded
const details = new LevelDetails('');
details.live = true;
details.totalduration = 30;
details.fragments.push({ start: 0 } as any);
hls.trigger(Events.LEVEL_LOADED, {
details,
id: 0,
level: 0,
networkDetails: {},
stats: new LoadStats(),
deliveryDirectives: null,
});
expect(streamController['startPosition']).to.equal(18);
expect(streamController['nextLoadPosition']).to.equal(18);
expect(streamController['lastCurrentTime']).to.equal(18);
});
});
describe('SN Searching', function () {
const fragPrevious = new Fragment(PlaylistLevelType.MAIN, '');
fragPrevious.programDateTime = 1505502671523;
fragPrevious.duration = 5.0;
fragPrevious.level = 1;
fragPrevious.start = 10.0;
fragPrevious.sn = 2; // Fragment with PDT 1505502671523 in level 1 does not have the same sn as in level 2 where cc is 1
fragPrevious.cc = 0;
const levelDetails = new LevelDetails('');
const bufferEnd = fragPrevious.start + fragPrevious.duration;
const end =
mockFragments[mockFragments.length - 1].start +
mockFragments[mockFragments.length - 1].duration;
beforeEach(function () {
streamController['fragPrevious'] = fragPrevious;
levelDetails.live = false;
levelDetails.startSN = mockFragments[0].sn;
levelDetails.endSN = mockFragments[mockFragments.length - 1].sn;
levelDetails.fragments = mockFragments;
levelDetails.targetduration = mockFragments[0].duration;
levelDetails.totalduration = mockFragments.reduce(
(sum, frag) => sum + frag.duration,
0,
);
});
it('PTS search choosing wrong fragment (3 instead of 2) after level loaded', function () {
const foundFragment = streamController['getNextFragment'](
bufferEnd,
levelDetails,
);
const resultSN = foundFragment ? foundFragment.sn : -1;
expect(foundFragment).to.equal(
mockFragments[3],
'Expected sn 3, found sn segment ' + resultSN,
);
});
it('PTS search choosing the right segment if fragPrevious is not available', function () {
streamController['fragPrevious'] = null;
const foundFragment = streamController['getNextFragment'](
bufferEnd,
levelDetails,
);
const resultSN = foundFragment ? foundFragment.sn : -1;
expect(foundFragment).to.equal(
mockFragments[3],
'Expected sn 3, found sn segment ' + resultSN,
);
});
it('returns the last fragment if the stream is fully buffered', function () {
const actual = streamController['getNextFragment'](end, levelDetails);
expect(actual).to.equal(mockFragments[mockFragments.length - 1]);
});
describe('getInitialLiveFragment', function () {
let fragPrevious;
beforeEach(function () {
// onLevelUpdated updates latencyController.levelDetails used to get live sync position
hls['latencyController']['levelDetails'] = levelDetails;
fragPrevious = new Fragment(PlaylistLevelType.MAIN, '');
// Fragment with PDT 1505502681523 in level 1 does not have the same sn as in level 2 where cc is 1
fragPrevious.cc = 0;
fragPrevious.programDateTime = 1505502681523;
fragPrevious.duration = 5.0;
fragPrevious.level = 1;
fragPrevious.start = 15.0;
fragPrevious.sn = 3;
streamController['fragPrevious'] = fragPrevious;
levelDetails.PTSKnown = false;
levelDetails.alignedSliding = false;
levelDetails.live = true;
});
describe('with program-date-time', function () {
it('does PDT search, choosing fragment after level loaded', function () {
const foundFragment = streamController['getInitialLiveFragment'](
levelDetails,
mockFragments,
);
expect(foundFragment).to.equal(
mockFragments[4],
`Expected sn 4, found sn segment ${
foundFragment ? foundFragment.sn : -1
}`,
);
});
});
describe('without program-date-time', function () {
const fragmentsWithoutPdt = mockFragments.map((frag) => {
const newFragment = new Fragment(PlaylistLevelType.MAIN, '');
return Object.assign(newFragment, frag, {
programDateTime: null,
});
});
beforeEach(function () {
// For sn lookup, cc much match
fragPrevious.cc = 1;
levelDetails.PTSKnown = false;
levelDetails.alignedSliding = false;
levelDetails.live = true;
levelDetails.startSN = fragmentsWithoutPdt[0].sn;
levelDetails.endSN =
fragmentsWithoutPdt[fragmentsWithoutPdt.length - 1].sn;
levelDetails.fragments = fragmentsWithoutPdt;
});
it('finds the next fragment to load based on the last fragment buffered', function () {
fragPrevious.sn = 0;
let foundFragment = streamController['getInitialLiveFragment'](
levelDetails,
fragmentsWithoutPdt,
);
expect(foundFragment).to.equal(
fragmentsWithoutPdt[1],
`Expected sn 1, found sn segment ${
foundFragment ? foundFragment.sn : -1
}`,
);
fragPrevious.sn = 3;
foundFragment = streamController['getInitialLiveFragment'](
levelDetails,
fragmentsWithoutPdt,
);
expect(foundFragment).to.equal(
fragmentsWithoutPdt[4],
`Expected sn 4, found sn segment ${
foundFragment ? foundFragment.sn : -1
}`,
);
});
it('finds the first fragment to load when starting or re-syncing with a live stream', function () {
streamController['fragPrevious'] = null;
const foundFragment = streamController['getInitialLiveFragment'](
levelDetails,
fragmentsWithoutPdt,
);
expect(foundFragment).to.equal(
fragmentsWithoutPdt[2],
`Expected sn 2, found sn segment ${
foundFragment ? foundFragment.sn : -1
}`,
);
});
it('finds the fragment with the same cc when there is no sn match', function () {
fragPrevious.cc = 0;
const foundFragment = streamController['getInitialLiveFragment'](
levelDetails,
fragmentsWithoutPdt,
);
expect(foundFragment).to.equal(
fragmentsWithoutPdt[0],
`Expected sn 0, found sn segment ${
foundFragment ? foundFragment.sn : -1
}`,
);
});
it('returns null when there is no cc match with the previous segment', function () {
fragPrevious.cc = 2;
const foundFragment = streamController['getInitialLiveFragment'](
levelDetails,
fragmentsWithoutPdt,
);
expect(foundFragment).to.equal(null);
});
});
});
});
describe('fragment loading', function () {
function fragStateStub(state) {
return sinon.stub(fragmentTracker, 'getState').callsFake(() => state);
}
let triggerSpy;
let frag;
let level;
beforeEach(function () {
streamController['levels'] = [
new Level({
name: '',
url: '',
attrs,
bitrate: 500000,
}),
];
triggerSpy = sinon.spy(hls, 'trigger');
frag = new Fragment(PlaylistLevelType.MAIN, '');
frag.level = 0;
frag.url = 'file';
level = new Level({
attrs: new AttrList({}),
bitrate: 1,
name: '',
url: '',
});
level.details = new LevelDetails('');
level.details.fragments.push(frag);
});
function assertLoadingState(frag) {
expect(triggerSpy).to.have.been.calledWith(Events.FRAG_LOADING, {
frag,
targetBufferTime: 0,
});
expect(streamController.state).to.equal(State.FRAG_LOADING);
}
function assertNotLoadingState() {
expect(triggerSpy).to.not.have.been.called;
expect(streamController.state).to.not.equal(State.FRAG_LOADING);
}
it('should load a complete fragment which has not been previously appended', function () {
fragStateStub(FragmentState.NOT_LOADED);
streamController['loadFragment'](frag, level, 0);
assertLoadingState(frag);
});
it('should not load a fragment which has completely & successfully loaded', function () {
fragStateStub(FragmentState.OK);
streamController['loadFragment'](frag, level, 0);
assertNotLoadingState();
});
it('should not load a fragment while it is appending', function () {
fragStateStub(FragmentState.APPENDING);
streamController['loadFragment'](frag, level, 0);
assertNotLoadingState();
});
});
describe('checkBuffer', function () {
const sandbox = sinon.createSandbox();
const bufStart = 5;
beforeEach(function () {
// @ts-ignore
streamController.gapController = {
poll: function () {},
destroy: function () {},
};
streamController['media'] = {
buffered: {
start() {
return bufStart;
},
end() {
return bufStart;
},
length: 1,
},
currentTime: 0,
readyState: 4,
} as any as HTMLMediaElement;
streamController['mediaBuffer'] = null;
});
afterEach(function () {
sandbox.restore();
});
it('should not throw when media is undefined', function () {
streamController['media'] = null;
streamController['checkBuffer']();
});
it('should seek to start pos when data is first loaded', function () {
const firstFrag = new Fragment(PlaylistLevelType.MAIN, '');
firstFrag.duration = 5.0;
firstFrag.level = 1;
firstFrag.start = 0;
firstFrag.sn = 1;
firstFrag.cc = 0;
firstFrag.elementaryStreams.video = {
startDTS: 0,
startPTS: 0,
endDTS: 5,
endPTS: 5,
};
// @ts-ignore
const seekStub = sandbox.stub(streamController, 'seekToStartPos');
streamController['loadedmetadata'] = false;
streamController['fragCurrent'] = streamController['fragPrevious'] =
firstFrag;
streamController['fragBufferedComplete'](firstFrag, null);
expect(seekStub).to.have.been.calledOnce;
expect(streamController['loadedmetadata']).to.be.true;
});
it('should not seek to start pos when metadata has been loaded', function () {
// @ts-ignore
const seekStub = sandbox.stub(streamController, 'seekToStartPos');
streamController['loadedmetadata'] = true;
streamController['checkBuffer']();
expect(seekStub).to.have.not.been.called;
expect(streamController['loadedmetadata']).to.be.true;
});
it('should not seek to start pos when nothing has been buffered', function () {
// @ts-ignore
const seekStub = sandbox.stub(streamController, 'seekToStartPos');
(streamController['media']!.buffered as any).length = 0;
streamController['checkBuffer']();
expect(seekStub).to.have.not.been.called;
expect(streamController['loadedmetadata']).to.be.false;
});
describe('seekToStartPos', function () {
it('should seek to startPosition when startPosition is not buffered & the media is not seeking', function () {
streamController['startPosition'] = 5;
streamController['seekToStartPos']();
expect(streamController['media']!.currentTime).to.equal(5);
});
it('should not seek to startPosition when it is buffered', function () {
streamController['startPosition'] = 5;
streamController['media']!.currentTime = 5;
streamController['seekToStartPos']();
expect(streamController['media']!.currentTime).to.equal(5);
});
});
describe('startLoad', function () {
beforeEach(function () {
hls.trigger(Events.LEVELS_UPDATED, {
levels: [
new Level({
name: '',
url: '',
attrs,
bitrate: 500000,
}),
new Level({
name: '',
url: '',
attrs,
bitrate: 250000,
}),
new Level({
name: '',
url: '',
attrs,
bitrate: 750000,
}),
],
});
streamController['media'] = null;
});
it('should not start when controller does not have level data', function () {
streamController['levels'] = null;
streamController.startLoad(-1);
assertStreamControllerStopped(streamController);
});
it('should start when controller has level data', function () {
streamController.startLoad(5);
assertStreamControllerStarted(streamController);
expect(streamController['nextLoadPosition']).to.equal(5);
expect(streamController['startPosition']).to.equal(5);
expect(streamController['lastCurrentTime']).to.equal(5);
});
it('should set startPosition to lastCurrentTime if unset and lastCurrentTime > 0', function () {
streamController['lastCurrentTime'] = 5;
streamController.startLoad(-1);
assertStreamControllerStarted(streamController);
expect(streamController['nextLoadPosition']).to.equal(5);
expect(streamController['startPosition']).to.equal(5);
expect(streamController['lastCurrentTime']).to.equal(5);
});
it('should set startPosition when passed as an argument', function () {
streamController.startLoad(123);
assertStreamControllerStarted(streamController);
expect(streamController['nextLoadPosition']).to.equal(123);
expect(streamController['startPosition']).to.equal(123);
expect(streamController['lastCurrentTime']).to.equal(123);
});
it('should set startPosition to -1 when passed as an argument', function () {
streamController.startLoad(-1);
assertStreamControllerStarted(streamController);
expect(streamController['nextLoadPosition']).to.equal(-1);
expect(streamController['startPosition']).to.equal(-1);
expect(streamController['lastCurrentTime']).to.equal(-1);
});
it('sets up for a bandwidth test if starting at auto', function () {
streamController['startFragRequested'] = false;
hls.startLevel = -1;
streamController.startLoad(-1);
expect(streamController['level']).to.equal(0);
expect(streamController['bitrateTest']).to.be.true;
});
it('should not signal a bandwidth test if config.testBandwidth is false', function () {
streamController['startFragRequested'] = false;
hls.startLevel = -1;
hls.nextAutoLevel = 2;
hls.config.testBandwidth = false;
streamController.startLoad(-1);
expect(streamController['level']).to.equal(2);
expect(streamController['bitrateTest']).to.be.false;
});
it('should not signal a bandwidth test with only one level', function () {
streamController['startFragRequested'] = false;
hls.trigger(Events.LEVELS_UPDATED, {
levels: [
new Level({
name: '',
url: '',
attrs,
bitrate: 250000,
}),
],
});
hls.startLevel = -1;
streamController.startLoad(-1);
expect(streamController['level']).to.equal(hls.nextAutoLevel);
expect(streamController['bitrateTest']).to.be.false;
});
});
});
});