The Streaming Endpoint — Sending Bytes Properly
Why this is the most interesting chapter
Let me set the stage. You could write a "stream" endpoint in two lines of PHP — just readfile($path). It would even work, sort of. The browser would receive the bytes, start the video, and play it from the beginning. But the second you tried to drag the scrubber to skip ahead, nothing would happen. Or worse, the player would freeze. Why? Because you didn't handle the Range header.
Here's what's happening under the hood. When a browser plays a video via <video>, it doesn't download the whole file in one shot. It downloads a chunk, starts playing, downloads more in the background. When the user drags the scrubber, the browser sends a NEW request with a header like Range: bytes=10485760- meaning "send me the file starting at byte 10485760." The server is supposed to respond with status 206 Partial Content, a Content-Range header indicating what slice it's sending, and the actual bytes from that offset. If the server ignores the Range header and just sends the whole file from the start, the browser gives up.
Range requests are also how progressive download works (the player doesn't have to wait for the whole file), how resuming works on big downloads, how Netflix and YouTube and every video streaming service do their magic. The protocol's been around since HTTP/1.1 in 1997. We're going to implement the server side of it in PHP. About 30 lines. The internet works because lots of servers do this exact thing.
The streaming endpoint, conceptually
Here's what stream.php needs to do, in order:
- Read the
?id=from the URL. - Look up the media row in the database to find the path and MIME type.
- Validate that the file exists on disk (don't crash, return 404).
- Check if the request has a
Rangeheader. - If no Range, send the whole file with status 200 and Content-Length.
- If there IS a Range, parse it, send status 206, Content-Range, and just the bytes the browser asked for.
- Stream the bytes in chunks so we don't load the whole file into memory.
Each of those steps is one or two lines of PHP. The whole thing fits on one screen. Let's walk through each chunk.
Parsing the Range header
The Range header looks like bytes=START-END. Both numbers are byte offsets. The END is optional — if missing, it means "to the end of the file." A regex pulls them apart cleanly:
$start = 0;
$end = $size - 1;
if (isset($_SERVER['HTTP_RANGE'])) {
if (preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
$start = (int) $m[1];
if ($m[2] !== '') {
$end = (int) $m[2];
}
}
}
Note that PHP exposes incoming HTTP headers via $_SERVER['HTTP_*'] where the header name is uppercased and dashes become underscores. So Range becomes HTTP_RANGE. Content-Type would become HTTP_CONTENT_TYPE. Etc. Slightly weird convention, but consistent once you see it.
Setting the response headers
For a partial response, we need to send back a few critical headers:
HTTP/1.1 206 Partial Content— tells the browser "I'm sending just a slice, not the whole file."Content-Type— the MIME we stored at scan time.Content-Length— the size of THIS RESPONSE (the slice), not the full file.Content-Range: bytes START-END/TOTAL— tells the browser exactly which slice and how big the full file is.Accept-Ranges: bytes— advertises that we support Range requests at all.
For a full-file response (no Range), it's status 200 and we skip Content-Range. Simple.
Streaming chunks, not loading everything
Here's the most important detail in the whole chapter. We do NOT do echo file_get_contents($path) — that would load the entire file into memory before sending. For a 4 GB movie, you'd blow your 512 MB memory_limit instantly. Instead, we open the file, seek to the start byte, and read+echo in small chunks of 8 KB at a time.
$fp = fopen($path, 'rb');
fseek($fp, $start);
$chunk = 8192; // 8 KB chunks
$bytes_left = $end - $start + 1;
while ($bytes_left > 0 && !feof($fp)) {
$read = ($bytes_left > $chunk) ? $chunk : $bytes_left;
echo fread($fp, $read);
flush(); // push bytes to the network NOW
$bytes_left -= $read;
}
fclose($fp);
The flush() call is important — it tells PHP to send what we've echo'd so far to the browser immediately, instead of buffering it all up. Without flush, the browser doesn't see anything until the whole file has been read. The chunk size (8 KB) is a good balance between not too many syscalls and not too much memory at once.
🐍 Python: This is structurally identical to what Flask's Response(generator) with yield chunk does, or what Django's StreamingHttpResponse does internally. The HTTP semantics are universal — only the language API differs.
One more critical detail: path safety
You're about to write code that takes a database row and reads a file from disk. The path comes from the database, which was populated by your scanner — so in theory it's trusted. But adopt the habit of double-checking ANY file path you read, because the day you accidentally let users pass a path directly is the day a malicious user reads /etc/passwd through your server.
For our case, since the path is fetched by ID from the database and the user only ever sees IDs in URLs, we're safe. But add a realpath() check anyway — if the file no longer exists, we want a clean 404, not a fatal PHP error.
$_GET or $_POST and feed it to fopen. That's how directory traversal vulnerabilities happen (?path=../../../../etc/passwd). Always store paths in the database and reference them by integer ID from the URL. That's exactly what we're doing in stream.php.
Build: stream.php — The Real Streaming Endpoint
Okay, this is the chapter you wanted to write. By the end of this project, you'll have a working streaming endpoint with proper HTTP Range support. You'll be able to hit it directly from a browser tab and see the audio/video play. Even cooler, in the next chapter when we build the player, video scrubbing will Just Work because of what we're doing now.
- Create
/home/erictey/server/homestream/public/stream.php. - Paste this in:
<?php declare(strict_types=1); require __DIR__ . '/../lib/db.php'; $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { http_response_code(400); exit('Bad ID'); } $stmt = db()->prepare("SELECT path, mime FROM media WHERE id = ?"); $stmt->execute([$id]); $row = $stmt->fetch(); if (!$row) { http_response_code(404); exit('Not found'); } $path = $row['path']; $mime = $row['mime'] ?: 'application/octet-stream'; if (!is_file($path)) { http_response_code(404); exit('File missing on disk'); } $size = filesize($path); $fp = fopen($path, 'rb'); if (!$fp) { http_response_code(500); exit('Cannot open file'); } // ---- parse Range header ---- $start = 0; $end = $size - 1; $is_partial = false; if (isset($_SERVER['HTTP_RANGE'])) { if (preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) { $start = (int)$m[1]; if ($m[2] !== '') { $end = min((int)$m[2], $size - 1); } if ($start > $end || $start >= $size) { http_response_code(416); // Range Not Satisfiable header("Content-Range: bytes */$size"); exit; } $is_partial = true; } } $length = $end - $start + 1; // ---- response headers ---- if ($is_partial) { http_response_code(206); header("Content-Range: bytes $start-$end/$size"); } else { http_response_code(200); } header("Content-Type: $mime"); header("Content-Length: $length"); header("Accept-Ranges: bytes"); header("Cache-Control: public, max-age=86400"); // Prevent PHP's output buffering from interfering with streaming while (ob_get_level()) { ob_end_clean(); } // ---- stream the bytes ---- fseek($fp, $start); $chunk = 8192; $bytes_left = $length; while ($bytes_left > 0 && !feof($fp)) { $read_size = ($bytes_left > $chunk) ? $chunk : $bytes_left; echo fread($fp, $read_size); flush(); $bytes_left -= $read_size; } fclose($fp); - Find the ID of one of your media items. Run
sudo mariadb -e "SELECT id, title FROM homestream.media LIMIT 5;"and pick one. - Hit the stream endpoint directly in a browser:
http://192.168.0.19/server/homestream/public/stream.php?id=1(sub in your real ID). - For an audio file, you should hear it start playing in the browser's built-in player. For a video file, you should see a video player appear.
- Try dragging the scrubber forward. If it jumps without rebuffering for ages, the Range request is working. That's the win.
Common gotchas if it's not working:
- 403 Forbidden: Apache's www-data user can't read your media folder. Run
sudo chmod -R o+rX /home/erictey/mediato grant read access to "others." The capital R in+rXsets read on files and execute (traverse) on directories — the magic combo. - Plays but won't seek: the Range header isn't being parsed. Add
error_log('RANGE: ' . ($_SERVER['HTTP_RANGE'] ?? 'none'));after the parsing block and watchtail -f /var/log/php_errors.logwhile you scrub. You should see the header come through. - Memory exhausted: the chunk streaming isn't working. Check that the
while (ob_get_level())block ran — that's what disables PHP's output buffer so the chunk loop actually streams.
Stretch goals:
- Use Apache's
X-Sendfilemodule to delegate streaming to Apache itself. Tiny PHP change, massive performance win for big files. Look upmod_xsendfile. - Add basic auth check (require login) before streaming. Anyone with the URL can grab files right now.
- Increment a "play count" column each time a file is streamed past, say, 30 seconds.
- Log every stream request to a separate "plays" table so you can build a "most played" view later.
What you flexed: the HTTP Range protocol, the difference between 200 and 206, fseek/fread for chunked file I/O, the importance of flush() during streaming, disabling output buffering, the realpath / 404 path-safety habit, and Content-Range/Accept-Ranges headers. This is one of the more technically substantial things you've built. Take a beat to appreciate it — you literally implemented part of the HTTP spec.