111

Tl;Dr - The Question:

What is the right way to handle streaming a video file to an html5 video player with Node.js so that the video controls continue to work?

I think it has to do with the way that the headers are handled. Anyway, here's the background information. The code is a little lengthy, however, it's pretty straightforward.

Streaming small video files to HTML5 video with Node is easy

I learned how to stream small video files to an HTML5 video player very easily. With this setup, the controls work without any work on my part, and the video streams flawlessly. A working copy of the fully working code with sample video is here, for download on Google Docs.

Client:

<html>
  <title>Welcome</title>
    <body>
      <video controls>
        <source src="movie.mp4" type="video/mp4"/>
        <source src="movie.webm" type="video/webm"/>
        <source src="movie.ogg" type="video/ogg"/>
        <!-- fallback -->
        Your browser does not support the <code>video</code> element.
    </video>
  </body>
</html>

Server:

// Declare Vars & Read Files

var fs = require('fs'),
    http = require('http'),
    url = require('url'),
    path = require('path');
var movie_webm, movie_mp4, movie_ogg;
// ... [snip] ... (Read index page)
fs.readFile(path.resolve(__dirname,"movie.mp4"), function (err, data) {
    if (err) {
        throw err;
    }
    movie_mp4 = data;
});
// ... [snip] ... (Read two other formats for the video)

// Serve & Stream Video

http.createServer(function (req, res) {
    // ... [snip] ... (Serve client files)
    var total;
    if (reqResource == "/movie.mp4") {
        total = movie_mp4.length;
    }
    // ... [snip] ... handle two other formats for the video
    var range = req.headers.range;
    var positions = range.replace(/bytes=/, "").split("-");
    var start = parseInt(positions[0], 10);
    var end = positions[1] ? parseInt(positions[1], 10) : total - 1;
    var chunksize = (end - start) + 1;
    if (reqResource == "/movie.mp4") {
        res.writeHead(206, {
            "Content-Range": "bytes " + start + "-" + end + "/" + total,
                "Accept-Ranges": "bytes",
                "Content-Length": chunksize,
                "Content-Type": "video/mp4"
        });
        res.end(movie_mp4.slice(start, end + 1), "binary");
    }
    // ... [snip] ... handle two other formats for the video
}).listen(8888);

But this method is limited to files < 1GB in size.

Streaming (any size) video files with fs.createReadStream

By utilizing fs.createReadStream(), the server can read the file in a stream rather than reading it all into memory at once. This sounds like the right way to do things, and the syntax is extremely simple:

Server Snippet:

movieStream = fs.createReadStream(pathToFile);
movieStream.on('open', function () {
    res.writeHead(206, {
        "Content-Range": "bytes " + start + "-" + end + "/" + total,
            "Accept-Ranges": "bytes",
            "Content-Length": chunksize,
            "Content-Type": "video/mp4"
    });
    // This just pipes the read stream to the response object (which goes 
    //to the client)
    movieStream.pipe(res);
});

movieStream.on('error', function (err) {
    res.end(err);
});

This streams the video just fine! But the video controls no longer work.

WebDeveloper404
  • 1,215
  • 3
  • 10
  • 11
  • 1
    I left that `writeHead()` code commented, but there in case it helps. Should I remove that to make the code snippet more readable? – WebDeveloper404 Jul 27 '14 at 00:23
  • 3
    where does req.headers.range come from? I keep getting undefined when I try to do the replace method. Thanks. – Chad Watkins Oct 01 '15 at 22:18

3 Answers3

133

The Accept Ranges header (the bit in writeHead()) is required for the HTML5 video controls to work.

I think instead of just blindly send the full file, you should first check the Accept Ranges header in the REQUEST, then read in and send just that bit. fs.createReadStream support start, and end option for that.

So I tried an example and it works. The code is not pretty but it is easy to understand. First we process the range header to get the start/end position. Then we use fs.stat to get the size of the file without reading the whole file into memory. Finally, use fs.createReadStream to send the requested part to the client.

var fs = require("fs"),
    http = require("http"),
    url = require("url"),
    path = require("path");

http.createServer(function (req, res) {
  if (req.url != "/movie.mp4") {
    res.writeHead(200, { "Content-Type": "text/html" });
    res.end('<video src="http://localhost:8888/movie.mp4" controls></video>');
  } else {
    var file = path.resolve(__dirname,"movie.mp4");
    fs.stat(file, function(err, stats) {
      if (err) {
        if (err.code === 'ENOENT') {
          // 404 Error if file not found
          return res.sendStatus(404);
        }
      res.end(err);
      }
      var range = req.headers.range;
      if (!range) {
       // 416 Wrong range
       return res.sendStatus(416);
      }
      var positions = range.replace(/bytes=/, "").split("-");
      var start = parseInt(positions[0], 10);
      var total = stats.size;
      var end = positions[1] ? parseInt(positions[1], 10) : total - 1;
      var chunksize = (end - start) + 1;

      res.writeHead(206, {
        "Content-Range": "bytes " + start + "-" + end + "/" + total,
        "Accept-Ranges": "bytes",
        "Content-Length": chunksize,
        "Content-Type": "video/mp4"
      });

      var stream = fs.createReadStream(file, { start: start, end: end })
        .on("open", function() {
          stream.pipe(res);
        }).on("error", function(err) {
          res.end(err);
        });
    });
  }
}).listen(8888);
Christian
  • 148
  • 8
tungd
  • 14,467
  • 5
  • 41
  • 45
  • 4
    Can we use this strategy to send only some part of the movie, i.e. between 5th second and 7th second? Is there a way to find what that interval corresponds to what bytes interval by ffmpeg like libraries? Thanks. – pembeci Apr 10 '15 at 13:11
  • 8
    Never mind my question. I found the magic words to find how to achieve what I asked: [pseudo-streaming](https://www.google.com/search?q=html5+video+pseudo-streaming&ie=utf-8&oe=utf-8). – pembeci Apr 10 '15 at 13:23
  • How can this be made to work if for some reason movie.mp4 is in an encrypted format and we need to decrypt it before streaming to the browser? – saraf Sep 12 '15 at 13:37
  • @saraf: It depends on what algorithm used for encryption. Does it works with stream or it only works as whole file encryption? Is it possible that you just decrypt the video into temporary location and serve it as usual? General speaking I it is possible, but it can get tricky. There's no general solution here. – tungd Sep 20 '15 at 13:58
  • Hi, tungd, thanks for responding! the use case is a raspberry pi based device that will act as a media distribution platform for educational content developers. We are free to choose the encryption algorithm, the key will be in firmware - but memory is limited to 1GB RAM and the content size is around 200GB (which will be on removable media - USB attached.) Even something like the Clear Key algorithm with EME would be okay - except for the problem that chromium does not have EME built into it on the ARM. Just that the removable media by itself should not be enough to enable playback/copying. – saraf Sep 21 '15 at 15:04
  • I know this is from a long way back, but I tried using this with Express and I can't seem to figure out how to actually send the video file to a video tag. I can make it download the video, but can't actually 'stream' it. – Jake Alsemgeest Feb 15 '16 at 00:19
  • @JakeAlsemgeest: Hm, I don't get it, do you have code example or something? – tungd Feb 21 '16 at 05:05
  • I try your code with 1MB suggestion by @danludwig but the video is not being played as soon as it downloads the first 1 MB or so. Instead, it first downloads the all full length (9.2 MB in my case) and then let me start the video. Am I am doing something wrong, or your code don't achieve this. [Here is the code](https://bitbucket.org/Vikasg7/nodejs-video-streaming/src/b04b9211660090d0227125cf748b8b7fc8bbe005/server.js?at=master&fileviewer=file-view-default) – Vikas Gautam Aug 23 '16 at 01:34
  • @tungd i was wondering if i can ask? How did you post the captured video i mean the output(video to be posted) to the server suppose i have a USB camera connected to my computer from that i want to go live.so i need to post the video to server then broadcast it live for users how did you managed to do that please help? – A Sahra Jan 03 '17 at 07:15
  • @ASahra: that is a totally different question and the answer to that question is pretty complicated, I'd suggest you post it as a new question and see if someone can help – tungd Feb 02 '17 at 00:22
  • This is great but I was wondering if this can be used to run the video synchronously and serve the same stream with multiple clients using socket.io. – Renato Francia Mar 08 '18 at 21:02
  • Found similar working demo code at: https://jsonworld.com/demo/video-streaming-with-nodejs – Soni Kumari Jul 23 '19 at 04:51
  • also for simplicity, you can also do fs.createReadStream(file, { start, end}) – B''H Bi'ezras -- Boruch Hashem Mar 11 '20 at 21:43
27

The accepted answer to this question is awesome and should remain the accepted answer. However I ran into an issue with the code where the read stream was not always being ended/closed. Part of the solution was to send autoClose: true along with start:start, end:end in the second createReadStream arg.

The other part of the solution was to limit the max chunksize being sent in the response. The other answer set end like so:

var end = positions[1] ? parseInt(positions[1], 10) : total - 1;

...which has the effect of sending the rest of the file from the requested start position through its last byte, no matter how many bytes that may be. However the client browser has the option to only read a portion of that stream, and will, if it doesn't need all of the bytes yet. This will cause the stream read to get blocked until the browser decides it's time to get more data (for example a user action like seek/scrub, or just by playing the stream).

I needed this stream to be closed because I was displaying the <video> element on a page that allowed the user to delete the video file. However the file was not being removed from the filesystem until the client (or server) closed the connection, because that is the only way the stream was getting ended/closed.

My solution was just to set a maxChunk configuration variable, set it to 1MB, and never pipe a read a stream of more than 1MB at a time to the response.

// same code as accepted answer
var end = positions[1] ? parseInt(positions[1], 10) : total - 1;
var chunksize = (end - start) + 1;

// poor hack to send smaller chunks to the browser
var maxChunk = 1024 * 1024; // 1MB at a time
if (chunksize > maxChunk) {
  end = start + maxChunk - 1;
  chunksize = (end - start) + 1;
}

This has the effect of making sure that the read stream is ended/closed after each request, and not kept alive by the browser.

I also wrote a separate StackOverflow question and answer covering this issue.

Community
  • 1
  • 1
danludwig
  • 46,965
  • 25
  • 159
  • 237
  • This works great for Chrome, but does not appear to work in Safari. In safari it only seems to work if it can request the whole range. Are you doing anything different for Safari? – f1lt3r Jun 12 '17 at 21:00
  • 2
    Upon further digging: Safari sees the "/${total}" in the 2-byte response, and then says... "Hey, hows about you just send me the whole file?". Then when it is told, "No, you're only getting the first 1Mb!", Safari gets upset "An error occurred trying to lead the resource". – f1lt3r Jun 12 '17 at 21:07
2

Firstly create app.js file in the directory you want to publish.

var http = require('http');
var fs = require('fs');
var mime = require('mime');
http.createServer(function(req,res){
    if (req.url != '/app.js') {
    var url = __dirname + req.url;
        fs.stat(url,function(err,stat){
            if (err) {
            res.writeHead(404,{'Content-Type':'text/html'});
            res.end('Your requested URI('+req.url+') wasn\'t found on our server');
            } else {
            var type = mime.getType(url);
            var fileSize = stat.size;
            var range = req.headers.range;
                if (range) {
                    var parts = range.replace(/bytes=/, "").split("-");
                var start = parseInt(parts[0], 10);
                    var end = parts[1] ? parseInt(parts[1], 10) : fileSize-1;
                    var chunksize = (end-start)+1;
                    var file = fs.createReadStream(url, {start, end});
                    var head = {
                'Content-Range': `bytes ${start}-${end}/${fileSize}`,
                'Accept-Ranges': 'bytes',
                'Content-Length': chunksize,
                'Content-Type': type
                }
                    res.writeHead(206, head);
                    file.pipe(res);
                    } else {    
                    var head = {
                'Content-Length': fileSize,
                'Content-Type': type
                    }
                res.writeHead(200, head);
                fs.createReadStream(url).pipe(res);
                    }
            }
        });
    } else {
    res.writeHead(403,{'Content-Type':'text/html'});
    res.end('Sorry, access to that file is Forbidden');
    }
}).listen(8080);

Simply run node app.js and your server shall be running on port 8080. Besides video it can stream all kinds of files.