Have you ever tried building your own Netflix?
It is one of those projects that starts out sounding so simple. You think: "Hey, I have a bunch of high-quality video files on my server, and I want to watch them on my laptop, my phone, and my TV. How hard can it be?"
Then you actually try to do it, and you run face-first into a brick wall of network protocols, video containers, audio tracks, and hardware constraints, and so on...
Today I'm going to deep dive into streaming massive, 7GB+ movie files on the fly, without duplicating files, without hogging my disk space, and most importantly, making sure that seeking actually works on device setups like Chromecast and Smart TVs in general.
The landscape: Three ways to stream video
Before I talk about HLS, you need to understand the history of how video gets from my server to the frontend client. There are three primary ways we can handle this. Let's break down each approach, look at how they work under the hood, and see where they succeed and where they fail.
Approach 1: Partial HTTP requests (Static Byte-Range MP4)
This is the simplest way to stream. I have a pre-compiled, browser-compatible MP4 file sitting on my 24TB disk.
When I play this video in an HTML <video> tag, the browser does not download the entire 7GB file at once (it would take years just to get to start the movie). Instead, it sends a GET request to the server, and the server responds with two critical headers:
-
**Content-Length: 7500000000**telling the browser the exact file size (it could have even more 0s in it). -
**Accept-Ranges: bytes**telling the browser that it supports byte-range requests.
Once the browser knows the server supports ranges, it sends a request with a header like Range: bytes=0-1048576 to fetch the first megabyte. As you watch, the browser keeps requesting subsequent byte ranges.
When the user seeks to the middle of the timeline, the browser looks at the index metadata inside the MP4 container (called the moov atom), calculates which byte offset that corresponds to that timestamp, and requests Range: bytes=3500000000-, and the server streams it.
- Why it is great: It is incredibly simple, requires no server-side logic, other than serving static files, and seeking is handled natively by the browser.
- Why it fails for me: It requires a single, static file. If the video is an MKV file containing multiple audio tracks (English, Spanish, Japanese, etc), the browsers cannot play it natively. If I pre-transcode it into multiple MP4 files to support different audio options, I would duplicate my 7GB files (which I have plenty) and destroy the hard drive storage.
Approach 2: Remuxing and chunking by seconds (On-the-Fly Progressive MP4)
MKV is an incredible container. It can hold multiple video tracks, multiple audio tracks in different languages (English, Spanish, Japanese), and multiple subtitle tracks.
But web browsers cannot play MKV files natively. They need MP4.
If I want to stream these MKV movies, I have two choices:
- Pre-transcode everything: Convert every MKV file to an MP4 file beforehand. But wait! If we do that, we lose the ability to easily select different audio tracks on the fly, and we double our disk usage. Storing two versions of a 7GB movie means 14GB of storage. If you have hundreds of movies, your hard drives will cry.
- On-the-fly remuxing: When the user requests a video, we extract the video track and their selected audio track, package them into an MP4 container on the fly, and stream that stream directly to the browser.
To avoid duplicating files, the server tries on-the-fly remuxing, we are going with option number 2. We keep the original MKV file intact (this is a must for me) and when the user selects an audio track, the server spawns an FFmpeg process.
FFmpeg then reads the original MKV file, extracts the H.264 video track, combines it with the selected pre-extracted AAC audio track, and repackages them into an MP4 container stream. Because we are only copying the streams (-c copy) and not re-encoding the video frames, this process uses almost no CPU (yay!).
But since we are generating this stream on the fly, the video player doesn't know what the final file size will be... So now the server streams the data using Transfer-Encoding: chunked and sets Accept-Ranges: none. And the total duration of the video is sent to the frontend by a different backend app (one that stores all the metadata about the movie).
HTTP/1.1 200 OK
Content-Type: video/mp4
Transfer-Encoding: chunked
Accept-Ranges: none
To allow seeking, the client player must encode the seek target in the URL (like ?start=600 for starting on the minute 00:10:00). When the user seeks, the client kills the old stream, sends a new request with the new start time, and the server spawns a new FFmpeg process seeking to that second.
- Why it is great: It keeps my original files intact, supports multiple audio tracks dynamically, and takes zero extra storage.
- Why it fails for me: While it works on desktop and Android browsers, it breaks on Chromecast and Smart TVs and IOS devices. These devices have rigid hardware media decoders. Because the stream has no content length and range requests are disabled, the TV treats it as a live broadcast. The timeline shows no duration. Furthermore, when the TV tries to seek, it issues byte-range requests. Since the server cannot fulfill them, or if the client forces a reload with a new container header, the TV's decoder crashes and resets the video to 00:00:00.
Why seeking back to 00:00 happens
If the user attempts to seek, the Chromecast media engine calculates a byte offset based on what it thinks the file layout is. It sends a request like Range: bytes=45000000-.
But our server does not support byte ranges on this dynamic endpoint!
If we try to bypass this by intercepting the seek in our app and restarting the stream from a new time (e.g. ?start=600), the server spawns a new FFmpeg process. This process outputs a new set of fMP4 headers (ftyp and moov).
When the Chromecast's hardware media decoder receives this new header data mid-stream, it panics. It was expecting raw movie data from byte offset 45000000 of the old file layout, not a brand-new container index. The player crashes, buffers infinitely, or resets back to 00:00:00.
If the user pauses the video and the connection times out, the player tries to resume by sending a range request. The server cannot fulfill it, and the movie starts over from the beginning.
Approach 3: Dynamic HLS (Virtual Segmented Streaming)
This is the modern industry standard, and it is the solution we are implementing.
HLS is an industry-standard streaming protocol. It works by breaking a movie down into a text playlist manifest (.m3u8) and a large number of tiny, self-contained video segments (usually .ts or .mp4 files), each containing about 10 seconds of video.
When a player loads an HLS stream, it first downloads the manifest file. The manifest contains the URLs of all the segments and the duration of each segment.
Instead of treating the movie as one large progressive file, HTTP Live Streaming (HLS) breaks the movie down into a text playlist manifest (.m3u8) and a large number of tiny, 10-second video segments (usually .ts files).
Instead of slicing the 7GB file into physical files on disk, we do it virtually.
When the user seeks to 00:10:00, the player simply calculates that it needs segment 60 by using the manifest generated, cancels the current segment request, and asks the server for segment 60. The server spawns a fast FFmpeg copy process, extracts that specific 10-second chunk using input seeking, and streams it back.
Here is what the manifest looks like:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:10.000000,
/video/segment?index=0
#EXTINF:10.000000,
/video/segment?index=1
#EXTINF:10.000000,
/video/segment?index=2
#EXT-X-ENDLIST
The TV player reads this list, and because the manifest ends with the #EXT-X-ENDLIST tag, the player knows this is a completed Movie (Video-on-Demand) rather than a live stream. The timeline displays the full length of the film, and seeking is fully enabled.
If the user seeks to 00:10:00 (600 seconds), the player looks at the manifest. Since each segment is 10 seconds, it realizes that 10 minutes corresponds to segment 60. It cancels the current segment request and requests /video/segment?index=60.
This is a standard, simple HTTP request. The server does not need to handle byte ranges, and the TV's decoder gets a clean, self-contained video segment. Seeking works perfectly.
- Why it is great: Seeking works natively on Chromecast and Smart TVs because the device handles the seeking calculations at the segment level. No byte-range requests are sent to the dynamic copier, the timeline displays the correct duration, and server overhead remains extremely low.
- Why it fails for me: I don't actually know yet since I haven't finished implementing this solution. I don't even know if its going to work at all or not, but with a bit of Claudio's help (if you know what I'm talking about) maybe I will be able to finally find the perfect solution for my problem.
Detailed comparison matrix
- Partial HTTP Requests (V1): Excellent seeking, correct timeline, zero CPU overhead, but has high disk storage cost (no dynamic audio tracks).
- Progressive MP4 Remuxing (V2): Poor seeking on TVs, broken timeline, low CPU, low storage, able to be scaled up with the other server app's metadata, but fails on Chromecast (kinda).
- Dynamic HLS (V3): Excellent seeking on TVs, correct timeline, low CPU, low storage. It is the best of both worlds... Or at least I hope so.
The dream: keep it simple idio...
Let's set the stage.
I have a collection of high-definition movies. Some are saved as standard web-friendly MP4 files, and others are saved as MKV files. These files are huge—roughly 7GB each.
If you are playing a standard MP4 file in a modern web browser on your laptop, things are easy. Modern web browsers have a native video player that knows how to read these files. But instead of sending the entire 7GB file over the network at once (which would destroy your bandwidth and make the video load forever).
Now, if you are playing an MKV file, things change. That why I need this solution.
The idea behind all of this hypothetical V3 migration is to be able to keep my current infrastructure untouched. Everything already works fine, so why modify it to begin with?
This hypothetical V3 HLS solution will be primarily implemented for Chromecast devices and Smart TVs and if it works there I'll try it on IOS devices (I'm going to try it on them anyways, but thats the initial plan).
Implementing dynamic HLS
We do not want to slice our 7GB movies into thousands of small files on disk. That would consume massive disk space and degrade performance.
Instead, we generate the playlist manifest and the segments dynamically (the magic word).
The manifest generator
When the user requests the manifest, our server reads a tiny, pre-generated JSON metadata file containing the exact duration of the movie.
We generate a virtual manifest mapping out the entire video into 10-second segments.
Here is a hypothetical implementation of the manifest controller in Node.js:
// Hypothetical Express handler for the HLS Playlist
app.get("/video/playlist.m3u8", async (req, res) => {
const { movieName, audioTrack } = req.query;
const segmentDuration = 10; // seconds
try {
// Read pre-extracted metadata containing the total movie duration
const metadata = await readMetadataFile(movieName);
const duration = parseFloat(metadata.duration);
const lines = [
"#EXTM3U",
"#EXT-X-VERSION:3",
`#EXT-X-TARGETDURATION:${segmentDuration}`,
"#EXT-X-MEDIA-SEQUENCE:0",
"#EXT-X-PLAYLIST-TYPE:VOD",
];
const totalSegments = Math.ceil(duration / segmentDuration);
for (let i = 0; i < totalSegments; i++) {
const isLast = i === totalSegments - 1;
const currentDuration = isLast
? duration % segmentDuration || segmentDuration
: segmentDuration;
lines.push(`#EXTINF:${currentDuration.toFixed(6)},`);
lines.push(
`/video/segment?movieName=${movieName}&audioTrack=${audioTrack}&segment=${i}`
);
}
lines.push("#EXT-X-ENDLIST");
res.writeHead(200, {
"Content-Type": "application/x-mpegURL",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Credentials": "true",
"Cache-Control": "no-cache, no-store, must-revalidate",
});
res.end(lines.join("\ n"));
} catch (err) {
res.status(500).send("Error generating manifest");
}
});
When this is sent to the Chromecast, it parses the manifest and renders the full timeline immediately.
Spawning the segment generator
When the TV requests a segment (e.g. /video/segment?segment=60), the server must extract that specific 10-second slice.
To achieve this, the server spawns an FFmpeg process. We map two inputs: the original video container (our 7GB MKV file) and the pre-extracted audio file.
Here is the flow diagram of this architecture:
To make sure the TV receives this segment instantly without buffering, we apply two optimizations:
1. Fast input seeking
We place the seek parameter -ss before the input file -i.
This tells FFmpeg to perform input seeking. Instead of decoding frames from the start of the movie, FFmpeg reads the container index and jumps directly to the target timestamp. For segment 60, this seek takes less than 1ms.
2. Stream copying
We use -c copy to copy the video stream without re-encoding.
We map the original video track and the pre-extracted AAC audio track, packaging them into an MPEG-TS container (.ts). MPEG-TS is the standard transport container for HLS. It has small packet headers every 188 bytes, making it incredibly resilient to network drops and easy for player hardware to parse.
Here is a hypothetical implementation of the segment controller:
const { spawn } = require("child_process");
app.get("/video/segment", (req, res) => {
const { movieName, audioTrack, segment } = req.query;
const segmentDuration = 10;
const segmentIndex = parseInt(segment, 10);
const startTime = (segmentIndex * segmentDuration).toString();
// Map file paths
const videoFilePath = getMoviePath(movieName);
const audioFilePath = getAudioTrackPath(movieName, audioTrack);
res.writeHead(200, {
"Content-Type": "video/MP2T",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Credentials": "true",
"Cache-Control": "public, max-age=3600", // Segments are static, cache them!
});
// Run FFmpeg: fast input seeking, stream copy, export as mpegts
const ffmpeg = spawn("ffmpeg", [
"-ss",
startTime, // Seek video input
"-i",
videoFilePath,
"-ss",
startTime, // Seek audio input
"-i",
audioFilePath,
"-t",
segmentDuration.toString(), // Output duration limit (10s)
"-map",
"0:v:0", // Map video
"-map",
"1:a:0", // Map audio
"-c",
"copy", // Stream copy (fast!)
"-avoid_negative_ts",
"make_zero",
"-f",
"mpegts", // Output format
"pipe:1", // Output to stdout pipe
]);
ffmpeg.stdout.pipe(res);
// If client closes connection early, kill the process
req.on("close", () => {
if (!ffmpeg.killed) {
ffmpeg.kill("SIGKILL");
}
});
});
Because of stream copying and input seeking, this entire operation takes under 100ms and consumes almost no CPU on the server.
Caching and chromecast performance
One of the biggest advantages of HLS is caching.
With progressive MP4 streaming, caching is difficult because the requests are made as variable byte ranges.
With HLS, each 10-second segment is a distinct file request. Segment 60 will always contain the exact same video data. By sending a Cache-Control: public, max-age=3600 header, we allow browser caches, CDNs, and Chromecast hardware caches to store these files.
If a user seeks back to a section they already watched, the player loads the segment from its local cache instantly, without placing any load on our server.
Furthermore, Chromecast receiver shells require Cross-Origin Resource Sharing (CORS) permissions. Without returning Access-Control-Allow-Origin: * in the playlist and segment headers, the Chromecast receiver will refuse to play the stream, throwing a CORS block error.
Conclusion
By migrating from progressive MP4 remuxing to Dynamic HLS, we resolved our streaming issues:
-
The timeline displays correctly on Smart TVs and Chromecast because the dynamic
.m3u8manifest lists all segment lengths upfront. - Seeking is flawless because the player requests specific 10-second chunks, avoiding complex byte-range requests.
- Server CPU overhead is zero because we copy the tracks without transcoding.
- Disk storage is minimized because we store only the original video file and lightweight pre-extracted audio tracks.
If you are building your own media server, go straight to Dynamic HLS. It is the most robust and scalable way to stream high-definition content to any device.
If you made this far, thank you. I'm writting these posts because I'm trying to demonstrate a point to myself: I am a capable developer, and AI is not going to replace me any time soon.
View more of my content at Chaldea Foundation News
Thanks again.
Top comments (0)