src/loader/playlist-loader.js
/**
* 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 Event from '../events';
import EventHandler from '../event-handler';
import { ErrorTypes, ErrorDetails } from '../errors';
import { logger } from '../utils/logger';
import MP4Demuxer from '../demux/mp4demuxer';
import M3U8Parser from './m3u8-parser';
/**
* `type` property values for this loaders' context object
* @enum
*
*/
const ContextType = {
MANIFEST: 'manifest',
LEVEL: 'level',
AUDIO_TRACK: 'audioTrack',
SUBTITLE_TRACK: 'subtitleTrack'
};
/**
* @enum {string}
*/
const LevelType = {
MAIN: 'main',
AUDIO: 'audio',
SUBTITLE: 'subtitle'
};
/**
* @constructor
*/
class PlaylistLoader extends EventHandler {
/**
* @constructs
* @param {Hls} hls
*/
constructor (hls) {
super(hls,
Event.MANIFEST_LOADING,
Event.LEVEL_LOADING,
Event.AUDIO_TRACK_LOADING,
Event.SUBTITLE_TRACK_LOADING);
this.loaders = {};
}
static get ContextType () {
return ContextType;
}
static get LevelType () {
return LevelType;
}
/**
* @param {ContextType} type
* @returns {boolean}
*/
static canHaveQualityLevels (type) {
return (type !== ContextType.AUDIO_TRACK &&
type !== ContextType.SUBTITLE_TRACK);
}
/**
* Map context.type to LevelType
* @param {{type: ContextType}} context
* @returns {LevelType}
*/
static mapContextToLevelType (context) {
const { type } = context;
switch (type) {
case ContextType.AUDIO_TRACK:
return LevelType.AUDIO;
case ContextType.SUBTITLE_TRACK:
return LevelType.SUBTITLE;
default:
return LevelType.MAIN;
}
}
static getResponseUrl (response, context) {
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;
}
/**
* Returns defaults or configured loader-type overloads (pLoader and loader config params)
* Default loader is XHRLoader (see utils)
* @param {object} context
* @returns {XHRLoader} or other compatible configured overload
*/
createInternalLoader (context) {
const config = this.hls.config;
const PLoader = config.pLoader;
const Loader = config.loader;
const InternalLoader = PLoader || Loader;
const loader = new InternalLoader(config);
context.loader = loader;
this.loaders[context.type] = loader;
return loader;
}
getInternalLoader (context) {
return this.loaders[context.type];
}
resetInternalLoader (contextType) {
if (this.loaders[contextType])
delete this.loaders[contextType];
}
/**
* Call `destroy` on all internal loader instances mapped (one per context type)
*/
destroyInternalLoaders () {
for (let contextType in this.loaders) {
let loader = this.loaders[contextType];
if (loader)
loader.destroy();
this.resetInternalLoader(contextType);
}
}
destroy () {
this.destroyInternalLoaders();
super.destroy();
}
onManifestLoading (data) {
this.load(data.url, { type: ContextType.MANIFEST });
}
onLevelLoading (data) {
this.load(data.url, { type: ContextType.LEVEL, level: data.level, id: data.id });
}
onAudioTrackLoading (data) {
this.load(data.url, { type: ContextType.AUDIO_TRACK, id: data.id });
}
onSubtitleTrackLoading (data) {
this.load(data.url, { type: ContextType.SUBTITLE_TRACK, id: data.id });
}
load (url, context) {
const config = this.hls.config;
// Check if a loader for this context already exists
let loader = this.getInternalLoader(context);
if (loader) {
const loaderContext = loader.context;
if (loaderContext && loaderContext.url === url) { // same URL can't overlap
logger.trace('playlist request ongoing');
return false;
} else {
logger.warn(`aborting previous loader for type: ${context.type}`);
loader.abort();
}
}
let maxRetry,
timeout,
retryDelay,
maxRetryDelay;
// apply different configs for retries depending on
// context (manifest, level, audio/subs playlist)
switch (context.type) {
case ContextType.MANIFEST:
maxRetry = config.manifestLoadingMaxRetry;
timeout = config.manifestLoadingTimeOut;
retryDelay = config.manifestLoadingRetryDelay;
maxRetryDelay = config.manifestLoadingMaxRetryTimeout;
break;
case ContextType.LEVEL:
// Disable internal loader retry logic, since we are managing retries in Level Controller
maxRetry = 0;
timeout = config.levelLoadingTimeOut;
// TODO Introduce retry settings for audio-track and subtitle-track, it should not use level retry config
break;
default:
maxRetry = config.levelLoadingMaxRetry;
timeout = config.levelLoadingTimeOut;
retryDelay = config.levelLoadingRetryDelay;
maxRetryDelay = config.levelLoadingMaxRetryTimeout;
logger.log(`Playlist loader for ${context.type} ${context.level || context.id}`);
break;
}
loader = this.createInternalLoader(context);
context.url = url;
context.responseType = context.responseType || ''; // FIXME: (should not be necessary to do this)
let loaderConfig, loaderCallbacks;
loaderConfig = {
timeout,
maxRetry,
retryDelay,
maxRetryDelay
};
loaderCallbacks = {
onSuccess: this.loadsuccess.bind(this),
onError: this.loaderror.bind(this),
onTimeout: this.loadtimeout.bind(this)
};
loader.load(context, loaderConfig, loaderCallbacks);
return true;
}
loadsuccess (response, stats, context, networkDetails = null) {
if (context.isSidxRequest) {
this._handleSidxRequest(response, context);
this._handlePlaylistLoaded(response, stats, context, networkDetails);
return;
}
this.resetInternalLoader(context.type);
const string = response.data;
stats.tload = performance.now();
// stats.mtime = new Date(target.getResponseHeader('Last-Modified'));
// Validate if it is an M3U8 at all
if (string.indexOf('#EXTM3U') !== 0) {
this._handleManifestParsingError(response, context, 'no EXTM3U delimiter', networkDetails);
return;
}
// 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);
}
loaderror (response, context, networkDetails = null) {
this._handleNetworkError(context, networkDetails);
}
loadtimeout (stats, context, networkDetails = null) {
this._handleNetworkError(context, networkDetails, true);
}
_handleMasterPlaylist (response, stats, context, networkDetails) {
const hls = this.hls;
const string = response.data;
const url = PlaylistLoader.getResponseUrl(response, context);
const levels = 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 => ({
id: level.attrs.AUDIO,
codec: level.audioCodec
}));
let audioTracks = M3U8Parser.parseMasterPlaylistMedia(string, url, 'AUDIO', audioGroups);
let subtitles = M3U8Parser.parseMasterPlaylistMedia(string, url, 'SUBTITLES');
if (audioTracks.length) {
// check if we have found an audio track embedded in main playlist (audio track without URI attribute)
let embeddedAudioFound = false;
audioTracks.forEach(audioTrack => {
if (!audioTrack.url)
embeddedAudioFound = true;
});
// 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 === false && levels[0].audioCodec && !levels[0].attrs.AUDIO) {
logger.log('audio codec signaled in quality level, but no embedded audio track signaled, create one');
audioTracks.unshift({
type: 'main',
name: 'main'
});
}
}
hls.trigger(Event.MANIFEST_LOADED, {
levels,
audioTracks,
subtitles,
url,
stats,
networkDetails
});
}
_handleTrackOrLevelPlaylist (response, stats, context, networkDetails) {
const hls = this.hls;
const { id, level, type } = context;
const url = PlaylistLoader.getResponseUrl(response, context);
const levelId = !isNaN(level) ? level : !isNaN(id) ? id : 0; // level -> id -> 0
const levelType = PlaylistLoader.mapContextToLevelType(context);
const levelDetails = M3U8Parser.parseLevelPlaylist(response.data, url, levelId, levelType);
// set stats on level structure
levelDetails.tload = stats.tload;
// 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 === ContextType.MANIFEST) {
const singleLevel = {
url,
details: levelDetails
};
hls.trigger(Event.MANIFEST_LOADED, {
levels: [singleLevel],
audioTracks: [],
url,
stats,
networkDetails
});
}
// save parsing time
stats.tparsed = performance.now();
// in case we need SIDX ranges
// return early after calling load for
// the SIDX box.
if (levelDetails.needSidxRanges) {
const sidxUrl = levelDetails.initSegment.url;
this.load(sidxUrl, {
isSidxRequest: true,
type,
level,
levelDetails,
id,
rangeStart: 0,
rangeEnd: 2048,
responseType: 'arraybuffer'
});
return;
}
// extend the context with the new levelDetails property
context.levelDetails = levelDetails;
this._handlePlaylistLoaded(response, stats, context, networkDetails);
}
_handleSidxRequest (response, context) {
const sidxInfo = MP4Demuxer.parseSegmentIndex(new Uint8Array(response.data));
sidxInfo.references.forEach((segmentRef, index) => {
const segRefInfo = segmentRef.info;
const frag = context.levelDetails.fragments[index];
if (frag.byteRange.length === 0)
frag.rawByteRange = String(1 + segRefInfo.end - segRefInfo.start) + '@' + String(segRefInfo.start);
});
context.levelDetails.initSegment.rawByteRange = String(sidxInfo.moovEndOffset) + '@0';
}
_handleManifestParsingError (response, context, reason, networkDetails) {
this.hls.trigger(Event.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.MANIFEST_PARSING_ERROR,
fatal: true,
url: response.url,
reason,
networkDetails
});
}
_handleNetworkError (context, networkDetails, timeout = false) {
let details;
let fatal;
const loader = this.getInternalLoader(context);
switch (context.type) {
case ContextType.MANIFEST:
details = (timeout ? ErrorDetails.MANIFEST_LOAD_TIMEOUT : ErrorDetails.MANIFEST_LOAD_ERROR);
fatal = true;
break;
case ContextType.LEVEL:
details = (timeout ? ErrorDetails.LEVEL_LOAD_TIMEOUT : ErrorDetails.LEVEL_LOAD_ERROR);
fatal = false;
break;
case ContextType.AUDIO_TRACK:
details = (timeout ? ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT : ErrorDetails.AUDIO_TRACK_LOAD_ERROR);
fatal = false;
break;
default:
// details = ...?
fatal = false;
}
if (loader) {
loader.abort();
this.resetInternalLoader(context.type);
}
this.hls.trigger(Event.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details,
fatal,
url: loader.url,
loader,
context,
networkDetails
});
}
_handlePlaylistLoaded (response, stats, context, networkDetails) {
const { type, level, id, levelDetails } = context;
if (!levelDetails.targetduration) {
this._handleManifestParsingError(response, context, 'invalid target duration', networkDetails);
return;
}
const canHaveLevels = PlaylistLoader.canHaveQualityLevels(context.type);
if (canHaveLevels) {
this.hls.trigger(Event.LEVEL_LOADED, {
details: levelDetails,
level: level || 0,
id: id || 0,
stats,
networkDetails
});
} else {
switch (type) {
case ContextType.AUDIO_TRACK:
this.hls.trigger(Event.AUDIO_TRACK_LOADED, {
details: levelDetails,
id,
stats,
networkDetails
});
break;
case ContextType.SUBTITLE_TRACK:
this.hls.trigger(Event.SUBTITLE_TRACK_LOADED, {
details: levelDetails,
id,
stats,
networkDetails
});
break;
}
}
}
}
export default PlaylistLoader;