276 lines
10 KiB
JavaScript
276 lines
10 KiB
JavaScript
/**
|
|
* Playlist Loader
|
|
*/
|
|
|
|
import Event from '../events';
|
|
import {ErrorTypes, ErrorDetails} from '../errors';
|
|
import URLHelper from '../utils/url';
|
|
//import {logger} from '../utils/logger';
|
|
|
|
class PlaylistLoader {
|
|
|
|
constructor(hls) {
|
|
this.hls = hls;
|
|
this.onml = this.onManifestLoading.bind(this);
|
|
this.onll = this.onLevelLoading.bind(this);
|
|
hls.on(Event.MANIFEST_LOADING, this.onml);
|
|
hls.on(Event.LEVEL_LOADING, this.onll);
|
|
}
|
|
|
|
destroy() {
|
|
if (this.loader) {
|
|
this.loader.destroy();
|
|
this.loader = null;
|
|
}
|
|
this.url = this.id = null;
|
|
this.hls.off(Event.MANIFEST_LOADING, this.onml);
|
|
this.hls.off(Event.LEVEL_LOADING, this.onll);
|
|
}
|
|
|
|
onManifestLoading(event, data) {
|
|
this.load(data.url, null);
|
|
}
|
|
|
|
onLevelLoading(event, data) {
|
|
this.load(data.url, data.level, data.id);
|
|
}
|
|
|
|
load(url, id1, id2) {
|
|
var config = this.hls.config;
|
|
this.url = url;
|
|
this.id = id1;
|
|
this.id2 = id2;
|
|
this.loader = typeof(config.pLoader) !== 'undefined' ? new config.pLoader(config) : new config.loader(config);
|
|
this.loader.load(url, '', this.loadsuccess.bind(this), this.loaderror.bind(this), this.loadtimeout.bind(this), config.manifestLoadingTimeOut, config.manifestLoadingMaxRetry, config.manifestLoadingRetryDelay);
|
|
}
|
|
|
|
resolve(url, baseUrl) {
|
|
return URLHelper.buildAbsoluteURL(baseUrl, url);
|
|
}
|
|
|
|
parseMasterPlaylist(string, baseurl) {
|
|
var levels = [], level = {}, result, codecs, codec;
|
|
// https://regex101.com is your friend
|
|
var re = /#EXT-X-STREAM-INF:([^\n\r]*(BAND)WIDTH=(\d+))?([^\n\r]*(CODECS)=\"([^\"\n\r]*)\",?)?([^\n\r]*(RES)OLUTION=(\d+)x(\d+))?([^\n\r]*(NAME)=\"(.*)\")?[^\n\r]*[\r\n]+([^\r\n]+)/g;
|
|
while ((result = re.exec(string)) != null){
|
|
result.shift();
|
|
result = result.filter(function(n) { return (n !== undefined); });
|
|
level.url = this.resolve(result.pop(), baseurl);
|
|
while (result.length > 0) {
|
|
switch (result.shift()) {
|
|
case 'RES':
|
|
level.width = parseInt(result.shift());
|
|
level.height = parseInt(result.shift());
|
|
break;
|
|
case 'BAND':
|
|
level.bitrate = parseInt(result.shift());
|
|
break;
|
|
case 'NAME':
|
|
level.name = result.shift();
|
|
break;
|
|
case 'CODECS':
|
|
codecs = result.shift().split(',');
|
|
while (codecs.length > 0) {
|
|
codec = codecs.shift();
|
|
if (codec.indexOf('avc1') !== -1) {
|
|
level.videoCodec = this.avc1toavcoti(codec);
|
|
} else {
|
|
level.audioCodec = codec;
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
levels.push(level);
|
|
level = {};
|
|
}
|
|
return levels;
|
|
}
|
|
|
|
avc1toavcoti(codec) {
|
|
var result, avcdata = codec.split('.');
|
|
if (avcdata.length > 2) {
|
|
result = avcdata.shift() + '.';
|
|
result += parseInt(avcdata.shift()).toString(16);
|
|
result += ('00' + parseInt(avcdata.shift()).toString(16)).substr(-4);
|
|
} else {
|
|
result = codec;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
parseKeyParamsByRegex(string, regexp) {
|
|
var result = regexp.exec(string);
|
|
if (result) {
|
|
result.shift();
|
|
result = result.filter(function(n) { return (n !== undefined); });
|
|
if (result.length === 2) {
|
|
return result[1];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
cloneObj(obj) {
|
|
return JSON.parse(JSON.stringify(obj));
|
|
}
|
|
|
|
parseLevelPlaylist(string, baseurl, id) {
|
|
var currentSN = 0, totalduration = 0, level = {url: baseurl, fragments: [], live: true, startSN: 0}, result, regexp, cc = 0, frag, byteRangeEndOffset, byteRangeStartOffset;
|
|
var levelkey = {method : null, key : null, iv : null, uri : null};
|
|
regexp = /(?:#EXT-X-(MEDIA-SEQUENCE):(\d+))|(?:#EXT-X-(TARGETDURATION):(\d+))|(?:#EXT-X-(KEY):(.*))|(?:#EXT(INF):([\d\.]+)[^\r\n]*([\r\n]+[^#|\r\n]+)?)|(?:#EXT-X-(BYTERANGE):([\d]+[@[\d]*)]*[\r\n]+([^#|\r\n]+)?|(?:#EXT-X-(ENDLIST))|(?:#EXT-X-(DIS)CONTINUITY))/g;
|
|
while ((result = regexp.exec(string)) !== null) {
|
|
result.shift();
|
|
result = result.filter(function(n) { return (n !== undefined); });
|
|
switch (result[0]) {
|
|
case 'MEDIA-SEQUENCE':
|
|
currentSN = level.startSN = parseInt(result[1]);
|
|
break;
|
|
case 'TARGETDURATION':
|
|
level.targetduration = parseFloat(result[1]);
|
|
break;
|
|
case 'ENDLIST':
|
|
level.live = false;
|
|
break;
|
|
case 'DIS':
|
|
cc++;
|
|
break;
|
|
case 'BYTERANGE':
|
|
var params = result[1].split('@');
|
|
if (params.length === 1) {
|
|
byteRangeStartOffset = byteRangeEndOffset;
|
|
} else {
|
|
byteRangeStartOffset = parseInt(params[1]);
|
|
}
|
|
byteRangeEndOffset = parseInt(params[0]) + byteRangeStartOffset;
|
|
frag = level.fragments.length ? level.fragments[level.fragments.length - 1] : null;
|
|
if (frag && !frag.url) {
|
|
frag.byteRangeStartOffset = byteRangeStartOffset;
|
|
frag.byteRangeEndOffset = byteRangeEndOffset;
|
|
frag.url = this.resolve(result[2], baseurl);
|
|
}
|
|
break;
|
|
case 'INF':
|
|
var duration = parseFloat(result[1]);
|
|
if (!isNaN(duration)) {
|
|
var fragdecryptdata,
|
|
sn = currentSN++;
|
|
if (levelkey.method && levelkey.uri && !levelkey.iv) {
|
|
fragdecryptdata = this.cloneObj(levelkey);
|
|
var uint8View = new Uint8Array(16);
|
|
for (var i = 12; i < 16; i++) {
|
|
uint8View[i] = (sn >> 8*(15-i)) & 0xff;
|
|
}
|
|
fragdecryptdata.iv = uint8View;
|
|
} else {
|
|
fragdecryptdata = levelkey;
|
|
}
|
|
level.fragments.push({url: result[2] ? this.resolve(result[2], baseurl) : null, duration: duration, start: totalduration, sn: sn, level: id, cc: cc, byteRangeStartOffset: byteRangeStartOffset, byteRangeEndOffset: byteRangeEndOffset, decryptdata : fragdecryptdata});
|
|
totalduration += duration;
|
|
byteRangeStartOffset = null;
|
|
}
|
|
break;
|
|
case 'KEY':
|
|
// https://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.4.4
|
|
var decryptparams = result[1];
|
|
var decryptmethod = this.parseKeyParamsByRegex(decryptparams, /(METHOD)=([^,]*)/),
|
|
decrypturi = this.parseKeyParamsByRegex(decryptparams, /(URI)=["]([^,]*)["]/),
|
|
decryptiv = this.parseKeyParamsByRegex(decryptparams, /(IV)=([^,]*)/);
|
|
if (decryptmethod) {
|
|
levelkey = { method: null, key: null, iv: null, uri: null };
|
|
if ((decrypturi) && (decryptmethod === 'AES-128')) {
|
|
levelkey.method = decryptmethod;
|
|
// URI to get the key
|
|
levelkey.uri = this.resolve(decrypturi, baseurl);
|
|
levelkey.key = null;
|
|
// Initialization Vector (IV)
|
|
if (decryptiv) {
|
|
levelkey.iv = decryptiv;
|
|
if (levelkey.iv.substring(0, 2) === '0x') {
|
|
levelkey.iv = levelkey.iv.substring(2);
|
|
}
|
|
levelkey.iv = levelkey.iv.match(/.{8}/g);
|
|
levelkey.iv[0] = parseInt(levelkey.iv[0], 16);
|
|
levelkey.iv[1] = parseInt(levelkey.iv[1], 16);
|
|
levelkey.iv[2] = parseInt(levelkey.iv[2], 16);
|
|
levelkey.iv[3] = parseInt(levelkey.iv[3], 16);
|
|
levelkey.iv = new Uint32Array(levelkey.iv);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
//logger.log('found ' + level.fragments.length + ' fragments');
|
|
level.totalduration = totalduration;
|
|
level.endSN = currentSN - 1;
|
|
return level;
|
|
}
|
|
|
|
loadsuccess(event, stats) {
|
|
var string = event.currentTarget.responseText, url = event.currentTarget.responseURL, id = this.id, id2 = this.id2, hls = this.hls, levels;
|
|
// responseURL not supported on some browsers (it is used to detect URL redirection)
|
|
if (url === undefined) {
|
|
// fallback to initial URL
|
|
url = this.url;
|
|
}
|
|
stats.tload = performance.now();
|
|
stats.mtime = new Date(event.currentTarget.getResponseHeader('Last-Modified'));
|
|
if (string.indexOf('#EXTM3U') === 0) {
|
|
if (string.indexOf('#EXTINF:') > 0) {
|
|
// 1 level playlist
|
|
// if first request, fire manifest loaded event, level will be reloaded afterwards
|
|
// (this is to have a uniform logic for 1 level/multilevel playlists)
|
|
if (this.id === null) {
|
|
hls.trigger(Event.MANIFEST_LOADED, {levels: [{url: url}], url: url, stats: stats});
|
|
} else {
|
|
var levelDetails = this.parseLevelPlaylist(string, url, id);
|
|
stats.tparsed = performance.now();
|
|
hls.trigger(Event.LEVEL_LOADED, {details: levelDetails, level: id, id: id2, stats: stats});
|
|
}
|
|
} else {
|
|
levels = this.parseMasterPlaylist(string, url);
|
|
// multi level playlist, parse level info
|
|
if (levels.length) {
|
|
hls.trigger(Event.MANIFEST_LOADED, {levels: levels, url: url, stats: stats});
|
|
} else {
|
|
hls.trigger(Event.ERROR, {type: ErrorTypes.NETWORK_ERROR, details: ErrorDetails.MANIFEST_PARSING_ERROR, fatal: true, url: url, reason: 'no level found in manifest'});
|
|
}
|
|
}
|
|
} else {
|
|
hls.trigger(Event.ERROR, {type: ErrorTypes.NETWORK_ERROR, details: ErrorDetails.MANIFEST_PARSING_ERROR, fatal: true, url: url, reason: 'no EXTM3U delimiter'});
|
|
}
|
|
}
|
|
|
|
loaderror(event) {
|
|
var details, fatal;
|
|
if (this.id === null) {
|
|
details = ErrorDetails.MANIFEST_LOAD_ERROR;
|
|
fatal = true;
|
|
} else {
|
|
details = ErrorDetails.LEVEL_LOAD_ERROR;
|
|
fatal = false;
|
|
}
|
|
this.loader.abort();
|
|
this.hls.trigger(Event.ERROR, {type: ErrorTypes.NETWORK_ERROR, details: details, fatal: fatal, url: this.url, loader: this.loader, response: event.currentTarget, level: this.id, id: this.id2});
|
|
}
|
|
|
|
loadtimeout() {
|
|
var details, fatal;
|
|
if (this.id === null) {
|
|
details = ErrorDetails.MANIFEST_LOAD_TIMEOUT;
|
|
fatal = true;
|
|
} else {
|
|
details = ErrorDetails.LEVEL_LOAD_TIMEOUT;
|
|
fatal = false;
|
|
}
|
|
this.loader.abort();
|
|
this.hls.trigger(Event.ERROR, {type: ErrorTypes.NETWORK_ERROR, details: details, fatal: fatal, url: this.url, loader: this.loader, level: this.id, id: this.id2});
|
|
}
|
|
}
|
|
|
|
export default PlaylistLoader;
|