tips

DASH Deep Dive: SegmentTemplate, ContentProtection, and Multi-View MPD Structure

Past the beginner MPD explainer — SegmentTemplate vs SegmentTimeline, $Number$ vs $Time$, ContentProtection signaling, and the structural reasons DASH is harder to parse but easier to download than HLS.
FlowPick Team
15 min read
# dash # mpd # deep-dive # segmenttemplate # drm # streaming

The beginner DASH guide explains what an MPD is and why a 12KB XML file shows up instead of an MP4. This is the follow-up — for people who've opened an MPD, seen 400 lines of XML with <SegmentTemplate>, <SegmentTimeline>, <ContentProtection> and <AdaptationSet> and wondered which parts actually matter.

If you're building a DASH parser or trying to understand why your downloader gets some streams but not others, this is the structure.

The MPD hierarchy

Every MPD has the same nested structure:

MPD
├── Period                  # A chunk of presentation (e.g., main content, ad)
│   ├── AdaptationSet       # A set of interchangeable renditions (video, audio, etc.)
│   │   ├── Representation  # A specific rendition (1080p, 720p, etc.)
│   │   │   ├── SegmentList     # Explicit segment URLs
│   │   │   ├── SegmentTemplate # Templated segment URLs
│   │   │   └── SegmentBase     # Single segment with byte ranges
│   │   └── ContentProtection   # DRM signaling (Widevine, PlayReady)
│   └── EventStream         # Out-of-band events (ID3-like)

A typical movie MPD has 1 Period, 2 AdaptationSets (video + audio), 5-10 Representations per set. A live stream with ad breaks might have 4+ Periods. A multi-angle sports stream might have 1 AdaptationSet per camera angle.

The key insight: everything in DASH is about avoiding the need to list every segment URL. HLS lists every segment. DASH prefers templates.

SegmentTemplate: the 90% case

<SegmentTemplate
  initialization="video/init.mp4"
  media="video/$Number$.m4s"
  startNumber="1"
  timescale="1000"
  duration="6000" />

Translation:

  • The init segment is video/init.mp4 (the fMP4 initialization box)
  • Each media segment follows the pattern video/$Number$.m4s where $Number$ starts at 1
  • Each segment is 6000 milliseconds (6 seconds, given timescale=1000)

To download this Representation, you generate URLs by substituting $Number$:

const startNumber = 1
const duration = 6000        // ms
const timescale = 1000       // 1 unit = 1 ms
const totalDuration = 7200000 // ms — from the Representation's @duration attribute

const segmentCount = Math.ceil(totalDuration * timescale / (duration * timescale))
// = 1200 segments

const urls = []
for (let n = startNumber; n < startNumber + segmentCount; n++) {
  urls.push(`video/${n}.m4s`)
}

This is dramatically more compact than HLS — instead of 1200 lines of #EXTINF + URL, you have one <SegmentTemplate> element. The trade-off: the parser has to do the math, and edge cases (last segment is shorter, segment duration varies) require care.

$Number$ vs $Time$

$Number$ is the segment index (1, 2, 3, ...). $Time$ is the segment's start time in timescale units. Some manifests use one, some use both:

<SegmentTemplate
  media="video/$Time$.m4s"
  timescale="1000"
  duration="6000" />

To resolve $Time$, multiply the segment index by the duration:

const segmentTime = (n - startNumber) * duration
const url = `video/${segmentTime}.m4s`

If you forget to multiply by timescale, your URLs are off by a factor of 1000 and you get 404s. This is the most common DASH parsing bug.

SegmentTimeline: when durations vary

Live streams and dynamic content use <SegmentTimeline> instead of fixed duration:

<SegmentTemplate timescale="1000" initialization="init.mp4" media="$Number$.m4s">
  <SegmentTimeline>
    <S t="0" d="6000" />
    <S t="6000" d="6000" />
    <S t="12000" d="4000" />
    <S t="16000" d="6000" r="3" />  <!-- r=3 means repeat 3 times: 4 segments total -->
    <S t="40000" d="6000" />
  </SegmentTimeline>
</SegmentTemplate>
  • t — start time in timescale units
  • d — duration in timescale units
  • r — repeat count (this entry represents r+1 segments)

For live streams, this timeline grows over time. A typical pattern:

  1. Client fetches MPD, sees segments 1-50 with a minimumUpdatePeriod of 2 seconds
  2. Client plays through segments 1-50
  3. Client re-fetches MPD; now it shows segments 1-52 (timeline grew)
  4. Repeat until MPD@endOfAvailability or the stream ends

For a downloader, you typically want to either:

  • Wait until the stream ends (VOD conversion) — not always possible
  • Snapshot the timeline as-is and accept you'll miss content added later

FlowPick takes the snapshot approach: capture the current timeline, download those segments, merge. If the stream is still live, the user can re-trigger to get newer segments.

ContentProtection: DRM signaling

<ContentProtection
  schemeIdUri="urn:mpeg:dash:mp4protection:2011"
  value="cenc"
  cenc:default_KID="12345678-1234-1234-1234-123456789012" />

<ContentProtection
  schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
  value="Widevine">
  <cenc:pssh>AAAA...</cenc:pssh>
</ContentProtection>

<ContentProtection
  schemeIdUri="urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95"
  value="PlayReady">
  <cenc:pssh>BBBB...</cenc:pssh>
</ContentProtection>

The UUIDs identify DRM systems:

  • edef8ba9-79d6-4ace-a3c8-27dcd51d21ed — Widevine (Google)
  • 9a04f079-9840-4286-ab92-e65be0885f95 — PlayReady (Microsoft)
  • f239e769-efa3-4850-9c16-a9036d3fe1c1 — Adobe Primetime
  • 94ce86fb-07ff-4f43-adb8-93d2fa968ca2 — FairPlay (Apple, in DASH context)

The <cenc:pssh> element is a Protection System Specific Header — a base64 blob containing the key ID and system-specific data. Browsers pass this to EME (Encrypted Media Extensions) which talks to the platform's CDM (Content Decryption Module).

Here's the line: FlowPick does not, will not, and cannot decrypt DRM-protected DASH content. Neither can any legitimate browser extension or open-source tool. The CDM is a black box — you give it an encrypted buffer, it gives you decrypted frames for display only, never for export. Bypassing this is illegal under anti-circumvention laws (DMCA §1201 in the US, similar elsewhere) and breaks the CDM's contract.

The DASH spec includes an "unprotected" path: if ContentProtection is absent from an AdaptationSet, segments are unencrypted and a downloader can fetch and merge them freely. This is why FlowPick works on Bilibili, Udemy-free, and most corporate LMS streams, but not on Netflix/Disney+/HBO Max.

Common DRM-free but "protected-feeling" patterns

Not every "can't download this" is DRM. Some patterns make download hard without actual encryption:

  1. Tokenized URLs with short TTL?token=abc&expires=1234567890. Tokens expire in seconds; you have to fetch segments immediately after parsing the manifest. FlowPick does this; batch downloaders that parse URLs hours in advance fail.
  2. CORS restrictions on the segment CDN — but extensions aren't subject to CORS for fetches from the page's origin, so this rarely stops a sniffer.
  3. <BaseURL> rewriting — the MPD's BaseURL element can change mid-document. Each BaseURL is resolved relative to its parent, so you have to track the URL chain. This is the DASH equivalent of HLS relative URIs, but more error-prone.

Multi-period MPDs: ads and live events

<Period id="ad1" start="PT0S" duration="PT30S">...</Period>
<Period id="main" start="PT30S">...</Period>
<Period id="ad2" start="PT1800S" duration="PT30S">...</Period>
<Period id="main2" start="PT1830S">...</Period>

Each Period has its own AdaptationSets. To download the full presentation:

  1. Iterate periods in order of @start
  2. For each period, download all video + audio segments
  3. Concatenate the periods — but watch for codec changes between periods

Ad breaks commonly use different codecs than the main content (the ad might be H.264 baseline while the movie is H.264 high). A naive concatenation produces an MP4 that plays the main content fine but shows corruption during ads.

The fix is either:

  1. Skip ad periods entirely (FlowPick's approach — most users don't want ads in their download anyway)
  2. Re-encode ad periods to match the main content (slow, requires transcoding)
  3. Use a smarter muxer that handles codec switches (FFmpeg does, but it's complex)

Multi-view and trick-mode AdaptationSets

<AdaptationSet id="1" contentType="video" group="1">
  <!-- Main camera angles -->
</AdaptationSet>
<AdaptationSet id="2" contentType="video" group="1">
  <!-- Alternate camera angles, same group -->
</AdaptationSet>
<AdaptationSet id="3" contentType="video" group="2">
  <!-- Trick mode (thumbnails for seek bar) -->
</AdaptationSet>

group="1" means these AdaptationSets are interchangeable views of the same content. A player lets the user switch angles. A downloader needs to pick one — usually the first, but ideally let the user choose.

Trick-mode AdaptationSets (group=2 here) are tiny low-FPS versions used for the scrub preview on the seek bar. They're useless for a download — make sure your parser doesn't accidentally grab them as the "video."

Pre-fetching and parallelism

The biggest practical win in DASH download speed: fetch segments in parallel.

async function downloadRepresentation(rep, concurrency = 6) {
  const segmentUrls = computeSegmentUrls(rep)
  const initBytes = await fetch(rep.initUrl).then(r => r.arrayBuffer())

  const results = new Array(segmentUrls.length)
  let nextIndex = 0

  async function worker() {
    while (nextIndex < segmentUrls.length) {
      const i = nextIndex++
      const resp = await fetch(segmentUrls[i])
      results[i] = new Uint8Array(await resp.arrayBuffer())
    }
  }

  await Promise.all(Array.from({ length: concurrency }, () => worker()))

  // Concatenate: init + segments in order
  const total = initBytes.byteLength + results.reduce((s, b) => s + b.byteLength, 0)
  const out = new Uint8Array(total)
  let offset = 0
  out.set(new Uint8Array(initBytes), offset); offset += initBytes.byteLength
  for (const buf of results) {
    out.set(buf, offset); offset += buf.byteLength
  }
  return out
}

Six concurrent connections is the sweet spot — more triggers CDN rate limiting, fewer leaves bandwidth on the table. The Promise.all worker pool pattern ensures segments land in the right array slots even if they complete out of order.

This is exactly the pattern FlowPick uses; the in-browser merge writeup covers how the resulting buffer gets muxed.

Common pitfalls

Pitfall 1: ignoring BaseURL. If the MPD is at https://cdn.example.com/v/manifest.mpd and contains <BaseURL>https://other-cdn.example.com/</BaseURL>, all relative URLs in that section resolve against other-cdn.example.com, not the MPD's origin. Forgetting this is the #1 cause of "404 on every segment" bugs.

Pitfall 2: misparsing timescale. duration="6000" with timescale="1000" means 6 seconds. With timescale="90000" (common for video), it means 0.067 seconds. Always check timescale — it can differ between AdaptationSets in the same MPD.

Pitfall 3: forgetting the init segment. Unlike HLS where (in the TS case) each segment is self-describing, DASH fMP4 segments are useless without the init segment. A download that "works" but produces an unplayable file usually missed initialization="init.mp4".

Pitfall 4: live MPDs that update. If you parse the MPD once and download based on that snapshot, you'll miss segments added after your parse. For VOD, this is fine. For live, you have to either re-fetch periodically or wait for the stream to end.

References

Summary

DASH and HLS solve the same problem — adaptive streaming over HTTP — with opposite philosophies. HLS lists every segment explicitly; DASH prefers templates. HLS uses MPEG-TS by default; DASH uses fMP4. HLS DRM is Apple-specific (FairPlay); DASH DRM is multi-vendor (Widevine, PlayReady, FairPlay) via CENC.

For a downloader, DASH is structurally easier: fMP4 segments concatenate cleanly, init segments make codec info explicit, and $Number$ substitution means you can compute URLs without fetching additional playlists. The complexity is in the XML parsing — but once you have SegmentTemplate, SegmentTimeline, BaseURL, and ContentProtection handled, you've covered 95% of real-world MPDs.

The next article in this series gets into the container format itself — why fMP4 concatenation works, what's in an moof box, and why "remux not transcode" is the right philosophy.