tips

HLS Deep Dive: Encryption, Multi-Track Audio, and the EXT-X Tags Actually Worth Knowing

Past the beginner M3U8 guide — EXT-X-KEY, EXT-X-MEDIA, discontinuities, and the corner cases that break naive HLS downloaders. With real manifest snippets and the logic FlowPick uses to handle them.
FlowPick Team
16 min read
# hls # m3u8 # deep-dive # encryption # multi-track # streaming

The beginner HLS guide covers "what is M3U8, how do I download it." This is the other one — the one for people who've already tried to download a stream and gotten a 200KB .m3u8 that references 8 audio tracks, 4 subtitle languages, and a #EXT-X-KEY tag that makes VLC spit out garbage.

If you've ever looked at a real-world HLS manifest from Netflix, Disney+, or a paid course platform and wondered why your downloader produced silent video or a corrupted file, this is why.

What a real HLS manifest looks like

Most tutorials use this as the example:

#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:3
#EXTINF:6.0,
segment0.ts
#EXTINF:6.0,
segment1.ts
#EXTINF:4.2,
segment2.ts
#EXT-X-ENDLIST

Cute. Real manifests don't look like this. Here's an actual master playlist from a major platform, trimmed but representative:

#EXTM3U
#EXT-X-VERSION:6
#EXT-X-INDEPENDENT-SEGMENTS:yes

# AUDIO groups
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac-64",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,CHANNELS="2",URI="audio/eng_64.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac-128",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,CHANNELS="2",URI="audio/eng_128.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac-64",NAME="Español",LANGUAGE="es",DEFAULT=NO,AUTOSELECT=YES,CHANNELS="2",URI="audio/spa_64.m3u8"
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",LANGUAGE="en",DEFAULT=NO,AUTOSELECT=YES,URI="subs/en.m3u8"
#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc",NAME="CC",LANGUAGE="en",DEFAULT=NO,AUTOSELECT=YES,INSTREAM-ID="CC1"

# VIDEO variants
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=854x480,CODECS="avc1.64001f,mp4a.40.2",AUDIO="aac-64",SUBTITLES="subs",CLOSED-CAPTIONS="cc"
v_480.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720,CODECS="avc1.640020,mp4a.40.2",AUDIO="aac-128",SUBTITLES="subs",CLOSED-CAPTIONS="cc"
v_720.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2",AUDIO="aac-128",SUBTITLES="subs",CLOSED-CAPTIONS="cc"
v_1080.m3u8

The actual media segments aren't in here at all. The master playlist points to variant playlists, each of which points to segments. And audio lives in its own playlists. If your downloader just grabs v_1080.m3u8 and concats the segments, you get video with no audio. This is the #1 cause of "my HLS download has no sound" reports.

The CODECS field matters

CODECS="avc1.640028,mp4a.40.2" isn't decoration. That's:

  • avc1.640028 — H.264 High profile, level 4.0. The first two hex digits after avc1. encode profile (64 = High), constraint flags, and level (28 = 4.0).
  • mp4a.40.2 — AAC-LC.

Why this matters: if a variant says avc1.640028 but you try to mux the segments into an MP4 with the wrong codec box (hvc1 instead of avc1), the file won't play. Naive concatenation of .ts segments avoids this because MPEG-TS carries its own codec info; remuxing into MP4 requires you to parse this field and write the correct stsd box.

EXT-X-KEY: encryption, and why "just decrypt it" isn't a sentence

When a manifest includes:

#EXT-X-KEY:METHOD=AES-128,URI="https://cdn.example.com/key.bin",IV=0x1a2b3c4d5e6f7890abcdef1234567890

every subsequent segment is AES-128-CBC encrypted with the key at URI and the IV specified. To decrypt:

const keyResp = await fetch(keyUri)
const keyBytes = await keyResp.arrayBuffer()  // 16 bytes
const iv = new Uint8Array(16)
// IV may be literal (as above) or derived from the sequence number
const decrypted = await crypto.subtle.decrypt(
  { name: 'AES-CBC', iv },
  await crypto.subtle.importKey('raw', keyBytes, 'AES-CBC', false, ['decrypt']),
  encryptedSegment
)

crypto.subtle.decrypt is the right tool in-browser — no need for a WASM AES implementation, the native WebCrypto is both faster and constant-time.

Here's the part tutorials skip: METHOD=AES-128 is the easy case. Most major platforms use METHOD=SAMPLE-AES, which is Apple's FairPlay DRM-adjacent scheme. With SAMPLE-AES:

  • Only part of each segment is encrypted (the video NAL units, not the audio or the PES headers).
  • The key URI usually returns an EXT-X-KEY response that's wrapped in FairPlay's license exchange — not a raw 16-byte key.
  • You cannot decrypt this in a browser tab. Period. This is DRM, and it's the reason "FlowPick can't download this Netflix video" is a feature, not a bug. We covered the legal and technical line in the streaming download legality guide.

Original opinion: If a downloader claims to handle FairPlay, Widevine, or PlayReady, it's either lying or it's breaking the law in most jurisdictions. FlowPick explicitly refuses to touch SAMPLE-AES content. The right behavior is to fail loudly, not silently produce a broken file.

EXT-X-MEDIA: audio and subtitle tracks

The EXT-X-MEDIA tag is how HLS does multi-track audio. Each GROUP-ID is a set of interchangeable audio renditions; the variant playlist's AUDIO attribute points to which group it uses.

The implication for downloaders: a "complete download" isn't just the video segments. It's:

  1. Video segments from the chosen variant playlist
  2. Audio segments from one EXT-X-MEDIA entry (usually DEFAULT=YES, but you should let users pick)
  3. Optionally, subtitle segments from TYPE=SUBTITLES
  4. Mux them into a single MP4 with proper track metadata

The muxing step is where it gets spicy. MPEG-TS segments from different playlists have their own PTS timelines; when you concatenate video-only .ts and audio-only .ts into a single MP4, you have to:

  • Parse PES headers from each TS packet to extract PTS
  • Sort packets by PTS
  • Write them into an MP4 with the correct mdia/minf/stbl structure for each track

This is what FlowPick's WebAssembly FFmpeg handles — see the in-browser merge deep dive. The point is: "concat the files" stops working the moment you have separate audio.

The discontinuity tag

#EXT-X-DISCONTINUITY

This means "the timestamps reset after this point." Common in:

  • Live streams with ad breaks (the ad has its own timeline)
  • Stitched playlists where segments come from different encoders

A downloader that just concatenates segments will produce a file where the player jumps backward in time at the discontinuity. You have to either:

  1. Use a remuxer that handles discontinuities (FFmpeg does)
  2. Strip the discontinuity and accept timestamp drift
  3. Skip ad segments entirely (FlowPick's default, since they're usually 2-second .ts files polluting the stream — see the v1.0.0 release notes for the size filter that catches these)

EXT-X-VERSION: the version tags that matter

VersionWhat it addedWhy you care
2I-frame playlistsTrick mode (fast-forward/rewind) support
3Floating-point EXTINFSub-second durations for low-latency streams
4EXT-X-MEDIA for independent audio/subtitlesMulti-track audio — see above
5EXT-X-KEY IV optionalKeys can derive IV from sequence number
6EXT-X-MAPInitialization segment for fMP4 — see next section

If you see #EXT-X-VERSION:6 and there's no #EXT-X-MAP in the variant playlist, something's weird. fMP4 HLS requires the init segment; without it, the segments are unplayable.

EXT-X-MAP and fragmented MP4

#EXT-X-MAP:URI="init.mp4"

This means segments aren't MPEG-TS — they're fragmented MP4 (fMP4), and init.mp4 contains the ftyp and moov boxes needed to interpret them. Without the init segment, you have raw moof/mdat boxes with no codec information.

To merge fMP4 HLS:

  1. Fetch init.mp4 — this is your output file's header
  2. Concatenate segment moof/mdat pairs in sequence order
  3. Optionally rewrite the sidx box if you want a seek index

This is dramatically easier than TS remuxing because fMP4 was designed for concatenation. Apple's LL-HLS uses fMP4 almost exclusively now.

Low-Latency HLS (LL-HLS)

Apple's 2019 spec extension adds:

  • EXT-X-PART — partial segments (typically 200ms instead of 6s)
  • EXT-X-PRELOAD-HINT — tells the client to start fetching a segment that doesn't exist yet
  • EXT-X-RENDITION-REPORT — lets variant playlists reference each other directly

For a downloader, LL-HLS mostly means "more requests, smaller files." The merge logic is identical to regular HLS — you just have to handle EXT-X-PART tags in addition to EXTINF. FlowPick does; if your hand-rolled downloader doesn't, you'll miss partial segments entirely.

Common pitfalls I've actually seen

Pitfall 1: relative URIs. M3U8s use relative URIs extensively. If the master playlist is at https://cdn.example.com/v/master.m3u8 and contains v_720.m3u8, the variant is at https://cdn.example.com/v/v_720.m3u8. But if the master is at https://cdn.example.com/v/master.m3u8?token=abc, the token doesn't automatically propagate. You need to handle query string inheritance manually.

Pitfall 2: byte-range addressing.

#EXT-X-BYTERANGE:522752@1024

This means "the segment is a range of bytes within a larger file." If you fetch the URI without the Range header, you get the whole file. Many CDN-hosted HLS playlists use this for archive content — instead of thousands of tiny files, they have one big file with byte ranges.

Pitfall 3: variant selection by CODECS. If a master playlist has both avc1.* (H.264) and hvc1.* (HEVC) variants, your player might pick HEVC. If your downloader just grabs the first variant, you might get HEVC when you wanted H.264. Always let the user choose, and parse CODECS to label variants.

Pitfall 4: expired tokens. Live stream tokens in the URI often expire in minutes. If you capture a manifest URL and try to download it an hour later, you get 403s. FlowPick re-fetches manifests at download time, which is why "capture then download" sometimes fails on live content.

References

Summary

The beginner M3U8 story — "it's a playlist, fetch the segments, concat them" — covers maybe 60% of real streams. The other 40% involves multi-track audio, encryption, fMP4 init segments, discontinuities, byte ranges, and tokens that expire. A robust HLS downloader has to parse all of EXT-X-MEDIA, EXT-X-KEY, EXT-X-MAP, EXT-X-BYTERANGE, and EXT-X-DISCONTINUITY correctly. If you want to see what "correctly" looks like in practice, FlowPick's open source.

The next post in this series is the DASH/MPD deep dive, which covers the equivalent machinery for MPEG-DASH — SegmentTemplate, $Number$ substitution, ContentProtection, and why DASH is structurally more complex than HLS but practically easier to download.