I generate a lot of video output from running simulations and often find myself trying to figure out a way to quickly share them with colleagues who are typically in the same building. Many times these are temporary results and which do not warrant even copying to the temporary shared drive.

Typically, when I want to share documents, I use the venerable Python module SimpleHTTPServer which is installed by default on virtually every modern operating system. However, the user experience of playing a video served by SimpleHTTPServer leaves a lot to be desired. Seeking, one of the most important interactions, especially for longer videos is not possible when using SimpleHTTPServer.

When I first started learning Go, I wanted to see how difficult it would be to develop something like SimpleHTTPServer. Turns out writing a static FileServer is a trivial task in Go requiring just a few lines of code as follows:

import ( "log" "net/http" ) func main() { log.Printf("Serving %s on http://localhost:8081", http.Dir(".")) log.Fatal(http.ListenAndServe(":8081", http.FileServer(http.Dir(".")))) }

However, when I tried to play a video using this humble server, the experience was completely different. Seeking simply worked. I knew I wanted to get this responsive video seeking behavior with SimpleHTTPServer. Because …, why not?

Go standard library source code is very readable. For example, all the code needed to understand http.FileServer is organized into the file src/net/http/fs.go. Reading through fs.go, I was found to two other resources, RFC 2616 - HTTP/1.1 and RFC 7233 - HTTP/1.1: Range Requests that helped me understand that the magic behind Go’s FileServer is Byte Serving. After understanding HTTP range requests, I next looked at the source of SimpleHTTPServer.py. I found that all we need to do is overwrite a couple of functions and we will have a proof-of-concept for handling range requests.

For the experiment, I use sample videos from the following locations:

Before we move on to the code, here are two videos showing the user experience with SimpleHTTPServer and FileServer. Click on the images to see a larger version.

Video served using Python's SimpleHTTPServer
Python's SimpleHTTPServer
Video served using Go's http.FileServer
Go's http.FileServer

The final code is so small that it did not warrant a repository of its own. You can find it in on GitHubGist. A copy is also available at the end of this post.

Reading through SimpleHTTPServer.py, the server is actually created through a call from __main__ to test(). We will use this function albeit replacing the HandlerClass parameter.

Since the SimpleHTTPRequestHandler class contains a lot of other functionality like cleaning and translating paths, guessing file types, etc., we will use it as the base class for our RangeHTTPRequestHandler.

import os import SimpleHTTPServer from SimpleHTTPServer import SimpleHTTPRequestHandler class RangeHTTPRequestHandler(SimpleHTTPRequestHandler): """RangeHTTPRequestHandler is a SimpleHTTPRequestHandler with HTTP 'Range' support """ def send_head(self): """Common code for GET and HEAD commands. Return value is either a file object or None """ # This is one of the functions we need to implement pass def copyfile(self, infile, outfile): """Copies data between two file objects If the current request is a 'Range' request then only the requested bytes are copied. Otherwise, the entire file is copied using SimpleHTTPRequestHandler.copyfile """ if not 'Range' in self.headers: SimpleHTTPRequestHandler.copyfile(self, infile, outfile) else: # We need to provide our own way to copy the requested range of # bytes from infile to outfile pass if __name__ == '__main__': SimpleHTTPServer.test(HandlerClass=RangeHTTPRequestHandler)

That’s it, these are the two functions we need to fill out to create a new subclass of SimpleHTTPRequestHandler to handle HTTP/1.1 Range header.

The logic of the send_head function is as follows:

  • If the path is a directory, let SimpleHTTPRequestHandler handle the request
  • If the path does not exist, send HTTP 404
  • If ‘Range’ is present in self.headers then parse the start and end bytes
    • ‘Range’ header looks like bytes=<start-byte>-<end-byte>
    • Both start-byte and end-byte are optional
    • Missing start-byte means the request is for the last ‘end-byte’ bytes
    • Missing end-byte means the request is from start-byte to the end of file
  • If ‘Range’ request, then send response HTTP 206 else HTTP 200
  • Add headers ‘Accept-Ranges’ and ‘Content-Length’ to help browsers send out the correct ‘Range’ requests

Implementation of send_head:

def send_head(self): """Common code for GET and HEAD commands. Return value is either a file object or None """ path = self.translate_path(self.path) ctype = self.guess_type(path) # Handling file location ## If directory, let SimpleHTTPRequestHandler handle the request if os.path.isdir(path): return SimpleHTTPRequestHandler.send_head(self) ## Handle file not found if not os.path.exists(path): return self.send_error(404, self.responses.get(404)[0]) ## Handle file request f = open(path, 'rb') fs = os.fstat(f.fileno()) size = fs[6] # Parse range header # Range headers look like 'bytes=500-1000' start, end = 0, size-1 if 'Range' in self.headers: start, end = self.headers.get('Range').strip().strip('bytes=').split('-') if start == "": ## If no start, then the request is for last N bytes ## e.g. bytes=-500 try: end = int(end) except ValueError as e: self.send_error(400, 'invalid range') start = size-end else: try: start = int(start) except ValueError as e: self.send_error(400, 'invalid range') if start >= size: # If requested start is greater than filesize self.send_error(416, self.responses.get(416)[0]) if end == "": ## If only start is provided then serve till end end = size-1 else: try: end = int(end) except ValueError as e: self.send_error(400, 'invalid range') ## Correct the values of start and end start = max(start, 0) end = min(end, size-1) self.range = (start, end) ## Setup headers and response l = end-start+1 if 'Range' in self.headers: self.send_response(206) else: self.send_response(200) self.send_header('Content-type', ctype) self.send_header('Accept-Ranges', 'bytes') self.send_header('Content-Range', 'bytes %s-%s/%s' % (start, end, size)) self.send_header('Content-Length', str(l)) self.send_header('Last-Modified', self.date_time_string(fs.st_mtime)) self.end_headers() return f

The second function copyfile is much simpler to implement. We read chunks of data from the input file object and write it to the output file which will be automatically flushed to the client side by the server.

An implementation of copyfile is as follows:

def copyfile(self, infile, outfile): """Copies data between two file objects If the current request is a 'Range' request then only the requested bytes are copied. Otherwise, the entire file is copied using SimpleHTTPServer.copyfile """ if not 'Range' in self.headers: SimpleHTTPRequestHandler.copyfile(self, infile, outfile) return start, end = range infile.seek(start) bufsize=64*1024 ## 64KB while True: buf = infile.read(bufsize) if not buf: break outfile.write(buf)
Video served using RangeHTTPServer
RangeHTTPServer

Important Note: It is very important to note that this the code presented in this post is a proof-of-concept and should not be used for production or otherwise critical use cases. The code presented doesn’t even implement the complete requirements of RFC 7233. For example, the presented server can only hand one range. A lot of input validation and error handling, e.g. checking if the range request is properly formatted is not implemented for the sake of brevity.

For the sake of posterity, the following links are copies of relevant files: