浏览代码

import current work

Gil Pedersen 13 年之前
当前提交
0b6c5c2ffa
共有 13 个文件被更改,包括 972 次插入0 次删除
  1. 48 0
      README.md
  2. 64 0
      bin/hlsdump.js
  3. 39 0
      bin/hlsmon.js
  4. 4 0
      index.js
  5. 274 0
      lib/m3u8.js
  6. 203 0
      lib/reader.js
  7. 65 0
      lib/tsblast.js
  8. 102 0
      lib/tslimit.js
  9. 30 0
      package.json
  10. 15 0
      test/fixtures/enc.m3u8
  11. 9 0
      test/fixtures/variant.m3u8
  12. 12 0
      test/fixtures/variant_v4.m3u8
  13. 107 0
      test/m3u8.test.js

+ 48 - 0
README.md

@@ -0,0 +1,48 @@
+#  HTTP Live Streaming tools
+
+This package aims to provide useful tools to consume and produce Apple™ HTTP Live Streaming (HLS) compatible media streams according to the [draft standard](http://tools.ietf.org/html/draft-pantos-http-live-streaming).
+
+## Tools
+ * `hlsdump` fetch a live or on-demand single-bitrate stream
+ * `hlsmon` monitor a live or on-demand single-bitrate stream
+
+## Installation
+		$ npm install git+https://github.com/kanongil/node-hls-tools.git
+
+## TODO
+ * More tools.
+ * More tests.
+ * More docs.
+ * Better error handling.
+
+## Testing
+		$ npm test
+
+## License
+(The BSD License)
+
+Copyright (c) 2013, Gil Pedersen <gpdev@gpost.dk>  
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without  
+modification, are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright  
+   notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright  
+   notice, this list of conditions and the following disclaimer in the  
+   documentation and/or other materials provided with the distribution.
+ * Neither the name of the author nor the  
+   names of its contributors may be used to endorse or promote products  
+   derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND  
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED  
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE  
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER BE LIABLE FOR ANY  
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES  
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;  
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND  
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT  
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS  
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.  

+ 64 - 0
bin/hlsdump.js

@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+
+var hlsdump = require('commander');
+hlsdump.version('0.0.0')
+   .usage('[options] <url>')
+   .option('-o, --output <path>', 'target file')
+   .option('-u, --udp [host:port]', 'relay TS over UDP', function(val) {
+     var r = { host:'localhost', port:1234 };
+     if (val) {
+       var s = val.split(':');
+       if (s.length === 1) {
+         r.port = parseInt(s[0], 10);
+       } else {
+         r.host = s[0];
+         r.port = parseInt(s[1], 10);
+       }
+     }
+     return r;
+   })
+   .option('-s, --sync', 'clock sync using stream PCR')
+   .option('-a, --user-agent <string>', 'HTTP User-Agent')
+   .parse(process.argv);
+
+var util = require('util'),
+    url = require('url'),
+    fs = require('fs');
+
+var reader = require('../lib/reader'),
+    tslimit = require('../lib/tslimit'),
+    tsblast = require('../lib/tsblast');
+
+var src = process.argv[2];
+if (!src) return hlsdump.help();
+
+var r = reader(src);
+
+var time = 0;
+r.on('segment', function(seqNo, duration, meta) {
+//  console.error(new Date().toJSON() + sep + meta.size + sep + duration.toFixed(3) + sep + (meta.size / (duration * 1024/8)).toFixed(3));
+  console.error('new segment at '+time.toFixed(0)+' seconds, avg bitrate (kbps):', (meta.size / (duration * 1024/8)).toFixed(1));
+  time += duration;
+});
+
+r.on('end', function() {
+  console.error('done');
+});
+
+var stream = r;
+if (hlsdump.sync)
+  stream = stream.pipe(tslimit());
+
+if (hlsdump.udp)
+  stream.pipe(tsblast(1234));
+
+if (hlsdump.output) {
+  var dst;
+  if (hlsdump.output === '-')
+    dst = process.stdout;
+  else
+    dst = fs.createWriteStream(hlsdump.output);
+
+  if (dst)
+    stream.pipe(dst);
+}

+ 39 - 0
bin/hlsmon.js

@@ -0,0 +1,39 @@
+#!/usr/bin/env node
+
+var hlsmon = require('commander');
+hlsmon.version('0.0.0')
+   .usage('[options] <url>')
+   .option('-a', '--user-agent <string>', 'User-Agent')
+   .parse(process.argv);
+
+var util = require('util'),
+    url = require('url');
+
+var reader = require('../lib/reader');
+
+var src = process.argv[2];
+var sep = ';';
+
+function monitor(srcUrl) {
+  var r = reader(srcUrl, {noData:true});
+
+  var time = 0;
+  r.on('segment', function(seqNo, duration, file) {
+    console.log(file.modified.toJSON() + sep + file.size + sep + duration.toFixed(3) + sep + (file.size / (duration * 1024/8)).toFixed(3));
+  //  console.error('new segment at '+time.toFixed(0)+' seconds, avg bitrate (kbps):', (file.size / (duration * 1024/8)).toFixed(1));
+    time += duration;
+  });
+
+  r.on('end', function() {
+    if (r.index.variant) {
+      var newUrl = url.resolve(r.baseUrl, r.index.programs['1'][0].uri);
+      console.error('found variant index, using: ', newUrl)
+      return monitor(newUrl);
+    }
+    console.error('done');
+  });
+
+  r.resume();
+}
+
+monitor(src);

+ 4 - 0
index.js

@@ -0,0 +1,4 @@
+module.exports = {
+  m3u8: require('./lib/m3u8'),
+  reader: require('./lib/reader')
+};

+ 274 - 0
lib/m3u8.js

@@ -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';

+ 203 - 0
lib/reader.js

@@ -0,0 +1,203 @@
+// stream from hls source
+
+var util = require('util'),
+    url = require('url'),
+    assert = require('assert');
+
+var async = require('async'),
+    http = require('http-get'),
+    debug = require('debug')('hls:reader'),
+    Readable = require('readable-stream'),
+    Transform = require('readable-stream/transform');
+
+var m3u8 = require('./m3u8');
+
+var DEFAULT_AGENT = util.format('hls-tools/v%s (http://github.com/kanongil/node-hls-tools) node.js/%s', '0.0.0', process.version);
+
+module.exports = hlsreader;
+hlsreader.HlsStreamReader = HlsStreamReader;
+
+/*
+options:
+  startSeq*
+  noData // don't emit any data - useful for analyzing the stream structure
+
+  maxRedirects*
+  cacheDir*
+  headers* // allows for custom user-agent, cookies, auth, etc
+  
+emits:
+  index (m3u8)
+  segment (seqNo, duration, datetime, size?, )
+*/
+
+function getFileStream(srcUrl, options, cb) {
+  assert(srcUrl.protocol);
+
+  if (typeof options === 'function') {
+    cb = options;
+    options = {};
+  }
+
+  if (srcUrl.protocol === 'http:' || srcUrl.protocol === 'https:') {
+    var headers = options.headers || {};
+    if (!headers['user-agent']) headers['user-agent'] = DEFAULT_AGENT;
+
+    (options.probe ? http.head : http.get)({url:url.format(srcUrl), stream:true, headers:headers}, function(err, res) {
+    	if (err) return cb(err);
+
+      var statusCode = res.code || res.stream.statusCode;
+      if (statusCode !== 200) {
+        if (res.stream)
+          res.stream.destroy();
+        return cb(new Error('Bad server response code: '+statusCode));
+      }
+
+      var typeparts = /^(.+?\/.+?)(?:;\w*.*)?$/.exec(res.headers['content-type']) || [null, 'application/octet-stream'],
+          mimetype = typeparts[1].toLowerCase(),
+          size = res.headers['content-length'] ? parseInt(res.headers['content-length'], 10) : -1,
+          modified = res.headers['last-modified'] ? new Date(res.headers['last-modified']) : null;
+
+      if (res.stream)
+        res.stream.resume(); // for some reason http-get pauses the stream for the callback
+      cb(null, res.stream, {url:res.url || srcUrl, mime:mimetype, size:size, modified:modified});
+    });
+  } else {
+    process.nextTick(function() {
+      cb(new Error('Unsupported protocol: '+srcUrl.protocol));
+    });
+  }
+
+/*    if (srcUrl.protocol === 'file:') {
+      
+  } else if (srcUrl.protocol === 'data:') {
+    //var regex = /^data:(.+\/.+);base64,(.*)$/;
+    // add content-type && content-length headers
+  } else {
+      
+  }*/
+}
+
+function HlsStreamReader(src, options) {
+  var self = this;
+  Transform.call(this, options);
+
+  if (typeof src === 'string')
+    src = url.parse(src);
+
+  this.url = src;
+  this.baseUrl = src;
+  this.options = options || {};
+
+  this.indexStream = null;
+  this.index = null;
+
+  this.readState = {
+    currentSeq:-1,
+    currentSegment:null,
+    readable:null
+  }
+
+  function updateindex() {
+    getFileStream(self.url, function(err, stream, meta) {
+      if (err) return self.emit('error', err);
+
+      if (meta.mime !== 'application/vnd.apple.mpegurl' &&
+          meta.mime !== 'application/x-mpegurl' && meta.mime !== 'audio/mpegurl')
+        return self.emit('error', new Error('Invalid MIME type: '+meta.mime));
+      // FIXME: correctly handle .m3u us-ascii encoding
+
+      self.baseUrl = meta.url;
+      m3u8.parse(stream, function(err, index) {
+        if (err) return self.emit('error', err);
+
+        var updated = true;
+        if (self.index && self.index.lastSeqNo() === index.lastSeqNo()) {
+          debug('index was not updated');
+          updated = false;
+        }
+
+        self.index = index;
+
+        if (updated) {
+          if (self.readState.currentSeq===-1)
+            self.readState.currentSeq = index.startSeqNo();
+
+          self.emit('index', index);
+
+          if (index.variant)
+            return self.end();
+
+          if (!self.readState.currentSegment) {
+            self.readState.currentSegment = index.getSegment(self.readState.currentSeq);
+            if (self.readState.currentSegment)
+              fetchfrom(self.readState.currentSegment);
+            else if (index.ended)
+              self.end();
+          }
+        }
+
+        if (!index.ended) {
+          var updateInterval = updated ? index.segments[index.segments.length-1].duration : self.index.target_duration / 2;
+          debug('scheduling index refresh', updateInterval);
+          setTimeout(updateindex, Math.max(1, updateInterval)*1000);
+        }
+      });
+    });
+  }
+  updateindex();
+
+  function fetchfrom(segment) {
+    var segmentUrl = url.resolve(self.baseUrl, segment.uri)
+
+    debug('fetching segment', segmentUrl);
+    getFileStream(url.parse(segmentUrl), {probe:!!self.options.noData}, function(err, stream, meta) {
+      if (err) return self.emit('error', err);
+
+      if (meta.mime !== 'video/mp2t'/* && 
+          meta.mime !== 'audio/aac' && meta.mime !== 'audio/x-aac' &&
+          meta.mime !== 'audio/ac3'*/)
+        return self.emit('error', new Error('Unsupported segment MIME type: '+meta.mime));
+
+      self.emit('segment', self.readState.currentSeq, segment.duration, meta);
+
+      function nextstream() {
+        self.readState.currentSeq++;
+        self.readState.currentSegment = self.index.getSegment(self.readState.currentSeq);
+        if (self.readState.currentSegment)
+          fetchfrom(self.readState.currentSegment);
+        else if (self.index.ended)
+          self.end();
+      }
+
+      if (stream) {
+        var r = stream;
+        if (!(stream instanceof Readable)) {
+          r = new Readable();
+          r.wrap(stream);
+        }
+        self.readState.readable = r;
+        r.pipe(self, {end:false});
+
+        r.on('end', function() {
+          r.unpipe(self);
+          process.nextTick(nextstream);
+        });
+      } else {
+        process.nextTick(nextstream);
+      }
+    });
+  }
+
+  return this;
+}
+util.inherits(HlsStreamReader, Transform);
+
+HlsStreamReader.prototype._transform = function(chunk, output, cb) {
+  // TODO: decrypt here
+  cb(null, chunk);
+};
+
+function hlsreader(url, options) {
+  return new HlsStreamReader(url, options);
+}

+ 65 - 0
lib/tsblast.js

@@ -0,0 +1,65 @@
+var dgram = require('dgram'),
+    util = require('util'),
+    assert = require('assert');
+
+var async = require('async'),
+    Writable = require('readable-stream/writable');
+
+module.exports = tsblast;
+exports.TsBlast = TsBlast;
+
+function TsBlast(dst, options) {
+  var self = this;
+  Writable.call(this, options);
+
+  if (typeof dst === 'number')
+    dst = {port:dst, host:'localhost'};
+
+  this.dst = dst;
+  this.options = options || {};
+
+  this.buffer = new Buffer(0);
+  this.client = dgram.createSocket('udp4');
+
+  this.on('finish', function() {
+    this.client.close();
+  });
+
+  return this;
+}
+util.inherits(TsBlast, Writable);
+
+TsBlast.prototype._write = function(chunk, cb) {
+  var self = this;
+
+  if (chunk) {
+    if (this.buffer.length)
+      this.buffer = Buffer.concat([this.buffer, chunk]);
+    else
+      this.buffer = chunk;
+  }
+
+  var index = 0, psize = 188*7;
+
+  function sendnext() {
+    if ((self.buffer.length - index) >= psize) {
+      self.client.send(self.buffer, index, psize, self.dst.port, self.dst.host, function(err, bytes) {
+        index += psize;
+        sendnext();
+      });
+    } else {
+      /*    if (!chunk) {
+            self.client.send(self.buffer, index, self.buffer.length - index, self.dst.port, self.dst.host);
+            index = self.buffer.length;
+          }*/
+      if (index) self.buffer = self.buffer.slice(index);
+      cb();
+    }
+  }
+
+  sendnext();
+};
+
+function tsblast(dst, options) {
+  return new TsBlast(dst, options);
+}

+ 102 - 0
lib/tslimit.js

@@ -0,0 +1,102 @@
+var util = require('util'),
+    assert = require('assert');
+
+var async = require('async'),
+    Transform = require('readable-stream/transform');
+
+// TODO: ratelimit the individual bytes
+
+module.exports = tslimit;
+exports.TsLimit = TsLimit;
+
+function parsePCR(packet) {
+  var head = packet.readUInt32BE(0, true);
+//  var b = packet.readUInt8(3, true);
+  var pid = (head >> 8) & 0x1fff;
+  if (((head >> 5) & 1) !== 1) return -1;
+
+  var s = packet.readUInt8(4, true);
+  if (s < 7) return -1;
+
+  var f = packet.readUInt8(5, true);
+  if (((f >> 4) & 1) !== 1) return -1;
+
+  var base = packet.readUInt32BE(6, true) * 2;
+  var ext = packet.readUInt32BE(10, true);
+
+  base += (ext >> 31);
+  ext = ext & 0x1ff;
+
+  return base / 0.09 + ext / 27; // return usecs
+}
+
+function TsLimit() {
+  var self = this;
+  Transform.call(this);
+
+  // the buffer is only used for partial TS packets ()< 188 bytes)
+  this.buffer = new Buffer(0);
+
+  this.pcr = -1;
+  this.last = null;
+
+  this.pcr2time = function(pcr) {
+    if (self.pcr === -1) {
+      self.pcr = pcr;
+      self.last = utime();
+    }
+
+    var pcr_delta = pcr - self.pcr;
+    if (pcr_delta < 0) pcr_delta += (0x200000000 * 300) / 27;
+
+    return self.last + pcr_delta;
+  }
+
+  return this;
+}
+util.inherits(TsLimit, Transform);
+
+function utime() {
+  var t = process.hrtime(); // based on CLOCK_MONOTONIC, and thus accommodates local drift (but apparently not suspend)
+  return t[0] * 1E6 + t[1] / 1E3;
+}
+
+TsLimit.prototype._transform = function(chunk, output, cb) {
+  var self = this;
+  this.buffer = Buffer.concat([this.buffer, chunk]);
+
+  function process() {
+    var buf = self.buffer;
+    var end = buf.length-188-1;
+    var now = utime();
+    for (var i=0; i<end; i+=188) {
+      var pcr = parsePCR(buf.slice(i, i+188));
+      if (pcr !== -1) {
+        var pcrtime = self.pcr2time(pcr);
+        if (pcrtime > now)
+          break;
+      }
+    }
+
+    // TODO: limit output speed
+    if (i) output(buf.slice(0, i));
+    self.buffer = buf.slice(i);
+
+    if (i < end) {
+      // TODO: calculate timeout?
+      return setTimeout(process, 5);
+    }
+    cb();
+  }
+
+  process();
+};
+
+TsLimit.prototype._flush = function(output, cb) {
+  if (this.buffer.length) output(this.buffer);
+  cb();
+};
+
+function tslimit() {
+  return new TsLimit();
+}

+ 30 - 0
package.json

@@ -0,0 +1,30 @@
+{
+  "name": "hls-tools",
+  "version": "0.0.0",
+  "description": "HTTP Live Streaming (HLS) tools",
+  "keywords": ["hls", "m3u8", "streaming"],
+	"main": "index.js",
+  "scripts": {
+    "test": "mocha -R list"
+  },
+  "directories": {
+    "test": "test"
+  },
+  "repository": {
+		"type" : "git", "url": "http://github.com/kanongil/node-hls-tools.git"
+	},
+  "author": "Gil Pedersen <gpdev@gpost.dk>",
+  "license": "BSD",
+  "dependencies": {
+    "debug": "~0.7.0",
+    "carrier": "~0.1.8",
+    "commander": "~1.1.1",
+    "async": "~0.1.22",
+    "readable-stream": "~0.2.0",
+    "http-get": "~0.5.2"
+  },
+  "devDependencies": {
+    "mocha": "~1.7.4",
+    "should": "~1.2.1"
+  }
+}

+ 15 - 0
test/fixtures/enc.m3u8

@@ -0,0 +1,15 @@
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-MEDIA-SEQUENCE:7794
+#EXT-X-TARGETDURATION:15
+#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"
+#EXTINF:2.833,
+http://media.example.com/fileSequence52-A.ts
+#EXTINF:15.0,
+http://media.example.com/fileSequence52-B.ts
+#EXTINF:13.333,
+http://media.example.com/fileSequence52-C.ts
+#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=53"
+#EXTINF:15.0,
+http://media.example.com/fileSequence53-A.ts
+

+ 9 - 0
test/fixtures/variant.m3u8

@@ -0,0 +1,9 @@
+#EXTM3U
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000
+http://example.com/low.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000
+http://example.com/mid.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,CODECS="avc,mp4a.40.5"
+http://example.com/hi.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5"
+http://example.com/audio-only.m3u8

+ 12 - 0
test/fixtures/variant_v4.m3u8

@@ -0,0 +1,12 @@
+#EXTM3U
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="English",DEFAULT=YES,AUTOSELECT=YES,LANGUAGE="en",URI="main/english-audio.m3u8"
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Deutsche",DEFAULT=NO,AUTOSELECT=YES,LANGUAGE="de",URI="main/german-audio.m3u8"
+#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Commentary",DEFAULT=NO,AUTOSELECT=NO,URI="commentary/audio-only.m3u8"
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,CODECS="...",AUDIO="aac"
+low/video-only.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000,CODECS="...",AUDIO="aac"
+mid/video-only.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,CODECS="...",AUDIO="aac"
+hi/video-only.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5",AUDIO="aac"
+main/english-audio.m3u8

+ 107 - 0
test/m3u8.test.js

@@ -0,0 +1,107 @@
+var fs = require('fs'),
+    path = require('path'),
+    should = require('should');
+
+var m3u8 = require('..').m3u8;
+
+var fixtureDir = path.join(__dirname, 'fixtures');
+
+describe('M3U8', function() {
+
+  it('should parse a valid file', function(done) {
+  	var stream = fs.createReadStream(path.join(fixtureDir, 'enc.m3u8'));
+    m3u8.parse(stream, function(err, index) {
+      should.not.exist(err);
+      should.exist(index);
+      index.variant.should.be.false;
+      done();
+    });
+  })
+
+  it('should parse a basic variant file', function(done) {
+  	var stream = fs.createReadStream(path.join(fixtureDir, 'variant.m3u8'));
+    m3u8.parse(stream, function(err, index) {
+      should.not.exist(err);
+      should.exist(index);
+      index.variant.should.be.true;
+      done();
+    });
+  })
+
+  it('should parse an advanced variant file', function(done) {
+  	var stream = fs.createReadStream(path.join(fixtureDir, 'variant_v4.m3u8'));
+    m3u8.parse(stream, function(err, index) {
+      should.not.exist(err);
+      should.exist(index);
+      index.variant.should.be.true;
+      done();
+    });
+  })
+
+})
+
+describe('M3U8Playlist', function() {
+  var testIndex = null;
+  var variantIndex = null;
+
+  before(function(done) {
+  	var stream = fs.createReadStream(path.join(fixtureDir, 'enc.m3u8'));
+    m3u8.parse(stream, function(err, index) {
+      should.not.exist(err);
+      testIndex = index;
+      done();
+    });
+  })
+
+  before(function(done) {
+  	var stream = fs.createReadStream(path.join(fixtureDir, 'variant_v4.m3u8'));
+    m3u8.parse(stream, function(err, index) {
+      should.not.exist(err);
+      variantIndex = index;
+      done();
+    });
+  })
+
+  describe('#totalDuration()', function() {
+    it('should calculate total of all segments durations', function() {
+      testIndex.totalDuration().should.equal(46.166);
+      variantIndex.totalDuration().should.equal(0);
+    })
+  })
+
+  describe('#isLive()', function() {
+    it('should return true when no #EXT-X-ENDLIST is present', function() {
+      testIndex.ended.should.be.false;
+      testIndex.isLive().should.be.true;
+    })
+  })
+
+  describe('#startSeqNo()', function() {
+    it('should return the sequence number to start streaming from', function() {
+      testIndex.startSeqNo().should.equal(7794);
+      variantIndex.startSeqNo().should.equal(-1);
+    })
+  })
+
+  describe('#lastSeqNo()', function() {
+    it('should return the sequence number of the final segment', function() {
+      testIndex.lastSeqNo().should.equal(7797);
+      variantIndex.lastSeqNo().should.equal(-1);
+    })
+  })
+
+  describe('#getSegment()', function() {
+    it('should return segment data for valid sequence numbers', function() {
+      testIndex.getSegment(7794).should.be.an.instanceof(m3u8.M3U8Segment);
+      testIndex.getSegment(7797).should.be.an.instanceof(m3u8.M3U8Segment);
+    })
+    it('should return null for out of bounds sequence numbers', function() {
+      should.not.exist(testIndex.getSegment(-1));
+      should.not.exist(testIndex.getSegment(7793));
+      should.not.exist(testIndex.getSegment(7798));
+
+      should.not.exist(variantIndex.getSegment(0));
+    })
+  })
+
+})