src/loader/playlist-loader.ts
/**
* PlaylistLoader - delegate for media manifest/playlist loading tasks. Takes care of parsing media to internal data-models.
*
* Once loaded, dispatches events with parsed data-models of manifest/levels/audio/subtitle tracks.
*
* Uses loader(s) set in config to do actual internal loading of resource tasks.
*
* @module
*
*/
import { Events } from '../events';
import { ErrorDetails, ErrorTypes } from '../errors';
import { logger } from '../utils/logger';
import { parseSegmentIndex, findBox } from '../utils/mp4-tools';
import M3U8Parser from './m3u8-parser';
import type { LevelParsed } from '../types/level';
import type {
Loader,
LoaderConfiguration,
LoaderContext,
LoaderResponse,
LoaderStats,
PlaylistLoaderContext,
} from '../types/loader';
import { PlaylistContextType, PlaylistLevelType } from '../types/loader';
import { LevelDetails } from './level-details';
import type Hls from '../hls';
import { AttrList } from '../utils/attr-list';
import type {
ErrorData,
LevelLoadingData,
ManifestLoadingData,
TrackLoadingData,
} from '../types/events';
import { NetworkComponentAPI } from '../types/component-api';
function mapContextToLevelType(
context: PlaylistLoaderContext
): PlaylistLevelType {
const { type } = context;
switch (type) {
case PlaylistContextType.AUDIO_TRACK:
return PlaylistLevelType.AUDIO;
case PlaylistContextType.SUBTITLE_TRACK:
return PlaylistLevelType.SUBTITLE;
default:
return PlaylistLevelType.MAIN;
}
}
function getResponseUrl(
response: LoaderResponse,
context: PlaylistLoaderContext
): string {
let url = response.url;
// responseURL not supported on some browsers (it is used to detect URL redirection)
// data-uri mode also not supported (but no need to detect redirection)
if (url === undefined || url.indexOf('data:') === 0) {
// fallback to initial URL
url = context.url;
}
return url;
}
class PlaylistLoader implements NetworkComponentAPI {
private readonly hls: Hls;
private readonly loaders: {
[key: string]: Loader<LoaderContext>;
} = Object.create(null);
constructor(hls: Hls) {
this.hls = hls;
this.registerListeners();
}
public startLoad(startPosition: number): void {}
public stopLoad(): void {
this.destroyInternalLoaders();
}
private registerListeners() {
const { hls } = this;
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.on(Events.AUDIO_TRACK_LOADING, this.onAudioTrackLoading, this);
hls.on(Events.SUBTITLE_TRACK_LOADING, this.onSubtitleTrackLoading, this);
}
private unregisterListeners() {
const { hls } = this;
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.off(Events.AUDIO_TRACK_LOADING, this.onAudioTrackLoading, this);
hls.off(Events.SUBTITLE_TRACK_LOADING, this.onSubtitleTrackLoading, this);
}
/**
* Returns defaults or configured loader-type overloads (pLoader and loader config params)
*/
private createInternalLoader(
context: PlaylistLoaderContext
): Loader<LoaderContext> {
const config = this.hls.config;
const PLoader = config.pLoader;
const Loader = config.loader;
const InternalLoader = PLoader || Loader;
const loader = new InternalLoader(config) as Loader<PlaylistLoaderContext>;
context.loader = loader;
this.loaders[context.type] = loader;
return loader;
}
private getInternalLoader(
context: PlaylistLoaderContext
): Loader<LoaderContext> {
return this.loaders[context.type];
}
private resetInternalLoader(contextType): void {
if (this.loaders[contextType]) {
delete this.loaders[contextType];
}
}
/**
* Call `destroy` on all internal loader instances mapped (one per context type)
*/
private destroyInternalLoaders(): void {
for (const contextType in this.loaders) {
const loader = this.loaders[contextType];
if (loader) {
loader.destroy();
}
this.resetInternalLoader(contextType);
}
}
public destroy(): void {
this.unregisterListeners();
this.destroyInternalLoaders();
}
private onManifestLoading(
event: Events.MANIFEST_LOADING,
data: ManifestLoadingData
) {
const { url } = data;
this.load({
id: null,
groupId: null,
level: 0,
responseType: 'text',
type: PlaylistContextType.MANIFEST,
url,
deliveryDirectives: null,
});
}
private onLevelLoading(event: Events.LEVEL_LOADING, data: LevelLoadingData) {
const { id, level, url, deliveryDirectives } = data;
this.load({
id,
groupId: null,
level,
responseType: 'text',
type: PlaylistContextType.LEVEL,
url,
deliveryDirectives,
});
}
private onAudioTrackLoading(
event: Events.AUDIO_TRACK_LOADING,
data: TrackLoadingData
) {
const { id, groupId, url, deliveryDirectives } = data;
this.load({
id,
groupId,
level: null,
responseType: 'text',
type: PlaylistContextType.AUDIO_TRACK,
url,
deliveryDirectives,
});
}
private onSubtitleTrackLoading(
event: Events.SUBTITLE_TRACK_LOADING,
data: TrackLoadingData
) {
const { id, groupId, url, deliveryDirectives } = data;
this.load({
id,
groupId,
level: null,
responseType: 'text',
type: PlaylistContextType.SUBTITLE_TRACK,
url,
deliveryDirectives,
});
}
private load(context: PlaylistLoaderContext): void {
const config = this.hls.config;
// logger.debug(`[playlist-loader]: Loading playlist of type ${context.type}, level: ${context.level}, id: ${context.id}`);
// Check if a loader for this context already exists
let loader = this.getInternalLoader(context);
if (loader) {
const loaderContext = loader.context;
if (loaderContext && loaderContext.url === context.url) {
// same URL can't overlap
logger.trace('[playlist-loader]: playlist request ongoing');
return;
}
logger.log(
`[playlist-loader]: aborting previous loader for type: ${context.type}`
);
loader.abort();
}
let maxRetry;
let timeout;
let retryDelay;
let maxRetryDelay;
// apply different configs for retries depending on
// context (manifest, level, audio/subs playlist)
switch (context.type) {
case PlaylistContextType.MANIFEST:
maxRetry = config.manifestLoadingMaxRetry;
timeout = config.manifestLoadingTimeOut;
retryDelay = config.manifestLoadingRetryDelay;
maxRetryDelay = config.manifestLoadingMaxRetryTimeout;
break;
case PlaylistContextType.LEVEL:
case PlaylistContextType.AUDIO_TRACK:
case PlaylistContextType.SUBTITLE_TRACK:
// Manage retries in Level/Track Controller
maxRetry = 0;
timeout = config.levelLoadingTimeOut;
break;
default:
maxRetry = config.levelLoadingMaxRetry;
timeout = config.levelLoadingTimeOut;
retryDelay = config.levelLoadingRetryDelay;
maxRetryDelay = config.levelLoadingMaxRetryTimeout;
break;
}
loader = this.createInternalLoader(context);
// Override level/track timeout for LL-HLS requests
// (the default of 10000ms is counter productive to blocking playlist reload requests)
if (context.deliveryDirectives?.part) {
let levelDetails: LevelDetails | undefined;
if (
context.type === PlaylistContextType.LEVEL &&
context.level !== null
) {
levelDetails = this.hls.levels[context.level].details;
} else if (
context.type === PlaylistContextType.AUDIO_TRACK &&
context.id !== null
) {
levelDetails = this.hls.audioTracks[context.id].details;
} else if (
context.type === PlaylistContextType.SUBTITLE_TRACK &&
context.id !== null
) {
levelDetails = this.hls.subtitleTracks[context.id].details;
}
if (levelDetails) {
const partTarget = levelDetails.partTarget;
const targetDuration = levelDetails.targetduration;
if (partTarget && targetDuration) {
timeout = Math.min(
Math.max(partTarget * 3, targetDuration * 0.8) * 1000,
timeout
);
}
}
}
const loaderConfig: LoaderConfiguration = {
timeout,
maxRetry,
retryDelay,
maxRetryDelay,
highWaterMark: 0,
};
const loaderCallbacks = {
onSuccess: this.loadsuccess.bind(this),
onError: this.loaderror.bind(this),
onTimeout: this.loadtimeout.bind(this),
};
// logger.debug(`[playlist-loader]: Calling internal loader delegate for URL: ${context.url}`);
loader.load(context, loaderConfig, loaderCallbacks);
}
private loadsuccess(
response: LoaderResponse,
stats: LoaderStats,
context: PlaylistLoaderContext,
networkDetails: any = null
): void {
if (context.isSidxRequest) {
this.handleSidxRequest(response, context);
this.handlePlaylistLoaded(response, stats, context, networkDetails);
return;
}
this.resetInternalLoader(context.type);
const string = response.data as string;
// Validate if it is an M3U8 at all
if (string.indexOf('#EXTM3U') !== 0) {
this.handleManifestParsingError(
response,
context,
'no EXTM3U delimiter',
networkDetails
);
return;
}
stats.parsing.start = performance.now();
// Check if chunk-list or master. handle empty chunk list case (first EXTINF not signaled, but TARGETDURATION present)
if (
string.indexOf('#EXTINF:') > 0 ||
string.indexOf('#EXT-X-TARGETDURATION:') > 0
) {
this.handleTrackOrLevelPlaylist(response, stats, context, networkDetails);
} else {
this.handleMasterPlaylist(response, stats, context, networkDetails);
}
}
private loaderror(
response: LoaderResponse,
context: PlaylistLoaderContext,
networkDetails: any = null
): void {
this.handleNetworkError(context, networkDetails, false, response);
}
private loadtimeout(
stats: LoaderStats,
context: PlaylistLoaderContext,
networkDetails: any = null
): void {
this.handleNetworkError(context, networkDetails, true);
}
private handleMasterPlaylist(
response: LoaderResponse,
stats: LoaderStats,
context: PlaylistLoaderContext,
networkDetails: any
): void {
const hls = this.hls;
const string = response.data as string;
const url = getResponseUrl(response, context);
const { levels, sessionData } = M3U8Parser.parseMasterPlaylist(string, url);
if (!levels.length) {
this.handleManifestParsingError(
response,
context,
'no level found in manifest',
networkDetails
);
return;
}
// multi level playlist, parse level info
const audioGroups = levels.map((level: LevelParsed) => ({
id: level.attrs.AUDIO,
audioCodec: level.audioCodec,
}));
const subtitleGroups = levels.map((level: LevelParsed) => ({
id: level.attrs.SUBTITLES,
textCodec: level.textCodec,
}));
const audioTracks = M3U8Parser.parseMasterPlaylistMedia(
string,
url,
'AUDIO',
audioGroups
);
const subtitles = M3U8Parser.parseMasterPlaylistMedia(
string,
url,
'SUBTITLES',
subtitleGroups
);
const captions = M3U8Parser.parseMasterPlaylistMedia(
string,
url,
'CLOSED-CAPTIONS'
);
if (audioTracks.length) {
// check if we have found an audio track embedded in main playlist (audio track without URI attribute)
const embeddedAudioFound: boolean = audioTracks.some(
(audioTrack) => !audioTrack.url
);
// if no embedded audio track defined, but audio codec signaled in quality level,
// we need to signal this main audio track this could happen with playlists with
// alt audio rendition in which quality levels (main)
// contains both audio+video. but with mixed audio track not signaled
if (
!embeddedAudioFound &&
levels[0].audioCodec &&
!levels[0].attrs.AUDIO
) {
logger.log(
'[playlist-loader]: audio codec signaled in quality level, but no embedded audio track signaled, create one'
);
audioTracks.unshift({
type: 'main',
name: 'main',
default: false,
autoselect: false,
forced: false,
id: -1,
attrs: new AttrList({}),
bitrate: 0,
url: '',
});
}
}
hls.trigger(Events.MANIFEST_LOADED, {
levels,
audioTracks,
subtitles,
captions,
url,
stats,
networkDetails,
sessionData,
});
}
private handleTrackOrLevelPlaylist(
response: LoaderResponse,
stats: LoaderStats,
context: PlaylistLoaderContext,
networkDetails: any
): void {
const hls = this.hls;
const { id, level, type } = context;
const url = getResponseUrl(response, context);
const levelUrlId = Number.isFinite(id as number) ? id : 0;
const levelId = Number.isFinite(level as number) ? level : levelUrlId;
const levelType = mapContextToLevelType(context);
const levelDetails: LevelDetails = M3U8Parser.parseLevelPlaylist(
response.data as string,
url,
levelId!,
levelType,
levelUrlId!
);
if (!levelDetails.fragments.length) {
hls.trigger(Events.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.LEVEL_EMPTY_ERROR,
fatal: false,
url: url,
reason: 'no fragments found in level',
level: typeof context.level === 'number' ? context.level : undefined,
});
return;
}
// We have done our first request (Manifest-type) and receive
// not a master playlist but a chunk-list (track/level)
// We fire the manifest-loaded event anyway with the parsed level-details
// by creating a single-level structure for it.
if (type === PlaylistContextType.MANIFEST) {
const singleLevel: LevelParsed = {
attrs: new AttrList({}),
bitrate: 0,
details: levelDetails,
name: '',
url,
};
hls.trigger(Events.MANIFEST_LOADED, {
levels: [singleLevel],
audioTracks: [],
url,
stats,
networkDetails,
sessionData: null,
});
}
// save parsing time
stats.parsing.end = performance.now();
// in case we need SIDX ranges
// return early after calling load for
// the SIDX box.
if (levelDetails.needSidxRanges) {
const sidxUrl = levelDetails.fragments[0].initSegment?.url as string;
this.load({
url: sidxUrl,
isSidxRequest: true,
type,
level,
levelDetails,
id,
groupId: null,
rangeStart: 0,
rangeEnd: 2048,
responseType: 'arraybuffer',
deliveryDirectives: null,
});
return;
}
// extend the context with the new levelDetails property
context.levelDetails = levelDetails;
this.handlePlaylistLoaded(response, stats, context, networkDetails);
}
private handleSidxRequest(
response: LoaderResponse,
context: PlaylistLoaderContext
): void {
const data = new Uint8Array(response.data as ArrayBuffer);
const sidxBox = findBox(data, ['sidx'])[0];
// if provided fragment does not contain sidx, early return
if (!sidxBox) {
return;
}
const sidxInfo = parseSegmentIndex(sidxBox);
if (!sidxInfo) {
return;
}
const sidxReferences = sidxInfo.references;
const levelDetails = context.levelDetails as LevelDetails;
sidxReferences.forEach((segmentRef, index) => {
const segRefInfo = segmentRef.info;
const frag = levelDetails.fragments[index];
if (frag.byteRange.length === 0) {
frag.setByteRange(
String(1 + segRefInfo.end - segRefInfo.start) +
'@' +
String(segRefInfo.start)
);
}
if (frag.initSegment) {
const moovBox = findBox(data, ['moov'])[0];
const moovEndOffset = moovBox ? moovBox.length : null;
frag.initSegment.setByteRange(String(moovEndOffset) + '@0');
}
});
}
private handleManifestParsingError(
response: LoaderResponse,
context: PlaylistLoaderContext,
reason: string,
networkDetails: any
): void {
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.MANIFEST_PARSING_ERROR,
fatal: context.type === PlaylistContextType.MANIFEST,
url: response.url,
reason,
response,
context,
networkDetails,
});
}
private handleNetworkError(
context: PlaylistLoaderContext,
networkDetails: any,
timeout = false,
response?: LoaderResponse
): void {
logger.warn(
`[playlist-loader]: A network ${
timeout ? 'timeout' : 'error'
} occurred while loading ${context.type} level: ${context.level} id: ${
context.id
} group-id: "${context.groupId}"`
);
let details = ErrorDetails.UNKNOWN;
let fatal = false;
const loader = this.getInternalLoader(context);
switch (context.type) {
case PlaylistContextType.MANIFEST:
details = timeout
? ErrorDetails.MANIFEST_LOAD_TIMEOUT
: ErrorDetails.MANIFEST_LOAD_ERROR;
fatal = true;
break;
case PlaylistContextType.LEVEL:
details = timeout
? ErrorDetails.LEVEL_LOAD_TIMEOUT
: ErrorDetails.LEVEL_LOAD_ERROR;
fatal = false;
break;
case PlaylistContextType.AUDIO_TRACK:
details = timeout
? ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT
: ErrorDetails.AUDIO_TRACK_LOAD_ERROR;
fatal = false;
break;
case PlaylistContextType.SUBTITLE_TRACK:
details = timeout
? ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT
: ErrorDetails.SUBTITLE_LOAD_ERROR;
fatal = false;
break;
}
if (loader) {
this.resetInternalLoader(context.type);
}
const errorData: ErrorData = {
type: ErrorTypes.NETWORK_ERROR,
details,
fatal,
url: context.url,
loader,
context,
networkDetails,
};
if (response) {
errorData.response = response;
}
this.hls.trigger(Events.ERROR, errorData);
}
private handlePlaylistLoaded(
response: LoaderResponse,
stats: LoaderStats,
context: PlaylistLoaderContext,
networkDetails: any
): void {
const {
type,
level,
id,
groupId,
loader,
levelDetails,
deliveryDirectives,
} = context;
if (!levelDetails?.targetduration) {
this.handleManifestParsingError(
response,
context,
'invalid target duration',
networkDetails
);
return;
}
if (!loader) {
return;
}
if (levelDetails.live) {
if (loader.getCacheAge) {
levelDetails.ageHeader = loader.getCacheAge() || 0;
}
if (!loader.getCacheAge || isNaN(levelDetails.ageHeader)) {
levelDetails.ageHeader = 0;
}
}
switch (type) {
case PlaylistContextType.MANIFEST:
case PlaylistContextType.LEVEL:
this.hls.trigger(Events.LEVEL_LOADED, {
details: levelDetails,
level: level || 0,
id: id || 0,
stats,
networkDetails,
deliveryDirectives,
});
break;
case PlaylistContextType.AUDIO_TRACK:
this.hls.trigger(Events.AUDIO_TRACK_LOADED, {
details: levelDetails,
id: id || 0,
groupId: groupId || '',
stats,
networkDetails,
deliveryDirectives,
});
break;
case PlaylistContextType.SUBTITLE_TRACK:
this.hls.trigger(Events.SUBTITLE_TRACK_LOADED, {
details: levelDetails,
id: id || 0,
groupId: groupId || '',
stats,
networkDetails,
deliveryDirectives,
});
break;
}
}
}
export default PlaylistLoader;