mirror of
https://github.com/video-dev/hls.js.git
synced 2026-06-16 13:34:44 +00:00
359197f20b
* 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>
628 lines
21 KiB
TypeScript
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;
|
|
});
|
|
});
|
|
});
|
|
});
|