Skip to content

Commit

Permalink
Add support from the HTTP range header
Browse files Browse the repository at this point in the history
  • Loading branch information
satchamo committed Apr 19, 2014
1 parent 401be8a commit 2ce75c5
Show file tree
Hide file tree
Showing 2 changed files with 139 additions and 3 deletions.
95 changes: 93 additions & 2 deletions django/views/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,32 @@ def serve(request, path, document_root=None, show_indexes=False):
return HttpResponseNotModified()
content_type, encoding = mimetypes.guess_type(fullpath)
content_type = content_type or 'application/octet-stream'
response = StreamingHttpResponse(open(fullpath, 'rb'),
ranged_file = RangedFileReader(open(fullpath, 'rb'))
response = StreamingHttpResponse(ranged_file,
content_type=content_type)
response["Last-Modified"] = http_date(statobj.st_mtime)
if stat.S_ISREG(statobj.st_mode):
response["Content-Length"] = statobj.st_size
size = statobj.st_size
response["Content-Length"] = size
response["Accept-Ranges"] = "bytes"
# Respect the Range header.
if "HTTP_RANGE" in request.META:
try:
ranges = parse_range_header(request.META['HTTP_RANGE'], size)
except ValueError:
ranges = None
# only handle syntactically valid headers, that are simple (no
# multipart byteranges)
if ranges is not None and len(ranges) == 1:
start, stop = ranges[0]
if stop > size:
# requested range not satisfiable
return HttpResponse(status=416)
ranged_file.start = start
ranged_file.stop = stop
response["Content-Range"] = "bytes %d-%d/%d" % (start, stop - 1, size)
response["Content-Length"] = stop - start
response.status_code = 206
if encoding:
response["Content-Encoding"] = encoding
return response
Expand Down Expand Up @@ -144,3 +165,73 @@ def was_modified_since(header=None, mtime=0, size=0):
except (AttributeError, ValueError, OverflowError):
return True
return False


def parse_range_header(header, resource_size):
"""
Parses a range header into a list of two-tuples (start, stop) where `start`
is the starting byte of the range (inclusive) and `stop` is the ending byte
position of the range (exclusive).
Returns None if the value of the header is not syntatically valid.
"""
if not header or '=' not in header:
return None

ranges = []
units, range_ = header.split('=', 1)
units = units.strip().lower()

if units != "bytes":
return None

for val in range_.split(","):
val = val.strip()
if '-' not in val:
return None

if val.startswith("-"):
# suffix-byte-range-spec: this form specifies the last N bytes of an
# entity-body
start = resource_size + int(val)
if start < 0:
start = 0
stop = resource_size
else:
# byte-range-spec: first-byte-pos "-" [last-byte-pos]
start, stop = val.split("-", 1)
start = int(start)
# the +1 is here since we want the stopping point to be exclusive, whereas in
# the HTTP spec, the last-byte-pos is inclusive
stop = int(stop)+1 if stop else resource_size
if start >= stop:
return None

ranges.append((start, stop))

return ranges


class RangedFileReader:
"""
Wraps a file like object with an iterator that runs over part (or all) of
the file defined by start and stop. Blocks of block_size will be returned
from the starting position, up to, but not including the stop point.
"""
block_size = 8192
def __init__(self, file_like, start=0, stop=float("inf"), block_size=None):
self.f = file_like
self.block_size = block_size or RangedFileReader.block_size
self.start = start
self.stop = stop

def __iter__(self):
self.f.seek(self.start)
position = self.start
while position < self.stop:
data = self.f.read(min(self.block_size, self.stop - position))
if not data:
break

yield data
position += self.block_size
47 changes: 46 additions & 1 deletion tests/view_tests/tests/test_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.http import HttpResponseNotModified
from django.test import SimpleTestCase, override_settings
from django.utils.http import http_date
from django.views.static import was_modified_since
from django.views.static import was_modified_since, RangedFileReader

from .. import urls
from ..urls import media_dir
Expand Down Expand Up @@ -95,6 +95,51 @@ def test_404(self):
response = self.client.get('/%s/non_existing_resource' % self.prefix)
self.assertEqual(404, response.status_code)

def test_accept_ranges(self):
response = self.client.get('/%s/%s' % (self.prefix, "file.txt"))
self.assertEqual(response['Accept-Ranges'], "bytes")

def test_syntactically_invalid_ranges(self):
"""
Test that a syntactically invalid byte range header is ignored and the
response gives back the whole resource as per RFC 2616, section 14.35.1
"""
content = open(path.join(media_dir, "file.txt")).read()
invalid = ["megabytes=1-2", "bytes=", "bytes=3-2", "bytes=--5", "units", "bytes=-,"]
for range_ in invalid:
response = self.client.get('/%s/%s' % (self.prefix, "file.txt"), HTTP_RANGE=range_)
self.assertEqual(content, b''.join(response))

def test_unsatisfiable_range(self):
"""Test that an unsatisfiable range results in a 416 HTTP status code"""
content = open(path.join(media_dir, "file.txt")).read()
# since byte ranges are *inclusive*, 0 to len(content) would be unsatisfiable
response = self.client.get('/%s/%s' % (self.prefix, "file.txt"), HTTP_RANGE="bytes=0-%d" % len(content))
self.assertEqual(response.status_code, 416)

def test_ranges(self):
# set the block size to something small so we do multiple iterations in
# the RangedFileReader class
original_block_size = RangedFileReader.block_size
RangedFileReader.block_size = 3

content = open(path.join(media_dir, "file.txt")).read()
# specify the range header, the expected response content, and the
# values of the content-range header byte positions
ranges = {
"bytes=0-10": (content[0:11], (0, 10)),
"bytes=9-9": (content[9:10], (9, 9)),
"bytes=-5": (content[len(content)-5:], (len(content)-5, len(content)-1)),
"bytes=3-": (content[3:], (3, len(content)-1)),
"bytes=-%d" % (len(content) + 1): (content, (0, len(content)-1)),
}
for range_, (expected_result, byte_positions) in ranges.items():
response = self.client.get('/%s/%s' % (self.prefix, "file.txt"), HTTP_RANGE=range_)
self.assertEqual(expected_result, b''.join(response))
self.assertEqual(int(response['Content-Length']), len(expected_result))
self.assertEqual(response['Content-Range'], "bytes %d-%d/%d" % (byte_positions + (len(content),)))

RangedFileReader.block_size = original_block_size

class StaticHelperTest(StaticTests):
"""
Expand Down

6 comments on commit 2ce75c5

@alacret
Copy link

@alacret alacret commented on 2ce75c5 Jul 6, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was merged?

@satchamo
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope

@daredevil82
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you make a PR? I can see this being helpful, especially handling video streams

@OndrejIT
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any progress?

@lpryszcz
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love this merged as well!

@pirate
Copy link

@pirate pirate commented on 2ce75c5 Sep 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would also love to see this merged someday!

Please sign in to comment.