diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..acb3b84 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore index 3c3629e..db906bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,37 @@ +.idea +.DS_Store + +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history diff --git a/m3u.js b/m3u.js index ce4cbd8..28869b6 100644 --- a/m3u.js +++ b/m3u.js @@ -1,3 +1,11 @@ +var util = {}; +util.isNumber = function (n) { + return !isNaN(parseFloat(n)) && isFinite(n); +}; +util.isDate = function (d) { + return d instanceof Date && !isNaN(d.valueOf()); +}; + var M3U = module.exports = function M3U() { this.items = { PlaylistItem: [], @@ -33,20 +41,50 @@ M3U.prototype.set = function setProperty(key, value) { M3U.prototype.addItem = function addItem(item) { this.items[item.constructor.name].push(item); - return this; }; M3U.prototype.addPlaylistItem = function addPlaylistItem(data) { - this.items.PlaylistItem.push(M3U.PlaylistItem.create(data)); + var newItem = M3U.PlaylistItem.create(data); + this.maybeSetTargetDuration(newItem.get('duration')); + this.items.PlaylistItem.push(newItem); +}; + +M3U.prototype.insertPlaylistItemsAfter = function insertPlaylistItemsAfter (newItems, afterItem) { + var index = this.items.PlaylistItem.length; + var self = this; + + if (!(afterItem instanceof M3U.PlaylistItem)) { + afterItem = M3U.PlaylistItem.create(afterItem); + } + + newItems = [].concat(newItems).map(function(newItem) { + if (!(newItem instanceof M3U.PlaylistItem)) { + newItem = M3U.PlaylistItem.create(newItem); + } + self.maybeSetTargetDuration(newItem.get('duration')); + return newItem; + }); + + this.items.PlaylistItem.some(function(item, i) { + if (item.properties.uri == afterItem.properties.uri) { + index = i; + return true; + } + }); + + this.items.PlaylistItem = this.items.PlaylistItem.slice(0, index + 1).concat(newItems).concat(this.items.PlaylistItem.slice(index + 1)); + this.resetTargetDuration(true); + return this; }; M3U.prototype.removePlaylistItem = function removePlaylistItem(index) { - if (index < this.items.PlaylistItem.length && index >= 0){ + if (index < this.items.PlaylistItem.length && index >= 0) { this.items.PlaylistItem.splice(index, 1); } else { throw new RangeError('M3U PlaylistItem out of range'); } + this.resetTargetDuration(true); }; M3U.prototype.addMediaItem = function addMediaItem(data) { @@ -79,23 +117,485 @@ M3U.prototype.totalDuration = function totalDuration() { }, 0); }; -M3U.prototype.merge = function merge(m3u) { - if (m3u.get('targetDuration') > this.get('targetDuration')) { - this.set('targetDuration', m3u.get('targetDuration')); +// if one is passed in, try it, if none is passed, iterate and find it. +M3U.prototype.resetTargetDuration = function resetTargetDuration (newTargetDuration) { + var self = this; + + // if you just want to set it to a number, don't use this function, nor maybeSetTargetDuration, just use this.set('targetDuration', Math.round(newTargetDuration)) + + if (util.isNumber(newTargetDuration)) { + this.maybeSetTargetDuration(newTargetDuration); + } else { + // force reset, so we set it to 0, this way, the 1st item will set it. + if (newTargetDuration === true) { + this.set('targetDuration', 0); + } + this.items.PlaylistItem.forEach(function(item) { + self.maybeSetTargetDuration(item.get('duration')); + }); + } + return this.get('targetDuration'); +}; + +M3U.prototype.maybeSetTargetDuration = function maybeSetTargetDuration (newTargetDuration) { + // round to nearest integer https://tools.ietf.org/html/draft-pantos-http-live-streaming-19#section-4.3.3.1 + newTargetDuration = Math.round(newTargetDuration); + + if (newTargetDuration > this.get('targetDuration')) { + this.set('targetDuration', newTargetDuration); + } +}; + +M3U.prototype.concat = function concat (m3u) { + var clone = this.clone(); + + if (m3u.items.PlaylistItem[0]) { + m3u.items.PlaylistItem[0].set('discontinuity', true); + } + + clone.items.PlaylistItem = clone.items.PlaylistItem.concat(m3u.items.PlaylistItem); + + if (m3u.isVOD()) { + clone.set('playlistType', 'VOD'); + } else { + delete clone.properties['playlistType']; + clone.set('foundEndlist', false); + } + + clone.resetTargetDuration(m3u.get('targetDuration')); + + return clone; +}; + +// backward-compatible merge function, that just concats and mutates self +// todo: remove this, since it's really not a merge, it's just a concat() +M3U.prototype.merge = function merge (m3u) { + var clone = this.concat(m3u); + this.items.PlaylistItem = clone.items.PlaylistItem; + this.resetTargetDuration(clone.get('targetDuration')); + return this; +}; + +// todo: too O(n^2) too slow - maybe use a hash of URIs +M3U.prototype.mergeByUri = function mergeByUri (m3u) { + var clone = this.concat(m3u); + + // todo: also, i don't think this is correct + if (m3u.get('mediaSequence') < clone.get('mediaSequence')) { + clone.set('mediaSequence', m3u.get('mediaSequence')); + } + var uri0 = ((m3u.items.PlaylistItem[0] || {}).properties || {}).uri; + + var segments = clone.items.PlaylistItem; + + for(var i = 0; i < segments.length; ++i) { + for(var j= i + 1; j < segments.length; ++j) { + if(segments[i].properties.uri == segments[j].properties.uri) { + if (uri0 == segments[j].properties.uri) { + segments[i].set('discontinuity', true); + } + segments.splice(j--, 1); + } else { + clone.maybeSetTargetDuration(segments[i].get('duration')); + } + } + } + + if (m3u.isVOD()) { + clone.set('playlistType', 'VOD'); + } else { + delete clone.properties['playlistType']; + clone.set('foundEndlist', false); + } + + return clone; +}; + +M3U.prototype.mergeByDate = function mergeByDate (m3u, options) { + + var clone = this.clone(m3u); + + options = options || {}; + + var len = clone.items.PlaylistItem.length; + var item0, itemN, dateA0, dateAN, m3uPre, m3uPost; + + if (len) { + item0 = clone.items.PlaylistItem[0]; + dateA0 = item0.get('date'); + itemN = clone.items.PlaylistItem[clone.items.PlaylistItem.length - 1]; + dateAN = itemN.get('date'); + } + + m3uPre = dateA0 ? m3u.sliceByDate(null, new Date((+new Date(dateA0)))) : M3U.create(); + m3uPost = dateAN ? m3u.sliceByDate(new Date((+new Date(dateAN)) + itemN.get('duration') * 1000)) : M3U.create(); + + var gaps = clone.findDateGaps(options); + gaps.forEach(function(gap) { + var m3uGap = m3u.sliceByDate(new Date(gap.starts), new Date(gap.ends)); + + if (m3uGap.items.PlaylistItem.length) { + m3uGap.items.PlaylistItem[0] && m3uGap.items.PlaylistItem[0].set('discontinuity', true); + gap.beforeItem.set('discontinuity', true); + clone.insertPlaylistItemsAfter(m3uGap.items.PlaylistItem, gap.afterItem); + } + }); + + if (m3uPre.items.PlaylistItem.length) { + clone.items.PlaylistItem[0] && clone.items.PlaylistItem[0].set('discontinuity', true); + } + + if (m3uPost.items.PlaylistItem.length) { + m3uPost.items.PlaylistItem[0].set('discontinuity', true); + } + + var result = m3uPre.concat(clone).concat(m3uPost); + + var m3uTail = m3uPost.items.PlaylistItem.length ? m3uPost : clone.items.PlaylistItem.length ? clone : m3uPre; + if (m3uTail.isVOD()) { + result.set('playlistType', 'VOD'); + } else { + delete result.properties['playlistType']; + result.set('foundEndlist', false); + } + + return result; +}; + +M3U.prototype.sortByDate = function sortByDate () { + if (! this.isDateSupported()) { + return this; } - m3u.items.PlaylistItem[0].set('discontinuity', true); - this.items.PlaylistItem = this.items.PlaylistItem.concat(m3u.items.PlaylistItem); + this.items.PlaylistItem.sort(function(playlistItem1, playlistItem2) { + var d1 = playlistItem1.properties.date; + var d2 = playlistItem2.properties.date; + + return d1 < d2 ? -1 : d1 > d2 ? 1 : 0; + }); + + return this; +}; + +M3U.prototype.sortByUri = function sortByUri (options) { + options = options || {}; + + this.items.PlaylistItem.sort(function(playlistItem1, playlistItem2) { + var u1 = playlistItem1.properties.uri; + var u2 = playlistItem2.properties.uri; + + if (!options.useFullPath) { + u1 = u1.split('/').pop(); + u2 = u2.split('/').pop(); + } + + return u1 < u2 ? -1 : u1 > u2 ? 1 : 0; + }); return this; }; -M3U.prototype.toString = function toString() { +M3U.prototype.findDateGaps = function findDateGaps (options) { + options = options || {}; + options.msMargin = options.msMargin == null ? 1500 : options.msMargin; + + var gaps = []; + var segments = this.items.PlaylistItem; + var that = this; + + segments.forEach(function(item, i) { + var itemNext = segments[i + 1]; + + var se = itemStartsEnds(item); + var seNext = itemStartsEnds(itemNext); + + if (seNext && (seNext.starts - se.ends > options.msMargin)) { + var duration = (seNext.starts - se.ends) / 1000; + gaps.push({ + index: i + 1, + starts: se.ends, + ends: seNext.starts, + duration: duration, + approximateMissingItems: duration / that.get('targetDuration'), + beforeItem: itemNext, + afterItem: item + }); + } + }); + + return gaps; +}; + +M3U.prototype.sliceByIndex = M3U.prototype.slice = function sliceByIndex (start, end) { + var m3u = this.clone(); + + if (start == null && end == null) { + return m3u; + } + + var len = m3u.items.PlaylistItem.length; + + start = !start || start < 0 ? 0 : start; + if (end == null || end > len) { + end = len; + } + + if (m3u.isLive()) { + if (start < len && end < len) { + // if live and both start & end were within the length of the stream, make it look like a VOD + m3u.set('playlistType', 'VOD'); + + } else if (start > 0 && end == len) { + /* + One thing I noticed recently is that if we are implementing a LIVE sliding window, we can't have a `playlistType`, (otherwise Safari refuses to play more than 1 segment) + + https://tools.ietf.org/html/draft-pantos-http-live-streaming-07#page-19 + > the Playlist file MAY contain an EXT-X-PLAYLIST-TYPE tag + > with a value of either EVENT or VOD. If the tag is present and has a + > value of EVENT, the server MUST NOT change or delete any part of the + > Playlist file (although it MAY append lines to it) + + So, If we slice an m3u and `isLive()` is true, and the `start` value of slicing is greater than 0, then we need to remove the playlistType + */ + delete m3u.properties['playlistType']; + } + } + + var mediaSequence = m3u.get('mediaSequence'); + // assume 1 if it doesn't exists https://tools.ietf.org/html/draft-pantos-http-live-streaming-01#section-3.1.2 + if (!mediaSequence || mediaSequence < 0) { + mediaSequence = 1; + } + m3u.set('mediaSequence', mediaSequence + start); + + m3u.items.PlaylistItem = m3u.items.PlaylistItem.slice(start, end); + m3u.resetTargetDuration(true); + + return m3u; +}; + +M3U.prototype.sliceBySeconds = function sliceBySeconds (from, to) { + var start = null; + var end = null; + + var total = 0; + + if (util.isNumber(from) && util.isNumber(to) && from > to) { + throw 'target `to` value, if truthy, must be greater than the `from` value'; + } + + if (!this.items.PlaylistItem.length) { + return this.sliceByIndex(); + } + + var duration = this.totalDuration(); + if (util.isNumber(from) && from > duration) { + start = this.items.PlaylistItem.length; + } + + if (util.isNumber(to) && to <= 0) { + end = 0; + } + + var currentIndex = 0; + + this.items.PlaylistItem.some(function(item, i) { + total += item.properties.duration; + currentIndex = i; + + if (from != null && total >= from && start == null) { // left-side-inclusive + start = i; + if (to == null) { + return true; + } + } + + if (to != null && total >= to && end == null) { + // we're adding the +1 here to include the current segment, + // it is still considered exclusive, since the current value here is the total duration, so the end of each segment + end = i + 1; + return true; + } + }); + + return this.sliceByIndex(start, end); +}; + +M3U.prototype.sliceByDate = function sliceByDate (from, to) { + var start = null; + var end = null; + + if (!util.isDate(from) && !util.isDate(to)) { + throw new Error('at least 1 of the arguments needs to be a Date object'); + } + + if (!this.items.PlaylistItem.length) { + return this.sliceByIndex(); + } + + if (util.isNumber(from)) { + from = new Date(to.getTime() - from * 1000); + } else if (util.isNumber(to)) { + to = new Date(from.getTime() + to * 1000); + } + + var firstDate = ((this.items.PlaylistItem[0] || {}).properties || {}).date; + var lastDate = ((this.items.PlaylistItem[this.items.PlaylistItem.length - 1] || {}).properties || {}).date; + + if (!firstDate || !lastDate) { + throw new Error('Playlist segments do not look like that they have a valid date fields, you must specify EXT-X-PROGRAM-DATE-TIME for each segment in order to sliceDate(), or set the date on your own using the beforeItemEmit hook when you setup the parser.'); + } + + if (util.isDate(from) && util.isDate(to) && from > to) { + throw new Error('target `to` date value, if available, must be greater than the `from` date value'); + } + + if (!from) { + from = new Date(firstDate.getTime() - 1); + } + + if (!to) { + to = new Date(lastDate.getTime() + 1); + } + + if (from > lastDate) { + start = this.items.PlaylistItem.length; + } + + if (to <= firstDate) { + end = 0; + } + + from = from && from.valueOf ? from.valueOf() : from; + to = to && to.valueOf ? to.valueOf(): to; + + var currentStart; + var currentEnd; + + this.items.PlaylistItem.some(function(item, i) { + currentStart = new Date(item.properties.date); + currentEnd = new Date(item.properties.date); + currentEnd.setSeconds(currentStart.getSeconds() + item.properties.duration); + + currentStart = currentEnd.valueOf(); + currentEnd = currentEnd.valueOf(); + + if (from != null && currentEnd >= from && start == null) { // right-side-inclusive + start = currentEnd == from ? i + 1 : i; // still exclude directly behind segment + if (to == null) { + return true; + } + } + + if (to != null && currentEnd >= to && end == null) { + // we're adding the +1 here to include the current segment, + // it is still considered exclusive, since the current = date + duration, so the end of each segment + end = currentStart >= to ? i + 1 : i; + return true; + } + }); + + return this.sliceByIndex(start, end); +}; + +M3U.prototype.isRangeWithinBounds = function isRangeWithinBounds (unit, from, to) { + switch (unit) { + case 'date': + return this.isRangeWithinDateBounds(from, to); + case 'seconds': + return this.isRangeWithinSecondsBounds(from, to); + case 'index': + return this.isRangeWithinIndexBounds(from, to); + } +}; + +M3U.prototype.isRangeWithinIndexBounds = function isRangeWithinIndexBounds (from, to) { + + var len = this.items.PlaylistItem.length; + + if (!len) { + return false; + } + + var left = true; + var right = true; + + if (from != null) { + left = !!this.items.PlaylistItem[from]; + } + + if (to != null) { + right = !!this.items.PlaylistItem[to]; + } + + return left && right; +}; + +M3U.prototype.isRangeWithinSecondsBounds = function isRangeWithinSecondsBounds (from, to) { + + var len = this.items.PlaylistItem.length; + + if (!len) { + return false; + } + + var left = true; + var right = true; + + if (from != null) { + left = 0 <= from; + } + + if (to != null) { + right = to <= this.totalDuration(); + } + + return left && right; +}; + +M3U.prototype.isRangeWithinDateBounds = function isRangeWithinDateBounds (from, to) { + + if (!util.isDate(from) && !util.isDate(to)) { + throw new Error('at least 1 of the arguments needs to be a Date object'); + } + + if (util.isNumber(from)) { + from = new Date(to.getTime() - from * 1000); + } else if (util.isNumber(to)) { + to = new Date(from.getTime() + to * 1000); + } + + var len = this.items.PlaylistItem.length; + + if (!len) { + return false; + } + + var left = true; + var right = true; + + if (from != null) { + left = this.items.PlaylistItem[0].properties.date <= from; + } + + if (to != null) { + right = this.items.PlaylistItem[len - 1].properties.date <= to; + } + + return left && right; +}; + +M3U.prototype.toString = function toString () { var self = this; var output = ['#EXTM3U']; + Object.keys(this.properties).forEach(function(key) { var tagKey = propertyMap.findByKey(key); var tag = tagKey ? tagKey.tag : key; + if (toStringIgnoredProperties[key]) { + return; + } + if (dataTypes[key] == 'boolean') { output.push('#' + tag); } else { @@ -106,7 +606,7 @@ M3U.prototype.toString = function toString() { if (this.items.PlaylistItem.length) { output.push(this.items.PlaylistItem.map(itemToString).join('\n')); - if (this.get('playlistType') === 'VOD') { + if (this.isVOD()) { output.push('#EXT-X-ENDLIST'); } } else { @@ -124,18 +624,49 @@ M3U.prototype.toString = function toString() { return output.join('\n') + '\n'; }; -M3U.prototype.serialize = function serialize() { - var object = { properties: this.properties, items: {} }; - var self = this; + +M3U.prototype.isDateSupported = function isDateSupported () { + var date = ((this.items.PlaylistItem[0] || {}).properties || {}).date; + return date ? util.isDate(date) : undefined; +}; + +M3U.prototype.isVOD = function isVOD () { + return this.get('foundEndlist') || this.get('playlistType') === 'VOD'; +}; + +M3U.prototype.isLive = function isLive () { + return !this.isVOD(); +}; + +M3U.prototype.isMaster = function isMaster () { + return !! (this.items.StreamItem.length || this.items.MediaItem.length || this.items.IframeStreamItem.length); +}; + +M3U.prototype.clone = function clone () { + return M3U.unserialize(this.serialize()); +}; + +M3U.prototype.toJSON = function toJSON () { + var object = this.serialize(); + object.properties.totalDuration = this.totalDuration(); + return object; +}; + +M3U.prototype.serialize = function serialize () { + var object = { properties: JSON.parse(JSON.stringify(this.properties)), items: {} }; + + var self = this; Object.keys(this.items).forEach(function(constructor) { object.items[constructor] = self.items[constructor].map(serializeItem); }); return object; }; -M3U.unserialize = function unserialize(object) { +M3U.unserialize = function unserialize (object) { var m3u = new M3U; m3u.properties = object.properties; + delete m3u.properties.totalDuration; + Object.keys(object.items).forEach(function(constructor) { m3u.items[constructor] = object.items[constructor].map( Item.unserialize.bind(null, M3U[constructor]) @@ -144,49 +675,72 @@ M3U.unserialize = function unserialize(object) { return m3u; }; -function itemToString(item) { +function itemStartsEnds (item) { + if (!item) { + return; + } + + var date = item.get('date'); + if (!util.isDate(date) || !date) { + throw new Error('Playlist segments do not look like that they have a valid date fields, you must specify EXT-X-PROGRAM-DATE-TIME for each segment in order to sliceDate(), or set the date on your own using the beforeItemEmit hook when you setup the parser.'); + } + + var starts = date.getTime(); + return { + starts: starts, + ends: starts + (item.get('duration') * 1000) + } +} + +function itemToString (item) { return item.toString(); } -function serializeItem(item) { +function serializeItem (item) { return item.serialize(); } var coerce = { - boolean: function coerceBoolean(value) { + boolean: function coerceBoolean (value) { return true; }, - integer: function coerceInteger(value) { + integer: function coerceInteger (value) { return parseInt(value, 10); }, - unknown: function coerceUnknown(value) { + unknown: function coerceUnknown (value) { return value; } }; +var toStringIgnoredProperties = { + foundEndlist : true +}; + var dataTypes = { - iframesOnly : 'boolean', - targetDuration : 'integer', - mediaSequence : 'integer', - version : 'integer' + iframesOnly : 'boolean', + targetDuration : 'integer', + mediaSequence : 'integer', + discontinuitySequence : 'integer', + version : 'integer' }; var propertyMap = [ - { tag: 'EXT-X-ALLOW-CACHE', key: 'allowCache' }, - { tag: 'EXT-X-I-FRAMES-ONLY', key: 'iframesOnly' }, - { tag: 'EXT-X-MEDIA-SEQUENCE', key: 'mediaSequence' }, - { tag: 'EXT-X-PLAYLIST-TYPE', key: 'playlistType' }, - { tag: 'EXT-X-TARGETDURATION', key: 'targetDuration' }, - { tag: 'EXT-X-VERSION', key: 'version' } + { tag: 'EXT-X-ALLOW-CACHE', key: 'allowCache' }, + { tag: 'EXT-X-I-FRAMES-ONLY', key: 'iframesOnly' }, + { tag: 'EXT-X-MEDIA-SEQUENCE', key: 'mediaSequence' }, + { tag: 'EXT-X-DISCONTINUITY-SEQUENCE', key: 'discontinuitySequence' }, + { tag: 'EXT-X-PLAYLIST-TYPE', key: 'playlistType' }, + { tag: 'EXT-X-TARGETDURATION', key: 'targetDuration' }, + { tag: 'EXT-X-VERSION', key: 'version' } ]; -propertyMap.findByTag = function findByTag(tag) { +propertyMap.findByTag = function findByTag (tag) { return propertyMap[propertyMap.map(function(tagKey) { return tagKey.tag; }).indexOf(tag)]; }; -propertyMap.findByKey = function findByKey(key) { +propertyMap.findByKey = function findByKey (key) { return propertyMap[propertyMap.map(function(tagKey) { return tagKey.key; }).indexOf(key)]; diff --git a/m3u/AttributeList.js b/m3u/AttributeList.js index e32b6d6..ab8028e 100644 --- a/m3u/AttributeList.js +++ b/m3u/AttributeList.js @@ -56,7 +56,7 @@ AttributeList.prototype.toString = function toString() { }; AttributeList.prototype.serialize = function serialize() { - return this.attributes; + return JSON.parse(JSON.stringify(this.attributes)); }; AttributeList.unserialize = function unserialize(object) { diff --git a/m3u/Item.js b/m3u/Item.js index 9c4f56e..41ef65b 100644 --- a/m3u/Item.js +++ b/m3u/Item.js @@ -31,9 +31,12 @@ Item.prototype.set = function set(key, value) { }; Item.prototype.serialize = function serialize() { + var attrs = JSON.parse(JSON.stringify(this.properties)); + attrs.date = attrs.date ? new Date(attrs.date) : attrs.date; + return { attributes : this.attributes.serialize(), - properties : this.properties + properties : attrs } }; diff --git a/package.json b/package.json index 886673b..5d465ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name" : "m3u8", - "version" : "0.0.6", + "version" : "0.0.7", "description" : "streaming m3u8 parser for Apple's HTTP Live Streaming protocol", "main" : "./parser.js", "keywords" : [ diff --git a/parser.js b/parser.js index d4d3f4d..af23fb3 100644 --- a/parser.js +++ b/parser.js @@ -9,12 +9,14 @@ var util = require('util'), // used for splitting strings by commas not within double quotes var NON_QUOTED_COMMA = /,(?=(?:[^"]|"[^"]*")*$)/; -var m3uParser = module.exports = function m3uParser() { +var m3uParser = module.exports = function m3uParser(options) { ChunkedStream.apply(this, ['\n', true]); this.linesRead = 0; this.m3u = new M3U; + this.options = options || {}; + this.on('data', this.parse.bind(this)); var self = this; this.on('end', function() { @@ -26,22 +28,36 @@ util.inherits(m3uParser, ChunkedStream); m3uParser.M3U = M3U; -m3uParser.createStream = function() { - return new m3uParser; +m3uParser.createStream = function(options) { + return new m3uParser(options); }; m3uParser.prototype.parse = function parse(line) { line = line.trim(); + if (this.linesRead == 0) { - if (line != '#EXTM3U') { + var extm3uSkipped = false; + + if (line != '#EXTM3U' && !this.options.lax) { return this.emit('error', new Error( 'Non-valid M3U file. First line: ' + line )); } + if (line != '#EXTM3U' && this.options.lax) { + extm3uSkipped = true; + } this.linesRead++; + + if (!extm3uSkipped) { + return true; + } + } + + if (['', '#EXT-X-ENDLIST'].indexOf(line) > -1) { + this.m3u.set('foundEndlist', true); return true; } - if (['', '#EXT-X-ENDLIST'].indexOf(line) > -1) return true; + if (line.indexOf('#') == 0) { this.parseLine(line); } else { @@ -49,6 +65,11 @@ m3uParser.prototype.parse = function parse(line) { this.addItem(new PlaylistItem); } this.currentItem.set('uri', line); + + if (typeof this.options.beforeItemEmit == 'function') { + this.currentItem = this.options.beforeItemEmit(this.currentItem); + } + this.emit('item', this.currentItem); } this.linesRead++; @@ -81,11 +102,19 @@ m3uParser.prototype['EXTINF'] = function parseInf(data) { this.currentItem.set('discontinuity', true); this.playlistDiscontinuity = false; } + if (this.playlistDate) { + this.currentItem.set('date', this.playlistDate); + this.playlistDate = null; + } +}; + +m3uParser.prototype['EXT-X-PROGRAM-DATE-TIME'] = function parseInf(data) { + this.playlistDate = new Date(data); }; m3uParser.prototype['EXT-X-DISCONTINUITY'] = function parseInf() { this.playlistDiscontinuity = true; -} +}; m3uParser.prototype['EXT-X-BYTERANGE'] = function parseByteRange(data) { this.currentItem.set('byteRange', data); @@ -107,7 +136,7 @@ m3uParser.prototype['EXT-X-MEDIA'] = function(data) { m3uParser.prototype.parseAttributes = function parseAttributes(data) { data = data.split(NON_QUOTED_COMMA); - var self = this; + return data.map(function(attribute) { var keyValue = attribute.split(/=(.+)/).map(function(str) { return str.trim(); diff --git a/test/m3u.test.js b/test/m3u.test.js index d163f7b..55f6f5f 100644 --- a/test/m3u.test.js +++ b/test/m3u.test.js @@ -4,6 +4,7 @@ var fs = require('fs'), should = require('should'); describe('m3u', function() { + describe('#set', function() { it('should set property on m3u', function() { var m3u = getM3u(); @@ -117,8 +118,8 @@ describe('m3u', function() { }); }); - describe('#merge', function() { - it('should merge PlaylistItems from two m3us, creating a discontinuity', function() { + describe('#concat', function() { + it('should concat PlaylistItems from two m3us and return a new m3u, creating a discontinuity', function() { var m3u1 = getM3u(); m3u1.addPlaylistItem({}); @@ -129,12 +130,48 @@ describe('m3u', function() { m3u2.addPlaylistItem({}); m3u2.addPlaylistItem({}); + var itemWithDiscontinuity = m3u2.items.PlaylistItem[0]; + m3u1 = m3u1.merge(m3u2); + itemWithDiscontinuity.get('discontinuity').should.be.true; + m3u1.items.PlaylistItem.filter(function(item) { + return item.get('discontinuity'); + }).length.should.eql(1); + }); + + it('should use the largest targetDuration', function() { + var m3u1 = getM3u(); + m3u1.set('targetDuration', 10); + m3u1.addPlaylistItem({}); + + var m3u2 = getM3u(); + m3u2.set('targetDuration', 11); + m3u2.addPlaylistItem({}); + m3u1 = m3u1.concat(m3u2); + m3u1.get('targetDuration').should.eql(11); + }); + }); + + describe('#merge', function() { + it('should just merge (as in concat) PlaylistItems from two m3us by self mutating the current m3u, creating a discontinuity', function() { + var m3u1 = getM3u(); + + m3u1.addPlaylistItem({uri: 'a'}); + m3u1.addPlaylistItem({uri: 'b'}); + m3u1.addPlaylistItem({uri: 'c'}); + + var m3u2 = getM3u(); + m3u2.addPlaylistItem({uri: 'c'}); + m3u2.addPlaylistItem({uri: 'd'}); + var itemWithDiscontinuity = m3u2.items.PlaylistItem[0]; m3u1.merge(m3u2); + itemWithDiscontinuity.get('discontinuity').should.be.true; m3u1.items.PlaylistItem.filter(function(item) { return item.get('discontinuity'); }).length.should.eql(1); + + m3u1.items.PlaylistItem.length.should.eql(5); }); it('should use the largest targetDuration', function() { @@ -150,6 +187,138 @@ describe('m3u', function() { }); }); + describe('#mergeByUri', function() { + it('should uniquely merge PlaylistItems from two m3us using URIs, creating a discontinuity', function() { + var m3u1 = getM3u(); + + m3u1.addPlaylistItem({uri: 'a'}); + m3u1.addPlaylistItem({uri: 'b'}); + m3u1.addPlaylistItem({uri: 'c'}); + + var m3u2 = getM3u(); + m3u2.addPlaylistItem({uri: 'c'}); + m3u2.addPlaylistItem({uri: 'd'}); + + var itemWithDiscontinuity = m3u2.items.PlaylistItem[0]; + m3u1 = m3u1.mergeByUri(m3u2); + + itemWithDiscontinuity.get('discontinuity').should.be.true; + m3u1.items.PlaylistItem.filter(function(item) { + return item.get('discontinuity'); + }).length.should.eql(1); + + m3u1.items.PlaylistItem.length.should.eql(4); + }); + }); + + describe('#mergeByDate', function() { + it('should uniquely merge PlaylistItems from two m3us using Dates, creating some discontinuities', function() { + var m3u1 = getM3u(); + var ms0 = +new Date() - (24 * 60 * 60 * 1000); + + m3u1.addPlaylistItem({uri: 'a.3', date: new Date(ms0), duration: 10}); + m3u1.addPlaylistItem({uri: 'a.4', date: new Date(ms0 + 10000), duration: 10}); + m3u1.addPlaylistItem({uri: 'a.6', date: new Date(ms0 + 30000), duration: 10}); + + var m3u2 = getM3u(); + m3u2.addPlaylistItem({uri: 'b.1', date: new Date(ms0 - 20000), duration: 10}); + m3u2.addPlaylistItem({uri: 'b.2', date: new Date(ms0 - 10000), duration: 10}); + m3u2.addPlaylistItem({uri: 'b.5', date: new Date(ms0 + 20000), duration: 10}); + m3u2.addPlaylistItem({uri: 'b.6', date: new Date(ms0 + 30000), duration: 10}); + m3u2.addPlaylistItem({uri: 'b.7', date: new Date(ms0 + 40000), duration: 10}); + + m3u1 = m3u1.mergeByDate(m3u2); + + m3u1.items.PlaylistItem.length.should.eql(7); + + m3u1.items.PlaylistItem[0].get('uri').should.be.eql('b.1'); + m3u1.items.PlaylistItem[1].get('uri').should.be.eql('b.2'); + m3u1.items.PlaylistItem[2].get('uri').should.be.eql('a.3'); + m3u1.items.PlaylistItem[3].get('uri').should.be.eql('a.4'); + m3u1.items.PlaylistItem[4].get('uri').should.be.eql('b.5'); + m3u1.items.PlaylistItem[5].get('uri').should.be.eql('a.6'); + m3u1.items.PlaylistItem[6].get('uri').should.be.eql('b.7'); + + m3u1.items.PlaylistItem[2].get('discontinuity').should.be.true; + m3u1.items.PlaylistItem[4].get('discontinuity').should.be.true; + m3u1.items.PlaylistItem[5].get('discontinuity').should.be.true; + m3u1.items.PlaylistItem[6].get('discontinuity').should.be.true; + }); + }); + + describe('#sliceByIndex', function() { + it('should slice from 1 index to another', function() { + var m3u1 = getM3u(); + + m3u1.addPlaylistItem({}); + m3u1.addPlaylistItem({}); + m3u1.addPlaylistItem({}); + m3u1.addPlaylistItem({}); + m3u1.set('mediaSequence', 5); + + var m3u2 = m3u1.sliceByIndex(1, 3); + + m3u2.get('mediaSequence').should.eql(6); + m3u2.items.PlaylistItem.length.should.eql(2); + }); + }); + + describe('#sliceBySeconds', function() { + it('should sliceBySeconds from a specific `second` to another', function() { + var m3u1 = getM3u(); + + m3u1.addPlaylistItem({duration: 5}); + m3u1.addPlaylistItem({duration: 5}); + m3u1.addPlaylistItem({duration: 5}); + m3u1.addPlaylistItem({duration: 5}); + + var m3u2 = m3u1.sliceBySeconds(5, 15); + m3u2.items.PlaylistItem.length.should.eql(3); + + }); + }); + + describe('#sliceByDate', function() { + it('should sliceByDate from a date to another', function() { + var m3u1 = getM3u(); + + var ms0 = +new Date(); + var duration = 10; + + var len = 4; + for (var i = 0; i < len; i++) { + m3u1.addPlaylistItem({date: new Date(ms0 + (duration * i * 1000)), duration: duration}); + } + + var m3uA = m3u1.sliceByDate(new Date(ms0 + 7000), new Date(ms0 + 17000)); + m3uA.items.PlaylistItem.length.should.eql(2); + + var m3uB = m3u1.sliceByDate(new Date(ms0 + 10000), new Date(ms0 + 20000)); + m3uB.items.PlaylistItem.length.should.eql(1); + + var m3uC = m3u1.sliceByDate(new Date(ms0 + 10000), new Date(ms0 + 31000)); + m3uC.items.PlaylistItem.length.should.eql(3); + + var m3uD = m3u1.sliceByDate(new Date(ms0 + 11000), new Date(ms0 + 21000)); + m3uD.items.PlaylistItem.length.should.eql(2); + + var m3uE = m3u1.sliceByDate(new Date(ms0 + 11000), new Date(ms0 + 20000)); + m3uE.items.PlaylistItem.length.should.eql(1); + + var m3u2 = m3u1.sliceByDate(new Date(ms0 + 10000), new Date(ms0 + 21000)); + m3u2.items.PlaylistItem[0].properties.date.valueOf().should.eql((new Date(ms0 + (duration /* *1 */ * 1000))).valueOf()); + m3u2.items.PlaylistItem[m3u2.items.PlaylistItem.length - 1].properties.date.valueOf().should.eql((new Date(ms0 + (duration * 2 * 1000))).valueOf()); + m3u2.items.PlaylistItem.length.should.eql(2); + + var m3u3 = m3u1.sliceByDate(new Date(ms0 + 10001), new Date(ms0 + 20000)); + m3u3.items.PlaylistItem.length.should.eql(1); + + var m3u4 = m3u1.sliceByDate(new Date(ms0 + 10000), new Date(ms0 + 20001)); + m3u4.items.PlaylistItem.length.should.eql(2); + + }); + }); + describe('#serialize', function(done) { it('should return an object containing items and properties', function(done) { getVariantM3U(function(error, m3u) { @@ -183,6 +352,28 @@ describe('m3u', function() { }); }); + describe('clone', function() { + it('should return a new M3U object with the same items and properties', function() { + var item = new M3U.PlaylistItem({ key: 'uri', value: '/path' }); + var data = { + properties: { + targetDuration: 10 + }, + items: { + PlaylistItem: [ item.serialize() ] + } + }; + var m3u = M3U.unserialize(data); + m3u.properties.should.eql(data.properties); + item.should.eql(m3u.items.PlaylistItem[0]); + + var m3u1 = m3u.clone(); + m3u1.properties.should.eql(data.properties); + item.should.eql(m3u1.items.PlaylistItem[0]); + + }); + }); + describe('writeVOD', function() { it('should return a string ending with #EXT-X-ENDLIST', function() { var m3u1 = getM3u(); @@ -198,6 +389,7 @@ describe('m3u', function() { describe('writeLive', function() { it('should return a string not ending with #EXT-X-ENDLIST', function() { var m3u1 = getM3u(); + m3u1.set('playlistType', 'EVENT'); m3u1.addPlaylistItem({}); var output = m3u1.toString(); @@ -207,9 +399,7 @@ describe('m3u', function() { }); function getM3u() { - var m3u = M3U.create(); - - return m3u; + return M3U.create(); } function getVariantM3U(callback) { diff --git a/test/parser.test.js b/test/parser.test.js index 4ffda67..a282e60 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -13,6 +13,54 @@ describe('parser', function() { parser.write('NOT VALID\n'); }); + describe('#options.lax', function() { + it('should forgive if #EXTM3U is not there, this is useful for mergin large and live tails of m3u8', function(done) { + var parser = getParser({lax: true}); + var text = '' + // + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:10\n' + + '#EXT-X-VERSION:4\n' + + '#EXTINF:10,\n' + + '1.ts\n' + + '#EXTINF:10,\n' + + '2.ts\n'; + + parser.on('m3u', function() { + done(); + }); + + parser.write(text); + parser.end(); + }); + }); + + describe('#options.beforeItemEmit', function() { + it('should call beforeItemEmit hook before item emit', function(done) { + var called = 0; + + var parser = getParser({beforeItemEmit: function() { + called++; + }}); + + var text = '' + + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:10\n' + + '#EXT-X-VERSION:4\n' + + '#EXTINF:10,\n' + + '1.ts\n' + + '#EXTINF:10,\n' + + '2.ts\n'; + + parser.on('m3u', function() { + called.should.eql(2); + done(); + }); + + parser.write(text); + parser.end(); + }); + }); + describe('#parseLine', function() { it('should call known tags', function() { var parser = getParser(); @@ -82,6 +130,21 @@ describe('parser', function() { }); }); + describe('#EXT-X-PROGRAM-DATE-TIME', function() { + it('should parse date value on subsequent playlist item', function() { + var parser = getParser(); + + var d = (new Date()).toISOString(); + + parser['EXT-X-PROGRAM-DATE-TIME'](d); + parser.EXTINF('4.5,some title'); + parser.currentItem.constructor.name.should.eql('PlaylistItem'); + parser.currentItem.get('duration').should.eql(4.5); + parser.currentItem.get('title').should.eql('some title'); + parser.currentItem.get('date').toISOString().should.eql(d); + }); + }); + describe('#EXT-X-STREAM-INF', function() { it('should create a new Stream item', function() { var parser = getParser(); @@ -126,8 +189,6 @@ describe('parser', function() { }); }); -function getParser() { - var parser = m3u8.createStream(); - - return parser; +function getParser(options) { + return m3u8.createStream(options); }