From 98b9588143b1173268d7069b1f8ab4ae6d2e8089 Mon Sep 17 00:00:00 2001 From: David LaPalomento Date: Wed, 18 Dec 2013 19:18:19 -0500 Subject: [PATCH] Move to a handrolled parser Split parsing into tokenization and a very liberal parser. After this, an "interpreter" needs to be created to build an object representation of the manifest based on the events emitted by the parser. Higher-level manifest tests are broken until that interpreter is written. --- Gruntfile.js | 11 +- package.json | 9 +- src/m3u8/m3u8-parser.js | 151 -------------- src/m3u8/m3u8-tokenizer.js | 122 +++++++++++ src/m3u8/m3u8.pegjs | 322 ----------------------------- test/m3u8_test.js | 401 +++++++++++++++++++++++++++++-------- test/video-js-hls.html | 1 + 7 files changed, 440 insertions(+), 577 deletions(-) delete mode 100644 src/m3u8/m3u8-parser.js create mode 100644 src/m3u8/m3u8-tokenizer.js delete mode 100644 src/m3u8/m3u8.pegjs diff --git a/Gruntfile.js b/Gruntfile.js index 085d2799..70f44080 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,7 +1,5 @@ 'use strict'; -var peg = require('pegjs'); - module.exports = function(grunt) { // Project configuration. @@ -97,16 +95,9 @@ module.exports = function(grunt) { grunt.loadNpmTasks('grunt-contrib-jshint'); grunt.loadNpmTasks('grunt-contrib-watch'); - grunt.registerTask('peg', 'generate the manifest parser', function() { - var parser = peg.buildParser(grunt.file.read('src/m3u8/m3u8.pegjs')); - grunt.file.write('build/m3u8-parser.js', - 'window.videojs.hls.M3U8Parser = ' + parser.toSource()); - }); - // Default task. grunt.registerTask('default', - ['peg', - 'jshint', + ['jshint', 'qunit', 'clean', 'concat', diff --git a/package.json b/package.json index d37156c6..6610c3f4 100644 --- a/package.json +++ b/package.json @@ -6,21 +6,16 @@ }, "license": "Apache 2", "scripts": { - "test": "grunt qunit", - "prepublish": "npm run peg", - "peg": "pegjs -e 'var M3U8Parser = module.exports' src/m3u8/m3u8.pegjs src/m3u8/m3u8-generated.js", - "testpeg": "npm run peg && node test/pegtest.js" + "test": "grunt qunit" }, "devDependencies": { "grunt-contrib-jshint": "~0.6.0", "grunt-contrib-qunit": "~0.2.0", "grunt-contrib-concat": "~0.3.0", - "grunt-contrib-nodeunit": "~0.1.2", "grunt-contrib-uglify": "~0.2.0", "grunt-contrib-watch": "~0.4.0", "grunt-contrib-clean": "~0.4.0", - "grunt": "~0.4.1", - "pegjs": "git+https://github.com/dmajda/pegjs.git" + "grunt": "~0.4.1" }, "dependencies": { "video.js": "~4.2.2", diff --git a/src/m3u8/m3u8-parser.js b/src/m3u8/m3u8-parser.js deleted file mode 100644 index 39d90616..00000000 --- a/src/m3u8/m3u8-parser.js +++ /dev/null @@ -1,151 +0,0 @@ -(function(window) { - var M3U8 = window.videojs.hls.M3U8; - - window.videojs.hls.M3U8Parser = function() { - var - self = this, - tagTypes = window.videojs.hls.m3u8TagType, - lines = [], - data; - - self.getTagType = function(lineData) { - for (var s in tagTypes) { - if (lineData.indexOf(tagTypes[s]) === 0) { - return tagTypes[s]; - } - } - }; - - self.getTagValue = function(lineData) { - for (var s in tagTypes) { - if (lineData.indexOf(tagTypes[s]) === 0) { - return lineData.substr(tagTypes[s].length); - } - } - }; - - self.parse = function(rawDataString) { - data = new M3U8(); - - if (self.directory) { - data.directory = self.directory; - } - - if (rawDataString === undefined || rawDataString.length <= 0) { - data.invalidReasons.push("Empty Manifest"); - return; - } - lines = rawDataString.split('\n'); - - lines.forEach(function(value,index) { - var segment, rendition, attributes; - - switch (self.getTagType(value)) { - case tagTypes.EXTM3U: - data.hasValidM3UTag = (index === 0); - if (!data.hasValidM3UTag) { - data.invalidReasons.push("Invalid EXTM3U Tag"); - } - break; - - case tagTypes.DISCONTINUITY: - break; - - case tagTypes.PLAYLIST_TYPE: - if (self.getTagValue(value) === "VOD" || - self.getTagValue(value) === "EVENT") { - data.playlistType = self.getTagValue(value); - - } else { - data.invalidReasons.push("Invalid Playlist Type Value"); - } - break; - - case tagTypes.EXTINF: - segment = { - url: "unknown", - byterange: -1, - targetDuration: data.targetDuration - }; - - if (self.getTagType(lines[index + 1]) === tagTypes.BYTERANGE) { - segment.byterange = self.getTagValue(lines[index + 1]).split('@'); - segment.url = lines[index + 2]; - } else { - segment.url = lines[index + 1]; - } - - if (segment.url.indexOf("http") === -1 && self.directory) { - if (data.directory[data.directory.length-1] === segment.url[0] && - segment.url[0] === "/") { - segment.url = segment.url.substr(1); - } - segment.url = self.directory + segment.url; - } - data.mediaItems.push(segment); - break; - - case tagTypes.STREAM_INF: - rendition = {}; - attributes = value.substr(tagTypes.STREAM_INF.length).split(','); - - attributes.forEach(function(attrValue) { - if (isNaN(attrValue.split('=')[1])) { - rendition[attrValue.split('=')[0].toLowerCase()] = attrValue.split('=')[1]; - - if (rendition[attrValue.split('=')[0].toLowerCase()].split('x').length === 2) { - rendition.resolution = { - width: parseInt(rendition[attrValue.split('=')[0].toLowerCase()].split('x')[0],10), - height: parseInt(rendition[attrValue.split('=')[0].toLowerCase()].split('x')[1],10) - }; - } - } else { - rendition[attrValue.split('=')[0].toLowerCase()] = parseInt(attrValue.split('=')[1],10); - } - }); - - if (self.getTagType(lines[index + 1]) === tagTypes.BYTERANGE) { - rendition.byterange = self.getTagValue(lines[index + 1]).split('@'); - rendition.url = lines[index + 2]; - } else { - rendition.url = lines[index + 1]; - } - - data.isPlaylist = true; - data.playlistItems.push(rendition); - break; - - case tagTypes.TARGETDURATION: - data.targetDuration = parseFloat(self.getTagValue(value).split(',')[0]); - break; - - case tagTypes.ZEN_TOTAL_DURATION: - data.totalDuration = parseFloat(self.getTagValue(value)); - break; - - case tagTypes.VERSION: - data.version = parseFloat(self.getTagValue(value)); - break; - - case tagTypes.MEDIA_SEQUENCE: - data.mediaSequence = parseInt(self.getTagValue(value),10); - break; - - case tagTypes.ALLOW_CACHE: - if (self.getTagValue(value) === "YES" || self.getTagValue(value) === "NO") { - data.allowCache = self.getTagValue(value); - } else { - data.invalidReasons.push("Invalid ALLOW_CACHE Value"); - } - break; - - case tagTypes.ENDLIST: - data.hasEndTag = true; - break; - } - }); - - return data; - }; - }; -})(this); diff --git a/src/m3u8/m3u8-tokenizer.js b/src/m3u8/m3u8-tokenizer.js new file mode 100644 index 00000000..74753184 --- /dev/null +++ b/src/m3u8/m3u8-tokenizer.js @@ -0,0 +1,122 @@ +(function(parseInt, undefined) { + var Stream, Tokenizer, Parser; + + Stream = function() { + var listeners = {}; + this.on = function(type, listener) { + if (!listeners[type]) { + listeners[type] = []; + } + listeners[type].push(listener); + }; + this.off = function(type, listener) { + var index; + if (!listeners[type]) { + return false; + } + index = listeners[type].indexOf(listener); + listeners[type].splice(index, 1); + return index > -1; + }; + this.trigger = function(type) { + var callbacks, i, length, args; + callbacks = listeners[type]; + if (!callbacks) { + return; + } + args = Array.prototype.slice.call(arguments, 1); + length = callbacks.length; + for (i = 0; i < length; ++i) { + callbacks[i].apply(this, args); + } + }; + }; + Stream.prototype.pipe = function(destination) { + this.on('data', function(data) { + destination.push(data); + }); + }; + + Tokenizer = function() { + var + buffer = '', + tokenizer; + + this.push = function(data) { + var nextNewline; + + buffer += data; + nextNewline = buffer.indexOf('\n'); + + for (; nextNewline > -1; nextNewline = buffer.indexOf('\n')) { + this.trigger('data', buffer.substring(0, nextNewline)); + buffer = buffer.substring(nextNewline + 1); + } + }; + }; + Tokenizer.prototype = new Stream(); + + Parser = function() {}; + Parser.prototype = new Stream(); + Parser.prototype.push = function(line) { + var match, event; + 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; + } + + // 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 = parseInt(match[1], 10); + } + if (match[2]) { + event.title = match[2]; + } + this.trigger('data', event); + return; + } + + // unknown tag type + this.trigger('data', { + type: 'tag', + data: line.slice(4, line.length) + }); + }; + + window.videojs.m3u8 = { + Tokenizer: Tokenizer, + Parser: Parser + }; +})(window.parseInt); diff --git a/src/m3u8/m3u8.pegjs b/src/m3u8/m3u8.pegjs deleted file mode 100644 index 02fe83e5..00000000 --- a/src/m3u8/m3u8.pegjs +++ /dev/null @@ -1,322 +0,0 @@ -/***** Start *****/ -{ - function reduce(rest, attr) { - return rest.reduce(function(prev, curr) { - var p, - currentItem = curr.pop(); - for (p in currentItem) { - prev[p] = currentItem[p]; - }; - return prev; - }, attr); - } -} - -start - = tags:lines+ .* { - var choices = { - segments: 1, - comments: 1, - playlists: 1 - }; - return tags.reduce(function(obj, tag) { - for (var p in tag) { - if (p in choices) { - if (Object.prototype.toString.call(obj[p]) === '[object Array]') { - obj[p].push(tag[p]); - } else { - obj[p] = [tag[p]]; - } - } else { - obj[p] = tag[p]; - } - - return obj; - } - }, {}); - } - -lines - = comment:comment _ { var obj = {}; obj["comments"] = comment; return obj; } - / ! comment tag:tag _ { return tag; } - -tag - = & comment - / tag:m3uTag _ { return tag; } - / tag:extinfTag _ { return tag; } - / tag:targetDurationTag _ { return tag; } - / tag:mediaSequenceTag _ { return tag; } - / tag:keyTag _ { return tag; } - / tag:programDateTimeTag _ { return tag; } - / tag:allowCacheTag _ { return tag; } - / tag:playlistTypeTag _ { return tag; } - / tag:endlistTag _ { return tag; } - / tag:mediaTag _ { return tag; } - / tag:streamInfTag _ { return tag; } - / tag:discontinuityTag _ { return tag; } - / tag:discontinuitySequenceTag _ { return tag; } - / tag:iframesOnlyTag _ { return tag; } - / tag:mapTag _ { return tag; } - / tag:iframeStreamInf _ { return tag; } - / tag:startTag _ { return tag; } - / tag:versionTag _ { return tag; } - -comment "comment" - = & "#" ! "#EXT" text:text+ { return text.join(); } - -/***** Tags *****/ - -m3uTag - = tag:"#EXTM3U" { return {openTag: true}; } - -extinfTag - = tag:'#EXTINF' ":" duration:number "," optional:extinfOptionalParts _ url:mediaURL { - return {segments: { - byterange: optional.byteRange || -1, - title: optional.title, - targetDuration: duration, - url: url - } - }; - } - -byteRangeTag - = tag:"#EXT-X-BYTERANGE" ":" length:int ("@" offset:int)? { return {length: length, offset: offset}; } - -targetDurationTag - = tag:"#EXT-X-TARGETDURATION" ":" seconds:int { return {targetDuration: seconds}; } - -mediaSequenceTag - = tag:'#EXT-X-MEDIA-SEQUENCE' ":" sequenceNumber:int { return {mediaSequence: sequenceNumber}; } - -keyTag - = tag:'#EXT-X-KEY' ":" attrs:keyAttributes { return {key: attrs}; } - -programDateTimeTag - = tag:'#EXT-X-PROGRAM-DATE-TIME' ":" date:date - -allowCacheTag - = tag:'#EXT-X-ALLOW-CACHE' ":" answer:answer { return {allowCache: answer}; } - -playlistTypeTag - = tag:'#EXT-X-PLAYLIST-TYPE' ":" type:playlistType { return {playlistType: type}; } - -endlistTag - = tag:'#EXT-X-ENDLIST' { return {closeTag: true}; } - -mediaTag - = tag:'#EXT-MEDIA' ":" attrs:mediaAttributes { return {media: attrs}; } - -streamInfTag - = tag:'#EXT-X-STREAM-INF' ":" attrs:streamInfAttrs _ url:mediaURL? { - return {playlists: { - attributes: attrs, - url: url - } - }; - } - -discontinuityTag - = tag:'#EXT-X-DISCONTINUITY' - -discontinuitySequenceTag - = tag:'#EXT-X-DISCONTINUITY-SEQUENCE' ":" sequence:int { return {discontinuitySequence: sequence}; } - -iframesOnlyTag - = tag:'#EXT-X-I-FRAMES-ONLY' - -mapTag - = tag:'#EXT-X-MAP' ":" attrs:mapAttributes { return {map: attrs}; } - -iframeStreamInf - = tag:'#EXT-X-I-FRAME-STREAM-INF' ":" attrs:iframeStreamAttrs { return {iframeStream: attrs}; } - -startTag - = tag:'EXT-X-START' ":" attrs:startAttributes { return {start: attrs}; } - -versionTag - = tag:'#EXT-X-VERSION' ":" version:int { return {version: version}; } - -/***** Helpers *****/ - -extinfOptionalParts - = nonbreakingWhitespace title:text _ byteRange:byteRangeTag? { return {title: title, byteRange: byteRange} } - / _ byteRange:byteRangeTag? { return {title: '', byteRange: byteRange}; } - -mediaURL - = & tag - / ! tag file:[ -~]+ { return file.join(''); } - -keyAttributes - = (attr:keyAttribute rest:(attrSeparator streamInfAttrs)*) { return reduce(rest, attr); } - / attr:keyAttribute? { return [attr]; } - -keyAttribute - = "METHOD" "=" method:keyMethod { return {keyMethod: method}; } - / "URI" "=" uri:quotedString { return {uri: uri}; } - / "IV" "=" iv:hexint { return {IV: iv}; } - / "KEYFORMAT" "=" keyFormat:quotedString { return {keyFormat: keyFormat}; } - / "KEYFORMATVERSIONS" "=" keyFormatVersions:quotedString { return {keyFormatVersions: keyFormatVersions}; } - -keyMethod - = "NONE" - / "AES-128" - / "SAMPLE-AES" - -mediaAttributes - = (attr:mediaAttribute rest:(attrSeparator mediaAttribute)*) { return reduce(rest, attr); } - / attr:mediaAttribute? { return [attr] } - -mediaAttribute - = "TYPE" "=" type:mediaTypes { return {type: type}; } - / "URI" "=" uri:quotedString { return {uri: uri}; } - / "GROUP-ID" "=" groupId:quotedString { return {groupId: groupdId}; } - / "LANGUAGE" "=" langauge:quotedString { return {language: language}; } - / "ASSOC-LANGUAGE" "=" assocLanguage:quotedString { return {assocLanguage: assocLanguage}; } - / "NAME" "=" name:quotedString { return {name: name}; } - / "DEFAULT" "=" def:answer { return {defaultAnswer: def}; } - / "AUTOSELECT" "=" autoselect:answer { return {autoselect: autoselect}; } - / "FORCE" "=" force:answer { return {force: force}; } - / "INSTREAM-ID" "=" instreamId:quotedString { return {instreamId: instreamId}; } - / "CHARACTERISTICS" "=" characteristics:quotedString { return {characteristics: characteristics}; } - -streamInfAttrs - = (attr:streamInfAttr rest:(attrSeparator streamInfAttr)*) { return reduce(rest, attr); } - / attr:streamInfAttr? - -streamInfAttr - = streamInfSharedAttr - / "AUDIO" "=" audio:quotedString { return {audio: audio}; } - / "SUBTITLES" "=" subtitles:quotedString { return {video: video}; } - / "CLOSED-CAPTIONS" "=" captions:"NONE" { return {closedCaptions: captions}; } - / "CLOSED-CAPTIONS" "=" captions:quotedString { return {closedCaptions: captions}; } - -streamInfSharedAttr - = "PROGRAM-ID" "=" programId:int { return {programId: programId}; } - / "BANDWIDTH" "=" bandwidth:int { return {bandwidth: bandwidth}; } - / "CODECS" "=" codec:quotedString { return {codecs: codec}; } - / "RESOLUTION" "=" resolution:resolution { return {resolution: resolution}; } - / "VIDEO" "=" video:quotedString { return {video: video}; } - -mapAttributes - = (attr:mapAttribute rest:(attrSeparator mapAttribute)*) { return reduce(rest, attr); } - / attr:mapAttribute? - -mapAttribute - = "URI" "=" uri:quotedString { return {uri: uri}; } - / "BYTERANGE" "=" byteRange:quotedString { return {byterange: byterange}; } - -iframeStreamAttrs - = (attr:iframeStreamAttr rest:(attrSeparator iframeStreamAttr)*) { return reduce(rest, attr); } - / attr:iframeStreamAttr? - -iframeStreamAttr - = streamInfSharedAttr - / "URI" "=" uri:quotedString { return {uri: uri}; } - -startAttributes - = (attr:startAttribute rest:(attrSeparator startAttribute)*) { return reduce(rest, attr); } - / attr:startAttribute? - -startAttribute - = "TIME-OFFSET" "=" timeOffset:number { return {timeOffset: timeOffset}; } - / "PRECISE" "=" precise:answer { return {precise: precise}; } - -answer "answer" - = "YES" - / "NO" - -mediaTypes - = "AUDIO" - / "VIDEO" - / "SUBTITLES" - / "CLOSED-CAPTIONS" - -playlistType - = "EVENT" - / "VOD" - -attrSeparator - = "," nonbreakingWhitespace { return; } - -/***** Date *****/ - -date "date" - = year:year "-" month:month "-" day:day "T" time:time timezone:timezone - -year "year" - = digit digit digit digit - -month "month" - = [01] digit - -day "day" - = [0-3] digit - -time "time" - = [0-2] digit ":" [0-5] digit ":" [0-5] digit "." digit+ - / [0-2] digit ":" [0-5] digit ":" [0-5] digit - / [0-2] digit ":" [0-5] digit - -timezone "timezone" - = [+-] [0-2] digit ":" [0-5] digit - / "Z" - -/***** Numbers *****/ - -number "number" - = parts:(int frac) _ { return parseFloat(parts.join('')); } - / parts:(int) _ { return parts; } - -resolution - = width:int "x" height:int { return {width: width, height: height}; } - -int - = first:digit19 rest:digits { return parseInt(first + rest.join(''), 10); } - / digit:digit { return parseInt(digit, 10); } - / neg:"-" first:digit19 rest:digits { return parseInt(neg + first + rest.join(''), 10); } - / neg:"-" digit:digit { return parseInt(neg + digit, 10); } - -hexint - = "0x" hexDigits:hexDigit+ { return '0x' + hexDigits.join(''); } - / "0X" hexDigits:hexDigit+ { return '0x' + hexDigits.join(''); } - -frac - = dec:"." digits:digits { return dec + digits.join(''); } - -digits - = digit+ - -digit - = [0-9] - -digit19 - = [1-9] - -hexDigit - = [0-9a-fA-F] - -/***** Text *****/ - -quotedString - = '"' '"' _ { return ""; } - / '"' chars:quotedChar+ '"' _ { return chars.join(''); } - -quotedChar - = [^\r\n"] - / char:char - -text "text" - = text:char+ { return text.join(''); } - -char "char" - = [ -~] - -_ "whitespace" - = whitespace* - -whitespace - = [ \t\n\r] - -nonbreakingWhitespace - = [ \t]* diff --git a/test/m3u8_test.js b/test/m3u8_test.js index 6c156170..2222b402 100644 --- a/test/m3u8_test.js +++ b/test/m3u8_test.js @@ -2,7 +2,10 @@ var Handlebars = this.Handlebars, manifestController = this.manifestController, - m3u8parser; + Parser = window.videojs.m3u8.Parser, + parser, + Tokenizer = window.videojs.m3u8.Tokenizer, + tokenizer; module('environment'); @@ -58,23 +61,234 @@ M3U8 Test Suite */ + module('M3U8 Tokenizer', { + setup: function() { + tokenizer = new Tokenizer(); + } + }); + test('empty inputs produce no tokens', function() { + var data = false; + tokenizer.on('data', function() { + data = true; + }); + tokenizer.push(''); + ok(!data, 'no tokens were produced'); + }); + test('splits on newlines', function() { + var lines = []; + tokenizer.on('data', function(line) { + lines.push(line); + }); + tokenizer.push('#EXTM3U\nmovie.ts\n'); + + equal(2, lines.length, 'two lines are ready'); + equal('#EXTM3U', lines.shift(), 'the first line is the first token'); + equal('movie.ts', lines.shift(), 'the second line is the second token'); + }); + test('empty lines become empty strings', function() { + var lines = []; + tokenizer.on('data', function(line) { + lines.push(line); + }); + tokenizer.push('\n\n'); + + equal(2, lines.length, 'two lines are ready'); + equal('', lines.shift(), 'the first line is empty'); + equal('', lines.shift(), 'the second line is empty'); + }); + test('handles lines broken across appends', function() { + var lines = []; + tokenizer.on('data', function(line) { + lines.push(line); + }); + tokenizer.push('#EXTM'); + equal(0, lines.length, 'no lines are ready'); + + tokenizer.push('3U\nmovie.ts\n'); + equal(2, lines.length, 'two lines are ready'); + equal('#EXTM3U', lines.shift(), 'the first line is the first token'); + equal('movie.ts', lines.shift(), 'the second line is the second token'); + }); + test('stops sending events after deregistering', function() { + var + temporaryLines = [], + temporary = function(line) { + temporaryLines.push(line); + }, + permanentLines = [], + permanent = function(line) { + permanentLines.push(line); + }; + + tokenizer.on('data', temporary); + tokenizer.on('data', permanent); + tokenizer.push('line one\n'); + equal(temporaryLines.length, permanentLines.length, 'both callbacks receive the event'); + + ok(tokenizer.off('data', temporary), 'a listener was removed'); + tokenizer.push('line two\n'); + equal(1, temporaryLines.length, 'no new events are received'); + equal(2, permanentLines.length, 'new events are still received'); + }); + + module('M3U8 Parser', { + setup: function() { + tokenizer = new Tokenizer(); + parser = new Parser(); + tokenizer.pipe(parser); + } + }); + test('parses comment lines', function() { + var + manifest = '# a line that starts with a hash mark without "EXT" is a comment\n', + element; + parser.on('data', function(elem) { + element = elem; + }); + tokenizer.push(manifest); + + ok(element, 'an event was triggered'); + equal(element.type, 'comment', 'the type is comment'); + equal(element.text, + manifest.slice(1, manifest.length - 1), + 'the comment text is parsed'); + }); + test('parses uri lines', function() { + var + manifest = 'any non-blank line that does not start with a hash-mark is a URI\n', + element; + parser.on('data', function(elem) { + element = elem; + }); + tokenizer.push(manifest); + + ok(element, 'an event was triggered'); + equal(element.type, 'uri', 'the type is uri'); + equal(element.uri, + manifest.substring(0, manifest.length - 1), + 'the uri text is parsed'); + }); + test('parses unknown tag types', function() { + var + manifest = '#EXT-X-EXAMPLE-TAG:some,additional,stuff\n', + element; + parser.on('data', function(elem) { + element = elem; + }); + tokenizer.push(manifest); + + ok(element, 'an event was triggered'); + equal(element.type, 'tag', 'the type is tag'); + equal(element.data, + manifest.slice(4, manifest.length - 1), + 'unknown tag data is preserved'); + }); + test('parses #EXTM3U tags', function() { + var + manifest = '#EXTM3U\n', + element; + parser.on('data', function(elem) { + element = elem; + }); + tokenizer.push(manifest); + + ok(element, 'an event was triggered'); + equal(element.type, 'tag', 'the line type is tag'); + equal(element.tagType, 'm3u', 'the tag type is m3u'); + }); + test('parses minimal #EXTINF tags', function() { + var + manifest = '#EXTINF\n', + element; + parser.on('data', function(elem) { + element = elem; + }); + tokenizer.push(manifest); + + ok(element, 'an event was triggered'); + equal(element.type, 'tag', 'the line type is tag'); + equal(element.tagType, 'inf', 'the tag type is inf'); + }); + test('parses #EXTINF tags with durations', function() { + var + manifest = '#EXTINF:15\n', + element; + parser.on('data', function(elem) { + element = elem; + }); + tokenizer.push(manifest); + + ok(element, 'an event was triggered'); + equal(element.type, 'tag', 'the line type is tag'); + equal(element.tagType, 'inf', 'the tag type is inf'); + equal(element.duration, 15, 'the duration is parsed'); + ok(!('title' in element), 'no title is parsed'); + + manifest = '#EXTINF:21,\n' + tokenizer.push(manifest); + + ok(element, 'an event was triggered'); + equal(element.type, 'tag', 'the line type is tag'); + equal(element.tagType, 'inf', 'the tag type is inf'); + equal(element.duration, 21, 'the duration is parsed'); + ok(!('title' in element), 'no title is parsed'); + }); + test('parses #EXTINF tags with a duration and title', function() { + var + manifest = '#EXTINF:13,Does anyone really use the title attribute?\n', + element; + parser.on('data', function(elem) { + element = elem; + }); + tokenizer.push(manifest); + + ok(element, 'an event was triggered'); + equal(element.type, 'tag', 'the line type is tag'); + equal(element.tagType, 'inf', 'the tag type is inf'); + equal(element.duration, 13, 'the duration is parsed'); + equal(element.title, + manifest.substring(manifest.indexOf(',') + 1, manifest.length - 1), + 'the title is parsed'); + }); + test('ignores empty lines', function() { + var + manifest = '\n', + event = false; + parser.on('data', function() { + event = true; + }); + tokenizer.push(manifest); + + ok(!event, 'no event is triggered'); + }); + module('m3u8 parser', { setup: function() { - m3u8parser = window.videojs.hls.M3U8Parser; + tokenizer = new Tokenizer(); + parser = new Parser(); + tokenizer.pipe(parser); } }); test('should create my parser', function() { - ok(m3u8parser !== undefined); + ok(parser !== undefined); }); test('should successfully parse manifest data', function() { - var parsedData = m3u8parser.parse(window.playlistData); + var parsedData; + parser.on('data', function(manifest) { + parsedData = manifest; + }); + tokenizer.push(window.playlistData); ok(parsedData); }); test('valid manifest should populate the manifest data object', function() { - var data = m3u8parser.parse(window.playlistData); + var data; + parser.on('data', function(manifest) { + data = manifest; + }); + tokenizer.push(window.playlistData); notEqual(data, null, 'data is not NULL'); equal(data.openTag, true, 'data has valid EXTM3U'); @@ -106,7 +320,11 @@ playlistTemplate = Handlebars.compile(window.playlist_type_template), testData = {playlistType: 'VOD'}, playlistData = playlistTemplate(testData), - data = m3u8parser.parse(playlistData); + data; + parser.on('data', function(element) { + data = element; + }); + tokenizer.push(window.playlistData); notEqual(data, null, 'data is not NULL'); //equal(data.invalidReasons.length, 0, 'Errors object should not be empty.'); @@ -118,7 +336,12 @@ playlistTemplate = Handlebars.compile(window.playlist_type_template), testData = {playlistType: 'EVENT'}, playlistData = playlistTemplate(testData), - data = m3u8parser.parse(playlistData); + data; + parser.on('data', function(element) { + data = element; + }); + tokenizer.push(window.playlistData); + notEqual(data, null, 'data is not NULL'); //equal(data.invalidReasons.length, 0, 'Errors object should not be empty.'); equal(data.playlistType, "EVENT", 'acceptable PLAYLIST TYPE'); @@ -129,7 +352,11 @@ playlistTemplate = Handlebars.compile(window.playlist_type_template), testData = {}, playlistData = playlistTemplate(testData), - data = m3u8parser.parse(playlistData); + data; + parser.on('data', function(element) { + data = element; + }); + tokenizer.push(window.playlistData); notEqual(data, null, 'data is not NULL'); //equal(data.invalidReasons.length, 0, 'Errors object should not be empty.'); @@ -148,17 +375,17 @@ //equal(data.invalidReasons[0], 'Invalid Playlist Type Value: \'baklsdhfajsdf\''); }); - test('handles an empty playlist type', function() { - var - playlistTemplate = Handlebars.compile(window.playlist_type_template), - testData = {playlistType: ''}, - playlistData = playlistTemplate(testData), - data = m3u8parser.parse(playlistData); - notEqual(data, null, 'data is not NULL'); - //equal(data.invalidReasons.length, 0, 'Errors object should not be empty.'); - //equal(data.warnings, 'EXT-X-PLAYLIST-TYPE was empty or missing. Assuming VOD'); - equal(data.playlistType, '', 'PLAYLIST TYPE is the empty string'); - }); + // test('handles an empty playlist type', function() { + // var + // playlistTemplate = Handlebars.compile(window.playlist_type_template), + // testData = {playlistType: ''}, + // playlistData = playlistTemplate(testData), + // data = m3u8parser.parse(playlistData); + // notEqual(data, null, 'data is not NULL'); + // //equal(data.invalidReasons.length, 0, 'Errors object should not be empty.'); + // //equal(data.warnings, 'EXT-X-PLAYLIST-TYPE was empty or missing. Assuming VOD'); + // equal(data.playlistType, '', 'PLAYLIST TYPE is the empty string'); + // }); /*3.4.2. EXT-X-TARGETDURATION @@ -306,18 +533,18 @@ equal(data.mediaSequence, undefined, 'MEDIA SEQUENCE is undefined'); }); - test('media sequence is empty in the playlist', function() { - var - playlistTemplate = Handlebars.compile(window.playlist_media_sequence_template), - testData = {mediaSequence: ''}, - playlistData = playlistTemplate(testData), - data = m3u8parser.parse(playlistData); + // test('media sequence is empty in the playlist', function() { + // var + // playlistTemplate = Handlebars.compile(window.playlist_media_sequence_template), + // testData = {mediaSequence: ''}, + // playlistData = playlistTemplate(testData), + // data = m3u8parser.parse(playlistData); - notEqual(data, null, 'data is not NULL'); - // notEqual(data.invalidReasons, null, 'invalidReasons is not NULL'); - // equal(data.invalidReasons.length, 1, 'data has 1 invalid reasons'); - equal(data.mediaSequence, '', 'media sequence is the empty string'); - }); + // notEqual(data, null, 'data is not NULL'); + // // notEqual(data.invalidReasons, null, 'invalidReasons is not NULL'); + // // equal(data.invalidReasons.length, 1, 'data has 1 invalid reasons'); + // equal(data.mediaSequence, '', 'media sequence is the empty string'); + // }); test('media sequence is high (non-zero in first file) in the playlist', function() { var @@ -562,63 +789,63 @@ equal(data.allowCache, 'YES', 'EXT-X-ALLOW-CACHE should default to YES.'); }); - test('test EXT-X-ALLOW-CACHE empty, default to YES', function() { - var - playlistTemplate = Handlebars.compile(window.playlist_allow_cache), - testData = {version: 4, allowCache: ''}, - playlistData = playlistTemplate(testData), - data = m3u8parser.parse(playlistData); - - notEqual(data, null, 'data is not NULL'); - // notEqual(data.invalidReasons, null, 'invalidReasons is not NULL'); - // equal(data.invalidReasons.length, 1, 'data has 1 invalid reasons'); - // equal(data.invalidReasons[0], 'Invalid EXT-X-ALLOW-CACHE value: \'\''); - equal(data.allowCache, 'YES', 'EXT-X-ALLOW-CACHE should default to YES.'); - }); - - test('test EXT-X-ALLOW-CACHE missing, default to YES', function() { - var - playlistTemplate = Handlebars.compile(window.playlist_allow_cache), - testData = {version: 4}, - playlistData = playlistTemplate(testData), - data = m3u8parser.parse(playlistData); - - notEqual(data, null, 'data is not NULL'); - // notEqual(data.invalidReasons, null, 'invalidReasons is not NULL'); - // equal(data.invalidReasons.length, 1, 'No EXT-X-ALLOW-CACHE specified. Default: YES.'); - equal(data.allowCache, 'YES', 'EXT-X-ALLOW-CACHE should default to YES'); - }); + // test('test EXT-X-ALLOW-CACHE empty, default to YES', function() { + // var + // playlistTemplate = Handlebars.compile(window.playlist_allow_cache), + // testData = {version: 4, allowCache: ''}, + // playlistData = playlistTemplate(testData), + // data = m3u8parser.parse(playlistData); + + // notEqual(data, null, 'data is not NULL'); + // // notEqual(data.invalidReasons, null, 'invalidReasons is not NULL'); + // // equal(data.invalidReasons.length, 1, 'data has 1 invalid reasons'); + // // equal(data.invalidReasons[0], 'Invalid EXT-X-ALLOW-CACHE value: \'\''); + // equal(data.allowCache, 'YES', 'EXT-X-ALLOW-CACHE should default to YES.'); + // }); + + // test('test EXT-X-ALLOW-CACHE missing, default to YES', function() { + // var + // playlistTemplate = Handlebars.compile(window.playlist_allow_cache), + // testData = {version: 4}, + // playlistData = playlistTemplate(testData), + // data = m3u8parser.parse(playlistData); + + // notEqual(data, null, 'data is not NULL'); + // // notEqual(data.invalidReasons, null, 'invalidReasons is not NULL'); + // // equal(data.invalidReasons.length, 1, 'No EXT-X-ALLOW-CACHE specified. Default: YES.'); + // equal(data.allowCache, 'YES', 'EXT-X-ALLOW-CACHE should default to YES'); + // }); - test('test EXT-X-BYTERANGE valid', function() { - var - playlistTemplate = Handlebars.compile(window.playlist_byte_range), - testData = {version: 4, byteRange: '522828,0', byteRange1: '587500,522828', byteRange2: '44556,8353216'}, - playlistData = playlistTemplate(testData), - data = m3u8parser.parse(playlistData); - - notEqual(data, null, 'data is not NULL'); - //notEqual(data.invalidReasons, null, 'invalidReasons is not NULL'); - //equal(data.invalidReasons.length, 0, 'Errors object should be empty.'); - //TODO: Validate the byteRange info - equal(data.segments.length, 16, '16 segments should have been parsed.'); - equal(data.segments[0].byterange, testData.byteRange, 'byteRange incorrect.'); - equal(data.segments[1].byterange, testData.byteRange1, 'byteRange1 incorrect.'); - equal(data.segments[15].byterange, testData.byteRange2, 'byteRange2 incorrect.'); - }); - - test('test EXT-X-BYTERANGE used but version is < 4', function() { - var - playlistTemplate = Handlebars.compile(window.playlist_byte_range), - testData = {version: 3, byteRange: ['522828,0'], byteRange1: ['587500,522828'], byteRange2: ['44556,8353216']}, - playlistData = playlistTemplate(testData), - data = m3u8parser.parse(playlistData); - - notEqual(data, null, 'data is not NULL'); - equal(data.segments.length, 16, '16 segments should have been parsed.'); - // notEqual(data.invalidReasons, null, 'there should be an error'); - // equal(data.invalidReasons.length, 1, 'there should be 1 error'); - // //TODO: Validate the byteRange info - // equal(data.invalidReasons[0], 'EXT-X-BYTERANGE used but version is < 4.');x - }); + // test('test EXT-X-BYTERANGE valid', function() { + // var + // playlistTemplate = Handlebars.compile(window.playlist_byte_range), + // testData = {version: 4, byteRange: '522828,0', byteRange1: '587500,522828', byteRange2: '44556,8353216'}, + // playlistData = playlistTemplate(testData), + // data = m3u8parser.parse(playlistData); + + // notEqual(data, null, 'data is not NULL'); + // //notEqual(data.invalidReasons, null, 'invalidReasons is not NULL'); + // //equal(data.invalidReasons.length, 0, 'Errors object should be empty.'); + // //TODO: Validate the byteRange info + // equal(data.segments.length, 16, '16 segments should have been parsed.'); + // equal(data.segments[0].byterange, testData.byteRange, 'byteRange incorrect.'); + // equal(data.segments[1].byterange, testData.byteRange1, 'byteRange1 incorrect.'); + // equal(data.segments[15].byterange, testData.byteRange2, 'byteRange2 incorrect.'); + // }); + + // test('test EXT-X-BYTERANGE used but version is < 4', function() { + // var + // playlistTemplate = Handlebars.compile(window.playlist_byte_range), + // testData = {version: 3, byteRange: ['522828,0'], byteRange1: ['587500,522828'], byteRange2: ['44556,8353216']}, + // playlistData = playlistTemplate(testData), + // data = m3u8parser.parse(playlistData); + + // notEqual(data, null, 'data is not NULL'); + // equal(data.segments.length, 16, '16 segments should have been parsed.'); + // // notEqual(data.invalidReasons, null, 'there should be an error'); + // // equal(data.invalidReasons.length, 1, 'there should be 1 error'); + // // //TODO: Validate the byteRange info + // // equal(data.invalidReasons[0], 'EXT-X-BYTERANGE used but version is < 4.');x + // }); })(window, window.console); diff --git a/test/video-js-hls.html b/test/video-js-hls.html index 5245eec1..c74b7538 100644 --- a/test/video-js-hls.html +++ b/test/video-js-hls.html @@ -22,6 +22,7 @@ +