HTTP Range Requestsยถ
HTTP range requests enable seeking in audio files by allowing clients to request specific byte ranges instead of entire files. This is essential for Chromecast seeking and HTML5 audio scrubbing.
๐ฏ Purposeยถ
Range requests allow:
- Seeking in audio player - Jump to any position in track
- Chromecast timeline scrubbing - Seek from TV interface
- Bandwidth optimization - Download only needed portions
- Resume downloads - Continue from breakpoint
Without range support: Clients must download entire file before seeking With range support: Jump instantly to any position
๐ HTTP Range Specificationยถ
Range Header Formatยถ
Request:
Formats:
| Format | Meaning | Example |
|---|---|---|
bytes=start-end |
Specific range | bytes=1000-2000 (bytes 1000-2000) |
bytes=start- |
From start to EOF | bytes=1000- (byte 1000 to end) |
bytes=-end |
Last N bytes | bytes=-1000 (last 1000 bytes) |
bytes=0- |
Entire file | bytes=0- (same as no Range header) |
Multiple ranges (not currently supported):
Response Formatยถ
206 Partial Content:
HTTP/1.1 206 Partial Content
Content-Type: audio/mpeg
Content-Range: bytes 1000-2000/5000000
Content-Length: 1001
Accept-Ranges: bytes
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Content-Range, Content-Length, Range
Cache-Control: public, max-age=3600
[binary audio data: 1001 bytes]
Header breakdown:
| Header | Value | Meaning |
|---|---|---|
| Status | 206 |
Partial Content (not 200) |
Content-Range |
bytes 1000-2000/5000000 |
Bytes 1000-2000 of 5,000,000 total |
Content-Length |
1001 |
Size of this chunk (end - start + 1) |
Accept-Ranges |
bytes |
Server supports byte ranges |
๐ง Implementationยถ
_handle_range_request()ยถ
Purpose: Parse range header and serve partial content
Signature:
def _handle_range_request(
full_path: Path,
mime_type: str,
range_header: str,
file_size: int
) -> Response
Implementation:
def _handle_range_request(
full_path: Path,
mime_type: str,
range_header: str,
file_size: int
) -> Response:
"""
Handle HTTP range request for audio seeking
Returns 206 Partial Content or 416 Range Not Satisfiable
"""
# Parse Range header: "bytes=start-end"
import re
match = re.match(r"bytes=(\d+)-(\d*)", range_header)
if not match:
logger.warning(f"Malformed range header: {range_header}")
abort(416) # Range Not Satisfiable
# Extract start and end
start = int(match.group(1))
end_str = match.group(2)
end = int(end_str) if end_str else file_size - 1
# Validate range
if start >= file_size or end >= file_size or start > end:
logger.warning(
f"Invalid range: bytes={start}-{end} "
f"for file size {file_size}"
)
response = Response(status=416)
response.headers["Content-Range"] = f"bytes */{file_size}"
return response
# Calculate chunk size
length = end - start + 1
# Stream chunk generator
def generate():
with open(full_path, "rb") as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk_size = min(8192, remaining) # 8KB chunks
chunk = f.read(chunk_size)
if not chunk:
break
remaining -= len(chunk)
yield chunk
# Build 206 response
response = Response(
generate(),
206, # Partial Content
mimetype=mime_type,
direct_passthrough=True
)
# Set range headers
response.headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
response.headers["Content-Length"] = str(length)
response.headers["Accept-Ranges"] = "bytes"
# CORS headers (required for Chromecast)
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Expose-Headers"] = \
"Content-Range, Content-Length, Range"
# Caching
response.headers["Cache-Control"] = "public, max-age=3600"
logger.info(
f"Range request: {full_path.name} | "
f"Bytes {start}-{end} ({length} bytes)"
)
return response
Range Parsingยถ
Regular expression: r"bytes=(\d+)-(\d*)"
Capture groups:
- Group 1: Start byte (required)
- Group 2: End byte (optional)
Examples:
"bytes=1000-2000" โ start=1000, end=2000
"bytes=1000-" โ start=1000, end=file_size-1
"bytes=0-999" โ start=0, end=999
"bytes=-1000" โ Not supported (negative offset)
Validationยถ
Checks performed:
- Range format valid: Matches regex pattern
- Start within bounds:
start < file_size - End within bounds:
end < file_size - Start before end:
start <= end
Invalid range examples:
# File size: 1000 bytes
bytes=1000-2000 โ Invalid (start >= file_size)
bytes=500-10000 โ Invalid (end >= file_size)
bytes=500-100 โ Invalid (start > end)
bytes=abc-def โ Invalid (not numbers)
๐ Request Flowยถ
sequenceDiagram
participant Client
participant stream_audio
participant _handle_range_request
participant FileSystem
Client->>stream_audio: GET /play/song.mp3<br>Range: bytes=1000-2000
stream_audio->>stream_audio: Validate path
stream_audio->>stream_audio: Get MIME type
stream_audio->>stream_audio: Check Range header
alt Range header present
stream_audio->>_handle_range_request: handle(path, mime, range, size)
_handle_range_request->>_handle_range_request: Parse "bytes=1000-2000"
_handle_range_request->>_handle_range_request: Validate range
alt Range valid
_handle_range_request->>FileSystem: seek(1000)
_handle_range_request->>FileSystem: read(1001 bytes)
FileSystem-->>_handle_range_request: chunk data
_handle_range_request-->>Client: 206 Partial Content<br>Content-Range: bytes 1000-2000/5000
else Range invalid
_handle_range_request-->>Client: 416 Range Not Satisfiable<br>Content-Range: bytes */5000
end
else No Range header
stream_audio-->>Client: 200 OK<br>Full file
end
๐ก Chromecast Requirementsยถ
Why Chromecast Needs Range Supportยถ
Problem: Chromecast uses range requests for seeking
Without range support: Seeking doesn't work on Chromecast
With range support: Full timeline scrubbing works
Required Headersยถ
CORS headers are critical:
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Expose-Headers"] = \
"Content-Range, Content-Length, Range"
Why Access-Control-Expose-Headers?
Browsers hide certain headers from JavaScript by default. Chromecast needs to read:
Content-Range- To know what bytes it receivedContent-Length- To know chunk sizeRange- To verify its request
Testing with Chromecastยถ
Manual test:
- Start casting a mixtape
- Use car dashboard/TV remote to scrub timeline
- Should jump instantly to new position
- Check network tab for 206 responses
Debug logging:
INFO: Range request: song.mp3 | Bytes 1000000-2000000 (1000001 bytes)
INFO: Range request: song.mp3 | Bytes 3000000-4000000 (1000001 bytes)
Common issues:
| Symptom | Cause | Fix |
|---|---|---|
| Seeking doesn't work | Missing CORS headers | Add Access-Control-* headers |
| Gets 416 errors | Invalid range calculation | Check end <= file_size |
| Seeking is slow | Not using ranges | Verify Range header is sent |
โ ๏ธ Error Handlingยถ
416 Range Not Satisfiableยถ
When: Client requests invalid byte range
Response:
Content-Range: bytes */5000000 means:
- No valid range to return
- File size is 5,000,000 bytes
- Client should adjust request
Causes:
- Start beyond file size:
- End beyond file size:
- Start after end:
Loggingยถ
Valid range:
Invalid range:
Malformed header:
๐จ Streaming Optimizationยถ
Chunk Sizeยถ
Current implementation: 8KB chunks
Why 8KB?
- Balances memory usage vs. efficiency
- Small enough to not block event loop
- Large enough to minimize overhead
- Standard for HTTP streaming
Alternative sizes:
| Size | Use Case | Trade-off |
|---|---|---|
| 4KB | Low memory environments | More overhead |
| 8KB | Default (balanced) | Good for most cases |
| 64KB | High-speed networks | More memory usage |
Generator Patternยถ
Why use generator:
def generate():
with open(full_path, "rb") as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(8192, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
Benefits:
- Streams data incrementally
- Doesn't load entire range into memory
- Client can start playing immediately
- Can be interrupted (if client disconnects)
Memory usage:
- Without generator:
lengthbytes in memory - With generator:
8KBbytes in memory (max)
For 10MB range:
- Without: 10MB in memory
- With: 8KB in memory
๐ Examplesยถ
Example 1: Seek to Middle of Trackยถ
Request:
Response:
HTTP/1.1 206 Partial Content
Content-Type: audio/mpeg
Content-Range: bytes 2500000-5000000/5000001
Content-Length: 2500001
Accept-Ranges: bytes
Access-Control-Allow-Origin: *
[2,500,001 bytes of audio data]
What happened:
- File is 5,000,001 bytes total
- Requested from byte 2,500,000 to end
- Served 2,500,001 bytes (half the file)
Example 2: Small Range (Preview)ยถ
Request:
Response:
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/7654321
Content-Length: 1024
[1,024 bytes of audio data]
What happened:
- Requested first 1KB of file
- Useful for preview/metadata extraction
- Minimal bandwidth usage
Example 3: Invalid Rangeยถ
Request:
Response (if file is 5MB):
What happened:
- Requested bytes 10,000,000-20,000,000
- File is only 5,000,000 bytes
- Server returns 416 with file size
๐งช Testingยถ
Using curlยถ
Test basic range:
Expected response:
HTTP/1.1 206 Partial Content
Content-Type: audio/mpeg
Content-Range: bytes 0-1023/5000000
Content-Length: 1024
Accept-Ranges: bytes
Test full range:
Test invalid range:
Expected: 416 Range Not Satisfiable
Using Browser DevToolsยถ
Steps:
- Open player page
- Open DevTools (F12) โ Network tab
- Play a track
- Seek to different position
- Look for 206 responses
What to check:
- Request has
Rangeheader - Response is
206 Partial Content Content-Rangeheader is present- CORS headers are present
Screenshot of successful range request:
Request Headers:
Range: bytes=1048576-2097151
Response Headers:
HTTP/1.1 206 Partial Content
Content-Range: bytes 1048576-2097151/5242880
Content-Length: 1048576
Accept-Ranges: bytes
Access-Control-Allow-Origin: *
Automated Testingยถ
Python test example:
import requests
def test_range_request():
url = "http://localhost:5000/play/test/song.mp3"
headers = {"Range": "bytes=0-1023"}
response = requests.get(url, headers=headers)
assert response.status_code == 206
assert "Content-Range" in response.headers
assert response.headers["Accept-Ranges"] == "bytes"
assert len(response.content) == 1024
def test_invalid_range():
url = "http://localhost:5000/play/test/song.mp3"
headers = {"Range": "bytes=9999999-"}
response = requests.get(url, headers=headers)
assert response.status_code == 416
assert "Content-Range" in response.headers
assert response.headers["Content-Range"].startswith("bytes */")
๐ Related Documentationยถ
- Audio Streaming - Main streaming implementation
- Chromecast Integration - Cast requirements
- Player Controls - Frontend seeking
๐ Referencesยถ
Implementation: src/routes/play.py::_handle_range_request()
