02b31a5e by Brandon Casey

separate m3u8 from HLS, and depend m3u8-parser package(#734)

1 parent b6ad6cc9
......@@ -83,6 +83,7 @@
"test/"
],
"dependencies": {
"m3u8-parser": "^1.0.2",
"pkcs7": "^0.2.2",
"video.js": "^5.10.1",
"videojs-contrib-media-sources": "^3.1.0",
......
/**
* @file m3u8/index.js
*
* Utilities for parsing M3U8 files. If the entire manifest is available,
* `Parser` will create an object representation with enough detail for managing
* playback. `ParseStream` and `LineStream` are lower-level parsing primitives
* that do not assume the entirety of the manifest is ready and expose a
* ReadableStream-like interface.
*/
import LineStream from './line-stream';
import ParseStream from './parse-stream';
import Parser from './parser';
export default {
LineStream,
ParseStream,
Parser
};
/**
* @file m3u8/line-stream.js
*/
import Stream from '../stream';
/**
* A stream that buffers string input and generates a `data` event for each
* line.
*
* @class LineStream
* @extends Stream
*/
export default class LineStream extends Stream {
constructor() {
super();
this.buffer = '';
}
/**
* Add new data to be parsed.
*
* @param {String} data the text to process
*/
push(data) {
let nextNewline;
this.buffer += data;
nextNewline = this.buffer.indexOf('\n');
for (; nextNewline > -1; nextNewline = this.buffer.indexOf('\n')) {
this.trigger('data', this.buffer.substring(0, nextNewline));
this.buffer = this.buffer.substring(nextNewline + 1);
}
}
}
/**
* @file m3u8/parse-stream.js
*/
import Stream from '../stream';
/**
* "forgiving" attribute list psuedo-grammar:
* attributes -> keyvalue (',' keyvalue)*
* keyvalue -> key '=' value
* key -> [^=]*
* value -> '"' [^"]* '"' | [^,]*
*/
const attributeSeparator = function() {
let key = '[^=]*';
let value = '"[^"]*"|[^,]*';
let keyvalue = '(?:' + key + ')=(?:' + value + ')';
return new RegExp('(?:^|,)(' + keyvalue + ')');
};
/**
* Parse attributes from a line given the seperator
*
* @param {String} attributes the attibute line to parse
*/
const parseAttributes = function(attributes) {
// split the string using attributes as the separator
let attrs = attributes.split(attributeSeparator());
let i = attrs.length;
let result = {};
let attr;
while (i--) {
// filter out unmatched portions of the string
if (attrs[i] === '') {
continue;
}
// split the key and value
attr = (/([^=]*)=(.*)/).exec(attrs[i]).slice(1);
// trim whitespace and remove optional quotes around the value
attr[0] = attr[0].replace(/^\s+|\s+$/g, '');
attr[1] = attr[1].replace(/^\s+|\s+$/g, '');
attr[1] = attr[1].replace(/^['"](.*)['"]$/g, '$1');
result[attr[0]] = attr[1];
}
return result;
};
/**
* A line-level M3U8 parser event stream. It expects to receive input one
* line at a time and performs a context-free parse of its contents. A stream
* interpretation of a manifest can be useful if the manifest is expected to
* be too large to fit comfortably into memory or the entirety of the input
* is not immediately available. Otherwise, it's probably much easier to work
* with a regular `Parser` object.
*
* Produces `data` events with an object that captures the parser's
* interpretation of the input. That object has a property `tag` that is one
* of `uri`, `comment`, or `tag`. URIs only have a single additional
* property, `line`, which captures the entirety of the input without
* interpretation. Comments similarly have a single additional property
* `text` which is the input without the leading `#`.
*
* Tags always have a property `tagType` which is the lower-cased version of
* the M3U8 directive without the `#EXT` or `#EXT-X-` prefix. For instance,
* `#EXT-X-MEDIA-SEQUENCE` becomes `media-sequence` when parsed. Unrecognized
* tags are given the tag type `unknown` and a single additional property
* `data` with the remainder of the input.
*
* @class ParseStream
* @extends Stream
*/
export default class ParseStream extends Stream {
constructor() {
super();
}
/**
* Parses an additional line of input.
*
* @param {String} line a single line of an M3U8 file to parse
*/
push(line) {
let match;
let event;
// strip whitespace
line = line.replace(/^[\u0000\s]+|[\u0000\s]+$/g, '');
if (line.length === 0) {
// ignore empty lines
return;
}
// URIs
if (line[0] !== '#') {
this.trigger('data', {
type: 'uri',
uri: line
});
return;
}
// Comments
if (line.indexOf('#EXT') !== 0) {
this.trigger('data', {
type: 'comment',
text: line.slice(1)
});
return;
}
// strip off any carriage returns here so the regex matching
// doesn't have to account for them.
line = line.replace('\r', '');
// Tags
match = (/^#EXTM3U/).exec(line);
if (match) {
this.trigger('data', {
type: 'tag',
tagType: 'm3u'
});
return;
}
match = (/^#EXTINF:?([0-9\.]*)?,?(.*)?$/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'inf'
};
if (match[1]) {
event.duration = parseFloat(match[1]);
}
if (match[2]) {
event.title = match[2];
}
this.trigger('data', event);
return;
}
match = (/^#EXT-X-TARGETDURATION:?([0-9.]*)?/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'targetduration'
};
if (match[1]) {
event.duration = parseInt(match[1], 10);
}
this.trigger('data', event);
return;
}
match = (/^#ZEN-TOTAL-DURATION:?([0-9.]*)?/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'totalduration'
};
if (match[1]) {
event.duration = parseInt(match[1], 10);
}
this.trigger('data', event);
return;
}
match = (/^#EXT-X-VERSION:?([0-9.]*)?/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'version'
};
if (match[1]) {
event.version = parseInt(match[1], 10);
}
this.trigger('data', event);
return;
}
match = (/^#EXT-X-MEDIA-SEQUENCE:?(\-?[0-9.]*)?/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'media-sequence'
};
if (match[1]) {
event.number = parseInt(match[1], 10);
}
this.trigger('data', event);
return;
}
match = (/^#EXT-X-DISCONTINUITY-SEQUENCE:?(\-?[0-9.]*)?/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'discontinuity-sequence'
};
if (match[1]) {
event.number = parseInt(match[1], 10);
}
this.trigger('data', event);
return;
}
match = (/^#EXT-X-PLAYLIST-TYPE:?(.*)?$/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'playlist-type'
};
if (match[1]) {
event.playlistType = match[1];
}
this.trigger('data', event);
return;
}
match = (/^#EXT-X-BYTERANGE:?([0-9.]*)?@?([0-9.]*)?/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'byterange'
};
if (match[1]) {
event.length = parseInt(match[1], 10);
}
if (match[2]) {
event.offset = parseInt(match[2], 10);
}
this.trigger('data', event);
return;
}
match = (/^#EXT-X-ALLOW-CACHE:?(YES|NO)?/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'allow-cache'
};
if (match[1]) {
event.allowed = !(/NO/).test(match[1]);
}
this.trigger('data', event);
return;
}
match = (/^#EXT-X-STREAM-INF:?(.*)$/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'stream-inf'
};
if (match[1]) {
event.attributes = parseAttributes(match[1]);
if (event.attributes.RESOLUTION) {
let split = event.attributes.RESOLUTION.split('x');
let resolution = {};
if (split[0]) {
resolution.width = parseInt(split[0], 10);
}
if (split[1]) {
resolution.height = parseInt(split[1], 10);
}
event.attributes.RESOLUTION = resolution;
}
if (event.attributes.BANDWIDTH) {
event.attributes.BANDWIDTH = parseInt(event.attributes.BANDWIDTH, 10);
}
if (event.attributes['PROGRAM-ID']) {
event.attributes['PROGRAM-ID'] = parseInt(event.attributes['PROGRAM-ID'], 10);
}
}
this.trigger('data', event);
return;
}
match = (/^#EXT-X-MEDIA:?(.*)$/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'media'
};
if (match[1]) {
event.attributes = parseAttributes(match[1]);
}
this.trigger('data', event);
return;
}
match = (/^#EXT-X-ENDLIST/).exec(line);
if (match) {
this.trigger('data', {
type: 'tag',
tagType: 'endlist'
});
return;
}
match = (/^#EXT-X-DISCONTINUITY/).exec(line);
if (match) {
this.trigger('data', {
type: 'tag',
tagType: 'discontinuity'
});
return;
}
match = (/^#EXT-X-KEY:?(.*)$/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'key'
};
if (match[1]) {
event.attributes = parseAttributes(match[1]);
// parse the IV string into a Uint32Array
if (event.attributes.IV) {
if (event.attributes.IV.substring(0, 2).toLowerCase() === '0x') {
event.attributes.IV = event.attributes.IV.substring(2);
}
event.attributes.IV = event.attributes.IV.match(/.{8}/g);
event.attributes.IV[0] = parseInt(event.attributes.IV[0], 16);
event.attributes.IV[1] = parseInt(event.attributes.IV[1], 16);
event.attributes.IV[2] = parseInt(event.attributes.IV[2], 16);
event.attributes.IV[3] = parseInt(event.attributes.IV[3], 16);
event.attributes.IV = new Uint32Array(event.attributes.IV);
}
}
this.trigger('data', event);
return;
}
// unknown tag type
this.trigger('data', {
type: 'tag',
data: line.slice(4, line.length)
});
}
}
/**
* @file m3u8/parser.js
*/
import Stream from '../stream' ;
import LineStream from './line-stream';
import ParseStream from './parse-stream';
import {mergeOptions} from 'video.js';
/**
* A parser for M3U8 files. The current interpretation of the input is
* exposed as a property `manifest` on parser objects. It's just two lines to
* create and parse a manifest once you have the contents available as a string:
*
* ```js
* var parser = new videojs.m3u8.Parser();
* parser.push(xhr.responseText);
* ```
*
* New input can later be applied to update the manifest object by calling
* `push` again.
*
* The parser attempts to create a usable manifest object even if the
* underlying input is somewhat nonsensical. It emits `info` and `warning`
* events during the parse if it encounters input that seems invalid or
* requires some property of the manifest object to be defaulted.
*
* @class Parser
* @extends Stream
*/
export default class Parser extends Stream {
constructor() {
super();
this.lineStream = new LineStream();
this.parseStream = new ParseStream();
this.lineStream.pipe(this.parseStream);
/* eslint-disable consistent-this */
let self = this;
/* eslint-enable consistent-this */
let uris = [];
let currentUri = {};
let key;
let noop = function() {};
let defaultMediaGroups = {
'AUDIO': {},
'VIDEO': {},
'CLOSED-CAPTIONS': {},
'SUBTITLES': {}
};
// group segments into numbered timelines delineated by discontinuities
let currentTimeline = 0;
// the manifest is empty until the parse stream begins delivering data
this.manifest = {
allowCache: true,
discontinuityStarts: []
};
// update the manifest with the m3u8 entry from the parse stream
this.parseStream.on('data', function(entry) {
let mediaGroup;
let rendition;
({
tag() {
// switch based on the tag type
(({
'allow-cache'() {
this.manifest.allowCache = entry.allowed;
if (!('allowed' in entry)) {
this.trigger('info', {
message: 'defaulting allowCache to YES'
});
this.manifest.allowCache = true;
}
},
byterange() {
let byterange = {};
if ('length' in entry) {
currentUri.byterange = byterange;
byterange.length = entry.length;
if (!('offset' in entry)) {
this.trigger('info', {
message: 'defaulting offset to zero'
});
entry.offset = 0;
}
}
if ('offset' in entry) {
currentUri.byterange = byterange;
byterange.offset = entry.offset;
}
},
endlist() {
this.manifest.endList = true;
},
inf() {
if (!('mediaSequence' in this.manifest)) {
this.manifest.mediaSequence = 0;
this.trigger('info', {
message: 'defaulting media sequence to zero'
});
}
if (!('discontinuitySequence' in this.manifest)) {
this.manifest.discontinuitySequence = 0;
this.trigger('info', {
message: 'defaulting discontinuity sequence to zero'
});
}
if (entry.duration > 0) {
currentUri.duration = entry.duration;
}
if (entry.duration === 0) {
currentUri.duration = 0.01;
this.trigger('info', {
message: 'updating zero segment duration to a small value'
});
}
this.manifest.segments = uris;
},
key() {
if (!entry.attributes) {
this.trigger('warn', {
message: 'ignoring key declaration without attribute list'
});
return;
}
// clear the active encryption key
if (entry.attributes.METHOD === 'NONE') {
key = null;
return;
}
if (!entry.attributes.URI) {
this.trigger('warn', {
message: 'ignoring key declaration without URI'
});
return;
}
if (!entry.attributes.METHOD) {
this.trigger('warn', {
message: 'defaulting key method to AES-128'
});
}
// setup an encryption key for upcoming segments
key = {
method: entry.attributes.METHOD || 'AES-128',
uri: entry.attributes.URI
};
if (typeof entry.attributes.IV !== 'undefined') {
key.iv = entry.attributes.IV;
}
},
'media-sequence'() {
if (!isFinite(entry.number)) {
this.trigger('warn', {
message: 'ignoring invalid media sequence: ' + entry.number
});
return;
}
this.manifest.mediaSequence = entry.number;
},
'discontinuity-sequence'() {
if (!isFinite(entry.number)) {
this.trigger('warn', {
message: 'ignoring invalid discontinuity sequence: ' + entry.number
});
return;
}
this.manifest.discontinuitySequence = entry.number;
currentTimeline = entry.number;
},
'playlist-type'() {
if (!(/VOD|EVENT/).test(entry.playlistType)) {
this.trigger('warn', {
message: 'ignoring unknown playlist type: ' + entry.playlist
});
return;
}
this.manifest.playlistType = entry.playlistType;
},
'stream-inf'() {
this.manifest.playlists = uris;
this.manifest.mediaGroups =
this.manifest.mediaGroups || defaultMediaGroups;
if (!entry.attributes) {
this.trigger('warn', {
message: 'ignoring empty stream-inf attributes'
});
return;
}
if (!currentUri.attributes) {
currentUri.attributes = {};
}
currentUri.attributes = mergeOptions(currentUri.attributes,
entry.attributes);
},
media() {
this.manifest.mediaGroups =
this.manifest.mediaGroups || defaultMediaGroups;
if (!(entry.attributes &&
entry.attributes.TYPE &&
entry.attributes['GROUP-ID'] &&
entry.attributes.NAME)) {
this.trigger('warn', {
message: 'ignoring incomplete or missing media group'
});
return;
}
// find the media group, creating defaults as necessary
let mediaGroupType = this.manifest.mediaGroups[entry.attributes.TYPE];
mediaGroupType[entry.attributes['GROUP-ID']] =
mediaGroupType[entry.attributes['GROUP-ID']] || {};
mediaGroup = mediaGroupType[entry.attributes['GROUP-ID']];
// collect the rendition metadata
rendition = {
default: (/yes/i).test(entry.attributes.DEFAULT)
};
if (rendition.default) {
rendition.autoselect = true;
} else {
rendition.autoselect = (/yes/i).test(entry.attributes.AUTOSELECT);
}
if (entry.attributes.LANGUAGE) {
rendition.language = entry.attributes.LANGUAGE;
}
if (entry.attributes.URI) {
rendition.uri = entry.attributes.URI;
}
// insert the new rendition
mediaGroup[entry.attributes.NAME] = rendition;
},
discontinuity() {
currentTimeline += 1;
currentUri.discontinuity = true;
this.manifest.discontinuityStarts.push(uris.length);
},
targetduration() {
if (!isFinite(entry.duration) || entry.duration < 0) {
this.trigger('warn', {
message: 'ignoring invalid target duration: ' + entry.duration
});
return;
}
this.manifest.targetDuration = entry.duration;
},
totalduration() {
if (!isFinite(entry.duration) || entry.duration < 0) {
this.trigger('warn', {
message: 'ignoring invalid total duration: ' + entry.duration
});
return;
}
this.manifest.totalDuration = entry.duration;
}
})[entry.tagType] || noop).call(self);
},
uri() {
currentUri.uri = entry.uri;
uris.push(currentUri);
// if no explicit duration was declared, use the target duration
if (this.manifest.targetDuration &&
!('duration' in currentUri)) {
this.trigger('warn', {
message: 'defaulting segment duration to the target duration'
});
currentUri.duration = this.manifest.targetDuration;
}
// annotate with encryption information, if necessary
if (key) {
currentUri.key = key;
}
currentUri.timeline = currentTimeline;
// prepare for the next URI
currentUri = {};
},
comment() {
// comments are not important for playback
}
})[entry.type].call(self);
});
}
/**
* Parse the input string and update the manifest object.
*
* @param {String} chunk a potentially incomplete portion of the manifest
*/
push(chunk) {
this.lineStream.push(chunk);
}
/**
* Flush any remaining input. This can be handy if the last line of an M3U8
* manifest did not contain a trailing newline but the file has been
* completely received.
*/
end() {
// flush any buffered input
this.lineStream.push('\n');
}
}
......@@ -8,7 +8,7 @@
import resolveUrl from './resolve-url';
import {mergeOptions} from 'video.js';
import Stream from './stream';
import m3u8 from './m3u8';
import m3u8 from 'm3u8-parser';
/**
* Returns a new array of segments that is the result of merging
......
......@@ -11,7 +11,7 @@ import xhrFactory from './xhr';
import {Decrypter, AsyncStream, decrypt} from './decrypter';
import utils from './bin-utils';
import {MediaSource, URL} from 'videojs-contrib-media-sources';
import m3u8 from './m3u8';
import m3u8 from 'm3u8-parser';
import videojs from 'video.js';
import MasterPlaylistController from './master-playlist-controller';
import Config from './config';
......
import {ParseStream, LineStream, Parser} from '../src/m3u8';
import QUnit from 'qunit';
import testDataExpected from './test-expected.js';
import testDataManifests from './test-manifests.js';
QUnit.module('LineStream', {
beforeEach() {
this.lineStream = new LineStream();
}
});
QUnit.test('empty inputs produce no tokens', function() {
let data = false;
this.lineStream.on('data', function() {
data = true;
});
this.lineStream.push('');
QUnit.ok(!data, 'no tokens were produced');
});
QUnit.test('splits on newlines', function() {
let lines = [];
this.lineStream.on('data', function(line) {
lines.push(line);
});
this.lineStream.push('#EXTM3U\nmovie.ts\n');
QUnit.strictEqual(2, lines.length, 'two lines are ready');
QUnit.strictEqual('#EXTM3U', lines.shift(), 'the first line is the first token');
QUnit.strictEqual('movie.ts', lines.shift(), 'the second line is the second token');
});
QUnit.test('empty lines become empty strings', function() {
let lines = [];
this.lineStream.on('data', function(line) {
lines.push(line);
});
this.lineStream.push('\n\n');
QUnit.strictEqual(2, lines.length, 'two lines are ready');
QUnit.strictEqual('', lines.shift(), 'the first line is empty');
QUnit.strictEqual('', lines.shift(), 'the second line is empty');
});
QUnit.test('handles lines broken across appends', function() {
let lines = [];
this.lineStream.on('data', function(line) {
lines.push(line);
});
this.lineStream.push('#EXTM');
QUnit.strictEqual(0, lines.length, 'no lines are ready');
this.lineStream.push('3U\nmovie.ts\n');
QUnit.strictEqual(2, lines.length, 'two lines are ready');
QUnit.strictEqual('#EXTM3U', lines.shift(), 'the first line is the first token');
QUnit.strictEqual('movie.ts', lines.shift(), 'the second line is the second token');
});
QUnit.test('stops sending events after deregistering', function() {
let temporaryLines = [];
let temporary = function(line) {
temporaryLines.push(line);
};
let permanentLines = [];
let permanent = function(line) {
permanentLines.push(line);
};
this.lineStream.on('data', temporary);
this.lineStream.on('data', permanent);
this.lineStream.push('line one\n');
QUnit.strictEqual(temporaryLines.length,
permanentLines.length,
'both callbacks receive the event');
QUnit.ok(this.lineStream.off('data', temporary), 'a listener was removed');
this.lineStream.push('line two\n');
QUnit.strictEqual(1, temporaryLines.length, 'no new events are received');
QUnit.strictEqual(2, permanentLines.length, 'new events are still received');
});
QUnit.module('ParseStream', {
beforeEach() {
this.lineStream = new LineStream();
this.parseStream = new ParseStream();
this.lineStream.pipe(this.parseStream);
}
});
QUnit.test('parses comment lines', function() {
let manifest = '# a line that starts with a hash mark without "EXT" is a comment\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'comment', 'the type is comment');
QUnit.strictEqual(element.text,
manifest.slice(1, manifest.length - 1),
'the comment text is parsed');
});
QUnit.test('parses uri lines', function() {
let manifest = 'any non-blank line that does not start with a hash-mark is a URI\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'uri', 'the type is uri');
QUnit.strictEqual(element.uri,
manifest.substring(0, manifest.length - 1),
'the uri text is parsed');
});
QUnit.test('parses unknown tag types', function() {
let manifest = '#EXT-X-EXAMPLE-TAG:some,additional,stuff\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the type is tag');
QUnit.strictEqual(element.data,
manifest.slice(4, manifest.length - 1),
'unknown tag data is preserved');
});
// #EXTM3U
QUnit.test('parses #EXTM3U tags', function() {
let manifest = '#EXTM3U\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'm3u', 'the tag type is m3u');
});
// #EXTINF
QUnit.test('parses minimal #EXTINF tags', function() {
let manifest = '#EXTINF\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf');
});
QUnit.test('parses #EXTINF tags with durations', function() {
let manifest = '#EXTINF:15\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf');
QUnit.strictEqual(element.duration, 15, 'the duration is parsed');
QUnit.ok(!('title' in element), 'no title is parsed');
manifest = '#EXTINF:21,\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf');
QUnit.strictEqual(element.duration, 21, 'the duration is parsed');
QUnit.ok(!('title' in element), 'no title is parsed');
});
QUnit.test('parses #EXTINF tags with a duration and title', function() {
let manifest = '#EXTINF:13,Does anyone really use the title attribute?\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf');
QUnit.strictEqual(element.duration, 13, 'the duration is parsed');
QUnit.strictEqual(element.title,
manifest.substring(manifest.indexOf(',') + 1, manifest.length - 1),
'the title is parsed');
});
QUnit.test('parses #EXTINF tags with carriage returns', function() {
let manifest = '#EXTINF:13,Does anyone really use the title attribute?\r\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf');
QUnit.strictEqual(element.duration, 13, 'the duration is parsed');
QUnit.strictEqual(element.title,
manifest.substring(manifest.indexOf(',') + 1, manifest.length - 2),
'the title is parsed');
});
// #EXT-X-TARGETDURATION
QUnit.test('parses minimal #EXT-X-TARGETDURATION tags', function() {
let manifest = '#EXT-X-TARGETDURATION\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'targetduration', 'the tag type is targetduration');
QUnit.ok(!('duration' in element), 'no duration is parsed');
});
QUnit.test('parses #EXT-X-TARGETDURATION with duration', function() {
let manifest = '#EXT-X-TARGETDURATION:47\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'targetduration', 'the tag type is targetduration');
QUnit.strictEqual(element.duration, 47, 'the duration is parsed');
});
// #EXT-X-VERSION
QUnit.test('parses minimal #EXT-X-VERSION tags', function() {
let manifest = '#EXT-X-VERSION:\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'version', 'the tag type is version');
QUnit.ok(!('version' in element), 'no version is present');
});
QUnit.test('parses #EXT-X-VERSION with a version', function() {
let manifest = '#EXT-X-VERSION:99\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'version', 'the tag type is version');
QUnit.strictEqual(element.version, 99, 'the version is parsed');
});
// #EXT-X-MEDIA-SEQUENCE
QUnit.test('parses minimal #EXT-X-MEDIA-SEQUENCE tags', function() {
let manifest = '#EXT-X-MEDIA-SEQUENCE\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'media-sequence', 'the tag type is media-sequence');
QUnit.ok(!('number' in element), 'no number is present');
});
QUnit.test('parses #EXT-X-MEDIA-SEQUENCE with sequence numbers', function() {
let manifest = '#EXT-X-MEDIA-SEQUENCE:109\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'media-sequence', 'the tag type is media-sequence');
QUnit.ok(element.number, 109, 'the number is parsed');
});
// #EXT-X-PLAYLIST-TYPE
QUnit.test('parses minimal #EXT-X-PLAYLIST-TYPE tags', function() {
let manifest = '#EXT-X-PLAYLIST-TYPE:\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
QUnit.ok(!('playlistType' in element), 'no playlist type is present');
});
QUnit.test('parses #EXT-X-PLAYLIST-TYPE with mutability info', function() {
let manifest = '#EXT-X-PLAYLIST-TYPE:EVENT\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
QUnit.strictEqual(element.playlistType, 'EVENT', 'the playlist type is EVENT');
manifest = '#EXT-X-PLAYLIST-TYPE:VOD\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
QUnit.strictEqual(element.playlistType, 'VOD', 'the playlist type is VOD');
manifest = '#EXT-X-PLAYLIST-TYPE:nonsense\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
QUnit.strictEqual(element.playlistType, 'nonsense', 'the playlist type is parsed');
});
// #EXT-X-BYTERANGE
QUnit.test('parses minimal #EXT-X-BYTERANGE tags', function() {
let manifest = '#EXT-X-BYTERANGE\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'byterange', 'the tag type is byterange');
QUnit.ok(!('length' in element), 'no length is present');
QUnit.ok(!('offset' in element), 'no offset is present');
});
QUnit.test('parses #EXT-X-BYTERANGE with length and offset', function() {
let manifest = '#EXT-X-BYTERANGE:45\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'byterange', 'the tag type is byterange');
QUnit.strictEqual(element.length, 45, 'length is parsed');
QUnit.ok(!('offset' in element), 'no offset is present');
manifest = '#EXT-X-BYTERANGE:108@16\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'byterange', 'the tag type is byterange');
QUnit.strictEqual(element.length, 108, 'length is parsed');
QUnit.strictEqual(element.offset, 16, 'offset is parsed');
});
// #EXT-X-ALLOW-CACHE
QUnit.test('parses minimal #EXT-X-ALLOW-CACHE tags', function() {
let manifest = '#EXT-X-ALLOW-CACHE:\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache');
QUnit.ok(!('allowed' in element), 'no allowed is present');
});
QUnit.test('parses valid #EXT-X-ALLOW-CACHE tags', function() {
let manifest = '#EXT-X-ALLOW-CACHE:YES\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache');
QUnit.ok(element.allowed, 'allowed is parsed');
manifest = '#EXT-X-ALLOW-CACHE:NO\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache');
QUnit.ok(!element.allowed, 'allowed is parsed');
});
// #EXT-X-STREAM-INF
QUnit.test('parses minimal #EXT-X-STREAM-INF tags', function() {
let manifest = '#EXT-X-STREAM-INF\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.ok(!('attributes' in element), 'no attributes are present');
});
QUnit.test('parses #EXT-X-STREAM-INF with common attributes', function() {
let manifest = '#EXT-X-STREAM-INF:BANDWIDTH=14400\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.strictEqual(element.attributes.BANDWIDTH, 14400, 'bandwidth is parsed');
manifest = '#EXT-X-STREAM-INF:PROGRAM-ID=7\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.strictEqual(element.attributes['PROGRAM-ID'], 7, 'program-id is parsed');
manifest = '#EXT-X-STREAM-INF:RESOLUTION=396x224\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.strictEqual(element.attributes.RESOLUTION.width, 396, 'width is parsed');
QUnit.strictEqual(element.attributes.RESOLUTION.height, 224, 'heigth is parsed');
manifest = '#EXT-X-STREAM-INF:CODECS="avc1.4d400d, mp4a.40.2"\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.strictEqual(element.attributes.CODECS,
'avc1.4d400d, mp4a.40.2',
'codecs are parsed');
});
QUnit.test('parses #EXT-X-STREAM-INF with arbitrary attributes', function() {
let manifest = '#EXT-X-STREAM-INF:NUMERIC=24,ALPHA=Value,MIXED=123abc\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.strictEqual(element.attributes.NUMERIC, '24', 'numeric attributes are parsed');
QUnit.strictEqual(element.attributes.ALPHA,
'Value',
'alphabetic attributes are parsed');
QUnit.strictEqual(element.attributes.MIXED, '123abc', 'mixed attributes are parsed');
});
// #EXT-X-ENDLIST
QUnit.test('parses #EXT-X-ENDLIST tags', function() {
let manifest = '#EXT-X-ENDLIST\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'endlist', 'the tag type is stream-inf');
});
// #EXT-X-KEY
QUnit.test('parses valid #EXT-X-KEY tags', function() {
let manifest =
'#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.deepEqual(element, {
type: 'tag',
tagType: 'key',
attributes: {
METHOD: 'AES-128',
URI: 'https://priv.example.com/key.php?r=52'
}
}, 'parsed a valid key');
manifest = '#EXT-X-KEY:URI="https://example.com/key#1",METHOD=FutureType-1024\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.deepEqual(element, {
type: 'tag',
tagType: 'key',
attributes: {
METHOD: 'FutureType-1024',
URI: 'https://example.com/key#1'
}
}, 'parsed the attribute list independent of order');
manifest = '#EXT-X-KEY:IV=1234567890abcdef1234567890abcdef\n';
this.lineStream.push(manifest);
QUnit.ok(element.attributes.IV, 'detected an IV attribute');
QUnit.deepEqual(element.attributes.IV, new Uint32Array([
0x12345678,
0x90abcdef,
0x12345678,
0x90abcdef
]), 'parsed an IV value');
});
QUnit.test('parses minimal #EXT-X-KEY tags', function() {
let manifest = '#EXT-X-KEY:\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.deepEqual(element, {
type: 'tag',
tagType: 'key'
}, 'parsed a minimal key tag');
});
QUnit.test('parses lightly-broken #EXT-X-KEY tags', function() {
let manifest = '#EXT-X-KEY:URI=\'https://example.com/single-quote\',METHOD=AES-128\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.strictEqual(element.attributes.URI,
'https://example.com/single-quote',
'parsed a single-quoted uri');
element = null;
manifest = '#EXT-X-KEYURI="https://example.com/key",METHOD=AES-128\n';
this.lineStream.push(manifest);
QUnit.strictEqual(element.tagType, 'key', 'parsed the tag type');
QUnit.strictEqual(element.attributes.URI,
'https://example.com/key',
'inferred a colon after the tag type');
element = null;
manifest = '#EXT-X-KEY: URI = "https://example.com/key",METHOD=AES-128\n';
this.lineStream.push(manifest);
QUnit.strictEqual(element.attributes.URI,
'https://example.com/key',
'trims and removes quotes around the URI');
});
QUnit.test('parses prefixed with 0x or 0X #EXT-X-KEY:IV tags', function() {
let manifest;
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
manifest = '#EXT-X-KEY:IV=0x1234567890abcdef1234567890abcdef\n';
this.lineStream.push(manifest);
QUnit.ok(element.attributes.IV, 'detected an IV attribute');
QUnit.deepEqual(element.attributes.IV, new Uint32Array([
0x12345678,
0x90abcdef,
0x12345678,
0x90abcdef
]), 'parsed an IV value with 0x');
manifest = '#EXT-X-KEY:IV=0X1234567890abcdef1234567890abcdef\n';
this.lineStream.push(manifest);
QUnit.ok(element.attributes.IV, 'detected an IV attribute');
QUnit.deepEqual(element.attributes.IV, new Uint32Array([
0x12345678,
0x90abcdef,
0x12345678,
0x90abcdef
]), 'parsed an IV value with 0X');
});
QUnit.test('ignores empty lines', function() {
let manifest = '\n';
let event = false;
this.parseStream.on('data', function() {
event = true;
});
this.lineStream.push(manifest);
QUnit.ok(!event, 'no event is triggered');
});
QUnit.module('m3u8 parser');
QUnit.test('can be constructed', function() {
QUnit.notStrictEqual(typeof new Parser(), 'undefined', 'parser is defined');
});
QUnit.module('m3u8s');
QUnit.test('parses static manifests as expected', function() {
let key;
for (key in testDataManifests) {
if (testDataExpected[key]) {
let parser = new Parser();
parser.push(testDataManifests[key]);
QUnit.deepEqual(parser.manifest,
testDataExpected[key],
key + '.m3u8 was parsed correctly'
);
}
}
});