8

I'm using a streaming server in Node.js to stream MP3 files. While the whole file streaming it is ok, I cannot use the Content-Range header to stream the file seeking to a start position and util a end position.

I calculate the start and end bytes from seconds using ffprobe like

ffprobe -i /audio/12380187.mp3 -show_frames -show_entries frame=pkt_pos -of default=noprint_wrappers=1:nokey=1 -hide_banner -loglevel panic -read_intervals 20%+#1

That will give me the exact bytes from 10 seconds in this case to the first next packet.

This becomes in Node.js as simple as

  const args = [
      '-hide_banner',
      '-loglevel', loglevel,
      '-show_frames',//Display information about each frame
      '-show_entries', 'frame=pkt_pos',// Display only information about byte position
      '-of', 'default=noprint_wrappers=1:nokey=1',//Don't want to print the key and the section header and footer
      '-read_intervals', seconds+'%+#1', //Read only 1 packet after seeking to position 01:23
      '-print_format', 'json',
      '-v', 'quiet',
      '-i', fpath
    ];
    const opts = {
      cwd: self._options.tempDir
    };
    const cb = (error, stdout) => {
      if (error)
        return reject(error);
      try {
        const outputObj = JSON.parse(stdout);
        return resolve(outputObj);
      } catch (ex) {
        return reject(ex);
      }
    };
    cp.execFile('ffprobe', args, opts, cb)
      .on('error', reject);
  });

Now that I have start and end bytes, my media server will get the ranges in this way from a custom value passed to it like bytes=120515-240260

var getRange = function (req, total) {
  var range = [0, total, 0];
  var rinfo = req.headers ? req.headers.range : null;

  if (rinfo) {
    var rloc = rinfo.indexOf('bytes=');
    if (rloc >= 0) {
      var ranges = rinfo.substr(rloc + 6).split('-');
      try {
        range[0] = parseInt(ranges[0]);
        if (ranges[1] && ranges[1].length) {
          range[1] = parseInt(ranges[1]);
          range[1] = range[1] < 16 ? 16 : range[1];
        }
      } catch (e) {}
    }

    if (range[1] == total)
     range[1]--;

    range[2] = total;
  }

  return range;
};

At this point I will get this range [ 120515, 240260, 4724126 ], where I have like [startBytes,endBytes,totalDurationInBytes]

I therfore can create a file read stream passing that range:

var file = fs.createReadStream(path, {start: range[0], end: range[1]});

and then compose the response header using

  var header = {
    'Content-Length': range[1],
    'Content-Type': type,
    'Access-Control-Allow-Origin': req.headers.origin || "*",
    'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
    'Access-Control-Allow-Headers': 'POST, GET, OPTIONS'
  };

  if (range[2]) {
    header['Expires'] = 0;
    header['Pragma'] = 'no-cache';
    header['Cache-Control']= 'no-cache, no-store, must-revalidate';
    header['Accept-Ranges'] = 'bytes';
    header['Content-Range'] = 'bytes ' + range[0] + '-' + range[1] + '/' + total;
    header['Content-Length'] = range[2];
    //HTTP/1.1 206 Partial Content
    res.writeHead(206, header);
  } else {
    res.writeHead(200, header);
  }

so to obtain

{
 "Content-Length": 4724126,
  "Content-Type": "audio/mpeg",
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "POST, GET, OPTIONS",
  "Access-Control-Allow-Headers": "POST, GET, OPTIONS",
  "Accept-Ranges": "bytes",
  "Content-Range": "bytes 120515-240260/4724126"
}

before doing the pipe of the read stream to the output

file.pipe(res);

The problem is that the browser I don't get any audio in the HTML5 <audio> tag, while it was streaming the contents when not using any Content-Range header. Here you can see the dump of the ReadStream object from the node api that shows how the range was ok

  start: 120515,
  end: 240260,
  autoClose: true,
  pos: 120515

So what is happening on the browser side that prevents to load the file?

[UPDATE]

It turns out that it works Safari but not in Google's Chrome! I can then assume that the Content-Range it correctly devised, but Chrome has some flawness with it. Now the specification is by rfc2616 and I'm following strictly that one for the byte-range-resp-spec so I pass

  "Accept-Ranges": "bytes",
  "Content-Range": "bytes 120515-240260/4724126"

and this should work on Chrome too according to the RFC specs. This it should work as-it-is as specified by Mozilla docs as well here

loretoparisi
  • 15,724
  • 11
  • 102
  • 146

1 Answers1

8

I'm using expressjs framework and I've made it like this:

// Readable Streams Storage Class
class FileReadStreams {
  constructor() {
    this._streams = {};
  }
  
  make(file, options = null) {
    return options ?
      fs.createReadStream(file, options)
      : fs.createReadStream(file);
  }
  
  get(file) {
    return this._streams[file] || this.set(file);
  }
  
  set(file) {
    return this._streams[file] = this.make(file);
  }
}
const readStreams = new FileReadStreams();

// Getting file stats and caching it to avoid disk i/o
function getFileStat(file, callback) {
  let cacheKey = ['File', 'stat', file].join(':');
  
  cache.get(cacheKey, function(err, stat) {
    if(stat) {
      return callback(null, stat);
    }
    
    fs.stat(file, function(err, stat) {
      if(err) {
        return callback(err);
      }
      
      cache.set(cacheKey, stat);
      callback(null, stat);
    });
  });
}

// Streaming whole file
function streamFile(file, req, res) {
  getFileStat(file, function(err, stat) {
    if(err) {
      console.error(err);
      return res.status(404).end();
    }
    
    let bufferSize = 1024 * 1024;
    res.writeHead(200, {
      'Cache-Control': 'no-cache, no-store, must-revalidate',
      'Pragma': 'no-cache',
      'Expires': 0,
      'Content-Type': 'audio/mpeg',
      'Content-Length': stat.size
    });
    readStreams.make(file, {bufferSize}).pipe(res);
  });
}

// Streaming chunk
function streamFileChunked(file, req, res) {
  getFileStat(file, function(err, stat) {
    if(err) {
      console.error(err);
      return res.status(404).end();
    }
    
    let chunkSize = 1024 * 1024;
    if(stat.size > chunkSize * 2) {
      chunkSize = Math.ceil(stat.size * 0.25);
    }
    let range = (req.headers.range) ? req.headers.range.replace(/bytes=/, "").split("-") : [];
    
    range[0] = range[0] ? parseInt(range[0], 10) : 0;
    range[1] = range[1] ? parseInt(range[1], 10) : range[0] + chunkSize;
    if(range[1] > stat.size - 1) {
      range[1] = stat.size - 1;
    }
    range = {start: range[0], end: range[1]};
    
    let stream = readStreams.make(file, range);
    res.writeHead(206, {
      'Cache-Control': 'no-cache, no-store, must-revalidate',
      'Pragma': 'no-cache',
      'Expires': 0,
      'Content-Type': 'audio/mpeg',
      'Accept-Ranges': 'bytes',
      'Content-Range': 'bytes ' + range.start + '-' + range.end + '/' + stat.size,
      'Content-Length': range.end - range.start + 1,
    });
    stream.pipe(res);
  });
}

router.get('/:file/stream', (req, res) => {

  const file = path.join('path/to/mp3/', req.params.file+'.mp3');
    
  if(/firefox/i.test(req.headers['user-agent'])) {
    return streamFile(file, req, res);
  }
  streamFileChunked(file, req, res);
});

Full sources of site here

Try to fix to Your code:

this will enforce browser to act with resource as chunked.

var header = {
    'Content-Length': range[1],
    'Content-Type': type,
    'Access-Control-Allow-Origin': req.headers.origin || "*",
    'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
    'Access-Control-Allow-Headers': 'POST, GET, OPTIONS',
    'Cache-Control': 'no-cache, no-store, must-revalidate',
    'Pragma': 'no-cache',
    'Expires': 0
  };

  if(/firefox/i.test(req.headers['user-agent'])) {  
    res.writeHead(200, header);
  }
  else {
    header['Accept-Ranges'] = 'bytes';
    header['Content-Range'] = 'bytes ' + range[0] + '-' + range[1] + '/' + total;
    header['Content-Length'] = range[2];
    res.writeHead(206, header);
  }
num8er
  • 18,604
  • 3
  • 43
  • 57
  • 1
    Thank you, so cool this web site!!! Let me check how to adapt your solution, it seems it should work! – loretoparisi Nov 09 '18 at 14:49
  • 1
    @loretoparisi keep in mind js player in site does not allow to jump not-cached part. But by sending content range header from client side You can jump anywhere. I've checked in Safari using touch-bar and seek to un-cached part. – num8er Nov 09 '18 at 14:52
  • The very strange thing is that Chrome it does not ignore the `Content-Range`. It seems that in my case, it will break the default player that Chrome builds when you put in the browser the streaming url like yours here http://m.saray.az/track/atb-could-you-believe/stream - that it works. In my case Chrome displays a ` – loretoparisi Nov 09 '18 at 15:00
  • 1
    I think headers being cached, try to add this headers: `'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': 0,` – num8er Nov 09 '18 at 15:02
  • done! Updated the code above with header setup, it seems someting it is still missing. – loretoparisi Nov 09 '18 at 15:07
  • add it in both situation (chunked and unchunked), cause first request mostly comes without range request. Also see I'm forcing to chunked response if useragent not firefox to tell browser act as chunked – num8er Nov 09 '18 at 15:08
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/183376/discussion-between-loretoparisi-and-num8er). – loretoparisi Nov 09 '18 at 15:18