mirror of https://bitbucket.org/ausocean/av.git
371 lines
13 KiB
JavaScript
371 lines
13 KiB
JavaScript
|
import * as URLToolkit from 'url-toolkit';
|
||
|
|
||
|
import Fragment from './fragment';
|
||
|
import Level from './level';
|
||
|
import LevelKey from './level-key';
|
||
|
|
||
|
import AttrList from '../utils/attr-list';
|
||
|
import { logger } from '../utils/logger';
|
||
|
import { isCodecType, CodecType } from '../utils/codecs';
|
||
|
import { MediaPlaylist, AudioGroup, MediaPlaylistType } from '../types/media-playlist';
|
||
|
import { PlaylistLevelType } from '../types/loader';
|
||
|
|
||
|
/**
|
||
|
* M3U8 parser
|
||
|
* @module
|
||
|
*/
|
||
|
|
||
|
// https://regex101.com is your friend
|
||
|
const MASTER_PLAYLIST_REGEX = /#EXT-X-STREAM-INF:([^\n\r]*)[\r\n]+([^\r\n]+)/g;
|
||
|
const MASTER_PLAYLIST_MEDIA_REGEX = /#EXT-X-MEDIA:(.*)/g;
|
||
|
|
||
|
const LEVEL_PLAYLIST_REGEX_FAST = new RegExp([
|
||
|
/#EXTINF:\s*(\d*(?:\.\d+)?)(?:,(.*)\s+)?/.source, // duration (#EXTINF:<duration>,<title>), group 1 => duration, group 2 => title
|
||
|
/|(?!#)([\S+ ?]+)/.source, // segment URI, group 3 => the URI (note newline is not eaten)
|
||
|
/|#EXT-X-BYTERANGE:*(.+)/.source, // next segment's byterange, group 4 => range spec (x@y)
|
||
|
/|#EXT-X-PROGRAM-DATE-TIME:(.+)/.source, // next segment's program date/time group 5 => the datetime spec
|
||
|
/|#.*/.source // All other non-segment oriented tags will match with all groups empty
|
||
|
].join(''), 'g');
|
||
|
|
||
|
const LEVEL_PLAYLIST_REGEX_SLOW = /(?:(?:#(EXTM3U))|(?:#EXT-X-(PLAYLIST-TYPE):(.+))|(?:#EXT-X-(MEDIA-SEQUENCE): *(\d+))|(?:#EXT-X-(TARGETDURATION): *(\d+))|(?:#EXT-X-(KEY):(.+))|(?:#EXT-X-(START):(.+))|(?:#EXT-X-(ENDLIST))|(?:#EXT-X-(DISCONTINUITY-SEQ)UENCE:(\d+))|(?:#EXT-X-(DIS)CONTINUITY))|(?:#EXT-X-(VERSION):(\d+))|(?:#EXT-X-(MAP):(.+))|(?:(#)([^:]*):(.*))|(?:(#)(.*))(?:.*)\r?\n?/;
|
||
|
|
||
|
const MP4_REGEX_SUFFIX = /\.(mp4|m4s|m4v|m4a)$/i;
|
||
|
|
||
|
export default class M3U8Parser {
|
||
|
static findGroup (groups: Array<AudioGroup>, mediaGroupId: string): AudioGroup | undefined {
|
||
|
for (let i = 0; i < groups.length; i++) {
|
||
|
const group = groups[i];
|
||
|
if (group.id === mediaGroupId) {
|
||
|
return group;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
static convertAVC1ToAVCOTI (codec) {
|
||
|
let avcdata = codec.split('.');
|
||
|
let result;
|
||
|
if (avcdata.length > 2) {
|
||
|
result = avcdata.shift() + '.';
|
||
|
result += parseInt(avcdata.shift()).toString(16);
|
||
|
result += ('000' + parseInt(avcdata.shift()).toString(16)).substr(-4);
|
||
|
} else {
|
||
|
result = codec;
|
||
|
}
|
||
|
return result;
|
||
|
}
|
||
|
|
||
|
static resolve (url, baseUrl) {
|
||
|
return URLToolkit.buildAbsoluteURL(baseUrl, url, { alwaysNormalize: true });
|
||
|
}
|
||
|
|
||
|
static parseMasterPlaylist (string: string, baseurl: string) {
|
||
|
// TODO(typescript-level)
|
||
|
let levels: Array<any> = [];
|
||
|
MASTER_PLAYLIST_REGEX.lastIndex = 0;
|
||
|
|
||
|
// TODO(typescript-level)
|
||
|
function setCodecs (codecs: Array<string>, level: any) {
|
||
|
['video', 'audio'].forEach((type: CodecType) => {
|
||
|
const filtered = codecs.filter((codec) => isCodecType(codec, type));
|
||
|
if (filtered.length) {
|
||
|
const preferred = filtered.filter((codec) => {
|
||
|
return codec.lastIndexOf('avc1', 0) === 0 || codec.lastIndexOf('mp4a', 0) === 0;
|
||
|
});
|
||
|
level[`${type}Codec`] = preferred.length > 0 ? preferred[0] : filtered[0];
|
||
|
|
||
|
// remove from list
|
||
|
codecs = codecs.filter((codec) => filtered.indexOf(codec) === -1);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
level.unknownCodecs = codecs;
|
||
|
}
|
||
|
|
||
|
let result: RegExpExecArray | null;
|
||
|
while ((result = MASTER_PLAYLIST_REGEX.exec(string)) != null) {
|
||
|
// TODO(typescript-level)
|
||
|
const level: any = {};
|
||
|
|
||
|
const attrs = level.attrs = new AttrList(result[1]);
|
||
|
level.url = M3U8Parser.resolve(result[2], baseurl);
|
||
|
|
||
|
const resolution = attrs.decimalResolution('RESOLUTION');
|
||
|
if (resolution) {
|
||
|
level.width = resolution.width;
|
||
|
level.height = resolution.height;
|
||
|
}
|
||
|
level.bitrate = attrs.decimalInteger('AVERAGE-BANDWIDTH') || attrs.decimalInteger('BANDWIDTH');
|
||
|
level.name = attrs.NAME;
|
||
|
|
||
|
setCodecs([].concat((attrs.CODECS || '').split(/[ ,]+/)), level);
|
||
|
|
||
|
if (level.videoCodec && level.videoCodec.indexOf('avc1') !== -1) {
|
||
|
level.videoCodec = M3U8Parser.convertAVC1ToAVCOTI(level.videoCodec);
|
||
|
}
|
||
|
|
||
|
levels.push(level);
|
||
|
}
|
||
|
return levels;
|
||
|
}
|
||
|
|
||
|
static parseMasterPlaylistMedia (string: string, baseurl: string, type: MediaPlaylistType, audioGroups: Array<AudioGroup> = []): Array<MediaPlaylist> {
|
||
|
let result: RegExpExecArray | null;
|
||
|
let medias: Array<MediaPlaylist> = [];
|
||
|
let id = 0;
|
||
|
MASTER_PLAYLIST_MEDIA_REGEX.lastIndex = 0;
|
||
|
while ((result = MASTER_PLAYLIST_MEDIA_REGEX.exec(string)) !== null) {
|
||
|
const attrs = new AttrList(result[1]);
|
||
|
if (attrs.TYPE === type) {
|
||
|
const media: MediaPlaylist = {
|
||
|
id: id++,
|
||
|
groupId: attrs['GROUP-ID'],
|
||
|
name: attrs.NAME || attrs.LANGUAGE,
|
||
|
type,
|
||
|
default: (attrs.DEFAULT === 'YES'),
|
||
|
autoselect: (attrs.AUTOSELECT === 'YES'),
|
||
|
forced: (attrs.FORCED === 'YES'),
|
||
|
lang: attrs.LANGUAGE
|
||
|
};
|
||
|
|
||
|
if (attrs.URI) {
|
||
|
media.url = M3U8Parser.resolve(attrs.URI, baseurl);
|
||
|
}
|
||
|
|
||
|
if (audioGroups.length) {
|
||
|
// If there are audio groups signalled in the manifest, let's look for a matching codec string for this track
|
||
|
const groupCodec = M3U8Parser.findGroup(audioGroups, media.groupId);
|
||
|
|
||
|
// If we don't find the track signalled, lets use the first audio groups codec we have
|
||
|
// Acting as a best guess
|
||
|
media.audioCodec = groupCodec ? groupCodec.codec : audioGroups[0].codec;
|
||
|
}
|
||
|
|
||
|
medias.push(media);
|
||
|
}
|
||
|
}
|
||
|
return medias;
|
||
|
}
|
||
|
|
||
|
static parseLevelPlaylist (string: string, baseurl: string, id: number, type: PlaylistLevelType, levelUrlId: number) {
|
||
|
let currentSN = 0;
|
||
|
let totalduration = 0;
|
||
|
let level = new Level(baseurl);
|
||
|
let discontinuityCounter = 0;
|
||
|
let prevFrag: Fragment | null = null;
|
||
|
let frag: Fragment | null = new Fragment();
|
||
|
let result: RegExpExecArray | RegExpMatchArray | null;
|
||
|
let i: number;
|
||
|
let levelkey: LevelKey | undefined;
|
||
|
|
||
|
let firstPdtIndex = null;
|
||
|
|
||
|
LEVEL_PLAYLIST_REGEX_FAST.lastIndex = 0;
|
||
|
|
||
|
while ((result = LEVEL_PLAYLIST_REGEX_FAST.exec(string)) !== null) {
|
||
|
const duration = result[1];
|
||
|
if (duration) { // INF
|
||
|
frag.duration = parseFloat(duration);
|
||
|
// avoid sliced strings https://github.com/video-dev/hls.js/issues/939
|
||
|
const title = (' ' + result[2]).slice(1);
|
||
|
frag.title = title || null;
|
||
|
frag.tagList.push(title ? [ 'INF', duration, title ] : [ 'INF', duration ]);
|
||
|
} else if (result[3]) { // url
|
||
|
if (Number.isFinite(frag.duration)) {
|
||
|
const sn = currentSN++;
|
||
|
frag.type = type;
|
||
|
frag.start = totalduration;
|
||
|
if (levelkey) {
|
||
|
frag.levelkey = levelkey;
|
||
|
}
|
||
|
frag.sn = sn;
|
||
|
frag.level = id;
|
||
|
frag.cc = discontinuityCounter;
|
||
|
frag.urlId = levelUrlId;
|
||
|
frag.baseurl = baseurl;
|
||
|
// avoid sliced strings https://github.com/video-dev/hls.js/issues/939
|
||
|
frag.relurl = (' ' + result[3]).slice(1);
|
||
|
assignProgramDateTime(frag, prevFrag);
|
||
|
|
||
|
level.fragments.push(frag);
|
||
|
prevFrag = frag;
|
||
|
totalduration += frag.duration;
|
||
|
|
||
|
frag = new Fragment();
|
||
|
}
|
||
|
} else if (result[4]) { // X-BYTERANGE
|
||
|
const data = (' ' + result[4]).slice(1);
|
||
|
if (prevFrag) {
|
||
|
frag.setByteRange(data, prevFrag);
|
||
|
} else {
|
||
|
frag.setByteRange(data);
|
||
|
}
|
||
|
} else if (result[5]) { // PROGRAM-DATE-TIME
|
||
|
// avoid sliced strings https://github.com/video-dev/hls.js/issues/939
|
||
|
frag.rawProgramDateTime = (' ' + result[5]).slice(1);
|
||
|
frag.tagList.push(['PROGRAM-DATE-TIME', frag.rawProgramDateTime]);
|
||
|
if (firstPdtIndex === null) {
|
||
|
firstPdtIndex = level.fragments.length;
|
||
|
}
|
||
|
} else {
|
||
|
result = result[0].match(LEVEL_PLAYLIST_REGEX_SLOW);
|
||
|
if (!result) {
|
||
|
logger.warn('No matches on slow regex match for level playlist!');
|
||
|
continue;
|
||
|
}
|
||
|
for (i = 1; i < result.length; i++) {
|
||
|
if (typeof result[i] !== 'undefined') {
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// avoid sliced strings https://github.com/video-dev/hls.js/issues/939
|
||
|
const value1 = (' ' + result[i + 1]).slice(1);
|
||
|
const value2 = (' ' + result[i + 2]).slice(1);
|
||
|
|
||
|
switch (result[i]) {
|
||
|
case '#':
|
||
|
frag.tagList.push(value2 ? [ value1, value2 ] : [ value1 ]);
|
||
|
break;
|
||
|
case 'PLAYLIST-TYPE':
|
||
|
level.type = value1.toUpperCase();
|
||
|
break;
|
||
|
case 'MEDIA-SEQUENCE':
|
||
|
currentSN = level.startSN = parseInt(value1);
|
||
|
break;
|
||
|
case 'TARGETDURATION':
|
||
|
level.targetduration = parseFloat(value1);
|
||
|
break;
|
||
|
case 'VERSION':
|
||
|
level.version = parseInt(value1);
|
||
|
break;
|
||
|
case 'EXTM3U':
|
||
|
break;
|
||
|
case 'ENDLIST':
|
||
|
level.live = false;
|
||
|
break;
|
||
|
case 'DIS':
|
||
|
discontinuityCounter++;
|
||
|
frag.tagList.push(['DIS']);
|
||
|
break;
|
||
|
case 'DISCONTINUITY-SEQ':
|
||
|
discontinuityCounter = parseInt(value1);
|
||
|
break;
|
||
|
case 'KEY': {
|
||
|
// https://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.4.4
|
||
|
const decryptparams = value1;
|
||
|
const keyAttrs = new AttrList(decryptparams);
|
||
|
const decryptmethod = keyAttrs.enumeratedString('METHOD');
|
||
|
const decrypturi = keyAttrs.URI;
|
||
|
const decryptiv = keyAttrs.hexadecimalInteger('IV');
|
||
|
|
||
|
if (decryptmethod) {
|
||
|
levelkey = new LevelKey(baseurl, decrypturi);
|
||
|
if ((decrypturi) && (['AES-128', 'SAMPLE-AES', 'SAMPLE-AES-CENC'].indexOf(decryptmethod) >= 0)) {
|
||
|
levelkey.method = decryptmethod;
|
||
|
levelkey.key = null;
|
||
|
// Initialization Vector (IV)
|
||
|
levelkey.iv = decryptiv;
|
||
|
}
|
||
|
}
|
||
|
break;
|
||
|
}
|
||
|
case 'START': {
|
||
|
const startAttrs = new AttrList(value1);
|
||
|
const startTimeOffset = startAttrs.decimalFloatingPoint('TIME-OFFSET');
|
||
|
// TIME-OFFSET can be 0
|
||
|
if (Number.isFinite(startTimeOffset)) {
|
||
|
level.startTimeOffset = startTimeOffset;
|
||
|
}
|
||
|
break;
|
||
|
}
|
||
|
case 'MAP': {
|
||
|
const mapAttrs = new AttrList(value1);
|
||
|
frag.relurl = mapAttrs.URI;
|
||
|
if (mapAttrs.BYTERANGE) {
|
||
|
frag.setByteRange(mapAttrs.BYTERANGE);
|
||
|
}
|
||
|
frag.baseurl = baseurl;
|
||
|
frag.level = id;
|
||
|
frag.type = type;
|
||
|
frag.sn = 'initSegment';
|
||
|
level.initSegment = frag;
|
||
|
frag = new Fragment();
|
||
|
frag.rawProgramDateTime = level.initSegment.rawProgramDateTime;
|
||
|
break;
|
||
|
}
|
||
|
default:
|
||
|
logger.warn(`line parsed but not handled: ${result}`);
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
frag = prevFrag;
|
||
|
// logger.log('found ' + level.fragments.length + ' fragments');
|
||
|
if (frag && !frag.relurl) {
|
||
|
level.fragments.pop();
|
||
|
totalduration -= frag.duration;
|
||
|
}
|
||
|
level.totalduration = totalduration;
|
||
|
level.averagetargetduration = totalduration / level.fragments.length;
|
||
|
level.endSN = currentSN - 1;
|
||
|
level.startCC = level.fragments[0] ? level.fragments[0].cc : 0;
|
||
|
level.endCC = discontinuityCounter;
|
||
|
|
||
|
if (!level.initSegment && level.fragments.length) {
|
||
|
// this is a bit lurky but HLS really has no other way to tell us
|
||
|
// if the fragments are TS or MP4, except if we download them :/
|
||
|
// but this is to be able to handle SIDX.
|
||
|
if (level.fragments.every((frag) => MP4_REGEX_SUFFIX.test(frag.relurl))) {
|
||
|
logger.warn('MP4 fragments found but no init segment (probably no MAP, incomplete M3U8), trying to fetch SIDX');
|
||
|
|
||
|
frag = new Fragment();
|
||
|
frag.relurl = level.fragments[0].relurl;
|
||
|
frag.baseurl = baseurl;
|
||
|
frag.level = id;
|
||
|
frag.type = type;
|
||
|
frag.sn = 'initSegment';
|
||
|
|
||
|
level.initSegment = frag;
|
||
|
level.needSidxRanges = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Backfill any missing PDT values
|
||
|
"If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after
|
||
|
one or more Media Segment URIs, the client SHOULD extrapolate
|
||
|
backward from that tag (using EXTINF durations and/or media
|
||
|
timestamps) to associate dates with those segments."
|
||
|
* We have already extrapolated forward, but all fragments up to the first instance of PDT do not have their PDTs
|
||
|
* computed.
|
||
|
*/
|
||
|
if (firstPdtIndex) {
|
||
|
backfillProgramDateTimes(level.fragments, firstPdtIndex);
|
||
|
}
|
||
|
|
||
|
return level;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function backfillProgramDateTimes (fragments, startIndex) {
|
||
|
let fragPrev = fragments[startIndex];
|
||
|
for (let i = startIndex - 1; i >= 0; i--) {
|
||
|
const frag = fragments[i];
|
||
|
frag.programDateTime = fragPrev.programDateTime - (frag.duration * 1000);
|
||
|
fragPrev = frag;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function assignProgramDateTime (frag, prevFrag) {
|
||
|
if (frag.rawProgramDateTime) {
|
||
|
frag.programDateTime = Date.parse(frag.rawProgramDateTime);
|
||
|
} else if (prevFrag && prevFrag.programDateTime) {
|
||
|
frag.programDateTime = prevFrag.endProgramDateTime;
|
||
|
}
|
||
|
|
||
|
if (!Number.isFinite(frag.programDateTime)) {
|
||
|
frag.programDateTime = null;
|
||
|
frag.rawProgramDateTime = null;
|
||
|
}
|
||
|
}
|