|
|
@@ -0,0 +1,274 @@
|
|
|
+var path = require('path'),
|
|
|
+ util = require('util'),
|
|
|
+ carrier = require('carrier'),
|
|
|
+ debug = require('debug')('hls:m3u8');
|
|
|
+
|
|
|
+exports.M3U8Playlist = M3U8Playlist;
|
|
|
+exports.M3U8Segment = M3U8Segment;
|
|
|
+exports.ParserError = ParserError;
|
|
|
+
|
|
|
+exports.parse = parse;
|
|
|
+//exports.stringify = stringify;
|
|
|
+
|
|
|
+function M3U8Playlist() {
|
|
|
+ this.variant = false;
|
|
|
+
|
|
|
+ // initialize to default values
|
|
|
+ this.version = 1; // V1
|
|
|
+ this.allow_cache = true;
|
|
|
+ this.i_frames_only = false; // V4
|
|
|
+ this.target_duration = undefined;
|
|
|
+ this.first_seq_no = 0;
|
|
|
+ this.type = undefined; // V?
|
|
|
+ this.ended = false;
|
|
|
+
|
|
|
+ this.segments = [];
|
|
|
+
|
|
|
+ // for variant streams
|
|
|
+ this.programs = {};
|
|
|
+ this.groups = {};
|
|
|
+}
|
|
|
+
|
|
|
+M3U8Playlist.prototype.PlaylistType = {
|
|
|
+ EVENT: 'EVENT',
|
|
|
+ VOD: 'VOD'
|
|
|
+};
|
|
|
+
|
|
|
+M3U8Playlist.prototype.totalDuration = function() {
|
|
|
+ return this.segments.reduce(function(sum, segment) {
|
|
|
+ return sum + segment.duration;
|
|
|
+ }, 0);
|
|
|
+};
|
|
|
+
|
|
|
+M3U8Playlist.prototype.isLive = function() {
|
|
|
+ return !(this.ended || this.type === this.PlaylistType.VOD);
|
|
|
+};
|
|
|
+
|
|
|
+M3U8Playlist.prototype.startSeqNo = function() {
|
|
|
+ if (!this.isLive()) return this.first_seq_no;
|
|
|
+
|
|
|
+ var duration = this.target_duration * 3;
|
|
|
+ for (var i=this.segments.length-1; i>0; i--) {
|
|
|
+ duration -= this.segments[i].duration;
|
|
|
+ if (duration < 0) break;
|
|
|
+ }
|
|
|
+ // TODO: validate that correct seqNo is returned
|
|
|
+ return this.first_seq_no + i;
|
|
|
+};
|
|
|
+
|
|
|
+M3U8Playlist.prototype.lastSeqNo = function() {
|
|
|
+ return this.first_seq_no + this.segments.length - 1;
|
|
|
+};
|
|
|
+
|
|
|
+M3U8Playlist.prototype.getSegment = function(seqNo) {
|
|
|
+ // TODO: should we check for number type and throw if not?
|
|
|
+ var index = seqNo-this.first_seq_no;
|
|
|
+ if (index < 0 || index > this.segments.length)
|
|
|
+ return null;
|
|
|
+ return this.segments[index];
|
|
|
+};
|
|
|
+
|
|
|
+M3U8Playlist.prototype.toString = function() {
|
|
|
+ // TODO:
|
|
|
+ return 'M3U8Playlist';
|
|
|
+};
|
|
|
+
|
|
|
+function M3U8Segment(uri, meta, version) {
|
|
|
+ this.duration = meta.duration;
|
|
|
+ this.title = meta.title;
|
|
|
+ this.uri = uri;
|
|
|
+ this.discontinuity = meta.discontinuity || false;
|
|
|
+
|
|
|
+ // optional
|
|
|
+ if (meta.program_time)
|
|
|
+ this.program_time = meta.program_time;
|
|
|
+ if (meta.key)
|
|
|
+ this.key = meta.key;
|
|
|
+
|
|
|
+ if (version >= 5 && meta.map)
|
|
|
+ this.map = meta.map;
|
|
|
+}
|
|
|
+
|
|
|
+M3U8Segment.prototype.toString = function() {
|
|
|
+ // TODO: support writing all the information
|
|
|
+ return '#EXTINF '+this.duration.toFixed(3)+','+this.title + '\n' + this.uri + '\n';
|
|
|
+};
|
|
|
+
|
|
|
+function parse(stream, cb) {
|
|
|
+ var m3u8 = new M3U8Playlist(),
|
|
|
+ line_no = 0,
|
|
|
+ meta = {};
|
|
|
+
|
|
|
+ var cr = carrier.carry(stream);
|
|
|
+ cr.on('line', ParseLine);
|
|
|
+ cr.on('end', Complete);
|
|
|
+
|
|
|
+ function ReportError(err) {
|
|
|
+ cr.removeListener('line', ParseLine);
|
|
|
+ cr.removeListener('end', Complete);
|
|
|
+ cb(err);
|
|
|
+ }
|
|
|
+
|
|
|
+ function Complete() {
|
|
|
+ debug('result', m3u8);
|
|
|
+ cb(null, m3u8);
|
|
|
+ }
|
|
|
+
|
|
|
+ function ParseExt(cmd, arg) {
|
|
|
+ if (!(cmd in extParser))
|
|
|
+ return false;
|
|
|
+
|
|
|
+ debug('parsing ext', cmd, arg);
|
|
|
+ extParser[cmd](arg);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // AttrList's are currently handled without any implicit knowledge of key/type mapping
|
|
|
+ function ParseAttrList(input) {
|
|
|
+ // TODO: handle newline escapes in quoted-string's
|
|
|
+ var re = /(.+?)=((?:\".*?\")|.*?)(?:,|$)/g;
|
|
|
+// var re = /(.+?)=(?:(?:\"(.*?)\")|(.*?))(?:,|$)/g;
|
|
|
+ var match, attrs = {};
|
|
|
+ while ((match = re.exec(input)) !== null)
|
|
|
+ attrs[match[1].toLowerCase()] = match[2];
|
|
|
+
|
|
|
+ debug('parsed attributes', attrs);
|
|
|
+ return attrs;
|
|
|
+ }
|
|
|
+
|
|
|
+ function unquote(str) {
|
|
|
+ return str.slice(1,-1);
|
|
|
+ }
|
|
|
+
|
|
|
+ function ParseLine(line) {
|
|
|
+ line_no += 1;
|
|
|
+
|
|
|
+ if (line_no === 1) {
|
|
|
+ if (line !== '#EXTM3U')
|
|
|
+ return ReportError(new ParserError('Missing required #EXTM3U header', line, line_no));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!line.length) return; // blank lines are ignored (3.1)
|
|
|
+
|
|
|
+ if (line[0] === '#') {
|
|
|
+ var matches = line.match(/^(#EXT[^:]*):?(.*)/);
|
|
|
+ if (!matches)
|
|
|
+ return debug('ignoring comment', line);
|
|
|
+
|
|
|
+ var cmd = matches[1],
|
|
|
+ arg = matches[2];
|
|
|
+
|
|
|
+ if (!ParseExt(cmd, arg))
|
|
|
+ return ReportError(new ParserError('Unknown #EXT: '+cmd, line, line_no));
|
|
|
+ } else if (m3u8.variant) {
|
|
|
+ var id = meta.info['program-id'];
|
|
|
+ if (!(id in m3u8.programs))
|
|
|
+ m3u8.programs[id] = [];
|
|
|
+
|
|
|
+ meta.uri = line;
|
|
|
+ m3u8.programs[id].push(meta);
|
|
|
+ meta = {};
|
|
|
+ } else {
|
|
|
+ if (!('duration' in meta))
|
|
|
+ return ReportError(new ParserError('Missing #EXTINF before media file URI', line, line_no));
|
|
|
+
|
|
|
+ m3u8.segments.push(new M3U8Segment(line, meta, m3u8.version));
|
|
|
+ meta = {};
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // TODO: add more validation logic
|
|
|
+ var extParser = {
|
|
|
+ '#EXT-X-VERSION': function(arg) {
|
|
|
+ m3u8.version = parseInt(arg, 10);
|
|
|
+
|
|
|
+ if (m3u8.version >= 4)
|
|
|
+ for (var attrname in extParserV4) { extParser[attrname] = extParser[attrname]; }
|
|
|
+ if (m3u8.version >= 5)
|
|
|
+ for (var attrname in extParserV5) { extParser[attrname] = extParser[attrname]; }
|
|
|
+ },
|
|
|
+ '#EXT-X-TARGETDURATION': function(arg) {
|
|
|
+ m3u8.target_duration = parseInt(arg, 10);
|
|
|
+ },
|
|
|
+ '#EXT-X-ALLOW-CACHE': function(arg) {
|
|
|
+ m3u8.allow_cache = (arg!=='NO');
|
|
|
+ },
|
|
|
+ '#EXT-X-MEDIA-SEQUENCE': function(arg) {
|
|
|
+ m3u8.first_seq_no = parseInt(arg, 10);
|
|
|
+ },
|
|
|
+ '#EXT-X-PLAYLIST-TYPE': function(arg) {
|
|
|
+ m3u8.type = arg;
|
|
|
+ },
|
|
|
+ '#EXT-X-ENDLIST': function(arg) {
|
|
|
+ m3u8.ended = true;
|
|
|
+ },
|
|
|
+
|
|
|
+ '#EXTINF': function(arg) {
|
|
|
+ var n = arg.split(',');
|
|
|
+ meta.duration = parseFloat(n.shift());
|
|
|
+ meta.title = n.join(',');
|
|
|
+
|
|
|
+ if (meta.duration <= 0)
|
|
|
+ return ReportError(new ParserError('Invalid duration', line, line_no));
|
|
|
+ },
|
|
|
+ '#EXT-X-KEY': function(arg) {
|
|
|
+ meta.key = ParseAttrList(arg);
|
|
|
+ },
|
|
|
+ '#EXT-X-PROGRAM-DATE-TIME': function(arg) {
|
|
|
+ meta.program_time = new Date(arg);
|
|
|
+ },
|
|
|
+ '#EXT-X-DISCONTINUITY': function(arg) {
|
|
|
+ meta.discontinuity = true;
|
|
|
+ },
|
|
|
+
|
|
|
+ // variant
|
|
|
+ '#EXT-X-STREAM-INF': function(arg) {
|
|
|
+ m3u8.variant = true;
|
|
|
+ meta.info = ParseAttrList(arg);
|
|
|
+ },
|
|
|
+ // variant v4 since variant streams are not required to specify version
|
|
|
+ '#EXT-X-MEDIA': function(arg) {
|
|
|
+ //m3u8.variant = true;
|
|
|
+ var attrs = ParseAttrList(arg),
|
|
|
+ id = unquote(attrs['group-id']);
|
|
|
+
|
|
|
+ if (!(id in m3u8.groups)) {
|
|
|
+ m3u8.groups[id] = [];
|
|
|
+ m3u8.groups[id].type = attrs.type;
|
|
|
+ }
|
|
|
+ m3u8.groups[id].push(attrs);
|
|
|
+ },
|
|
|
+ '#EXT-X-I-FRAME-STREAM-INF': function(arg) {
|
|
|
+ m3u8.variant = true;
|
|
|
+ debug('not yet supported', '#EXT-X-I-FRAME-STREAM-INF');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ var extParserV4 = {
|
|
|
+ '#EXT-X-I-FRAMES-ONLY': function(arg) {
|
|
|
+ m3u8.i_frames_only = true;
|
|
|
+ },
|
|
|
+ '#EXT-X-BYTERANGE': function(arg) {
|
|
|
+ var n = arg.split('@');
|
|
|
+ meta.byterange = {length:parseInt(n[0], 10)};
|
|
|
+ if (n.length > 1)
|
|
|
+ meta.byterange.offset = parseInt(n[1], 10);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var extParserV5 = {
|
|
|
+ '#EXT-X-MAP': function(arg) {
|
|
|
+ meta.map = ParseAttrList(arg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function ParserError(msg, line, line_no, constr) {
|
|
|
+ Error.captureStackTrace(this, constr || this);
|
|
|
+ this.message = msg || 'Error';
|
|
|
+ this.line = line;
|
|
|
+ this.line_no = line_no;
|
|
|
+}
|
|
|
+util.inherits(ParserError, Error);
|
|
|
+ParserError.prototype.name = 'Parser Error';
|