Skip to content

Seek

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:

GET /play/artist/album/song.mp3 HTTP/1.1
Range: bytes=1000-2000

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):

Range: bytes=0-1000, 2000-3000

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:

  1. Range format valid: Matches regex pattern
  2. Start within bounds: start < file_size
  3. End within bounds: end < file_size
  4. 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
Hold "Alt" / "Option" to enable pan & zoom

๐Ÿ“ก 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 received
  • Content-Length - To know chunk size
  • Range - To verify its request

Testing with Chromecastยถ

Manual test:

  1. Start casting a mixtape
  2. Use car dashboard/TV remote to scrub timeline
  3. Should jump instantly to new position
  4. 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

See: Chromecast Integration


โš ๏ธ Error Handlingยถ

416 Range Not Satisfiableยถ

When: Client requests invalid byte range

Response:

HTTP/1.1 416 Range Not Satisfiable
Content-Range: bytes */5000000

Content-Range: bytes */5000000 means:

  • No valid range to return
  • File size is 5,000,000 bytes
  • Client should adjust request

Causes:

  1. Start beyond file size:
File: 1000 bytes
Request: bytes=2000-3000
โ†’ 416
  1. End beyond file size:
File: 1000 bytes
Request: bytes=500-2000
โ†’ 416
  1. Start after end:
Request: bytes=500-100
โ†’ 416

Loggingยถ

Valid range:

INFO: Range request: song.mp3 | Bytes 1000-2000 (1001 bytes)

Invalid range:

WARNING: Invalid range: bytes=1000-2000 for file size 500

Malformed header:

WARNING: Malformed range header: bytes=abc-def

๐ŸŽจ Streaming Optimizationยถ

Chunk Sizeยถ

Current implementation: 8KB chunks

chunk_size = min(8192, remaining)

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: length bytes in memory
  • With generator: 8KB bytes in memory (max)

For 10MB range:

  • Without: 10MB in memory
  • With: 8KB in memory

๐Ÿ“Š Examplesยถ

Example 1: Seek to Middle of Trackยถ

Request:

GET /play/jazz/miles-davis/so-what.mp3 HTTP/1.1
Range: bytes=2500000-

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:

GET /play/rock/the-beatles/hey-jude.mp3 HTTP/1.1
Range: bytes=0-1023

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:

GET /play/classical/beethoven/symphony-9.mp3 HTTP/1.1
Range: bytes=10000000-20000000

Response (if file is 5MB):

HTTP/1.1 416 Range Not Satisfiable
Content-Range: bytes */5000000

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:

curl -I \
  -H "Range: bytes=0-1023" \
  http://localhost:5000/play/artist/album/song.mp3

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:

curl -I \
  -H "Range: bytes=1000-" \
  http://localhost:5000/play/artist/album/song.mp3

Test invalid range:

curl -I \
  -H "Range: bytes=9999999-" \
  http://localhost:5000/play/artist/album/song.mp3

Expected: 416 Range Not Satisfiable

Using Browser DevToolsยถ

Steps:

  1. Open player page
  2. Open DevTools (F12) โ†’ Network tab
  3. Play a track
  4. Seek to different position
  5. Look for 206 responses

What to check:

  • Request has Range header
  • Response is 206 Partial Content
  • Content-Range header 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 */")


๐Ÿ”— Referencesยถ


Implementation: src/routes/play.py::_handle_range_request()