Skip to main content

hls_runtime/client/
engine.rs

1//! [`HlsClient`] — the sans-IO caller-driven engine.
2
3use alloc::collections::{BTreeMap, BTreeSet, VecDeque};
4use alloc::string::{String, ToString};
5use alloc::vec::Vec;
6
7use broadcast_common::Unpackage;
8use broadcast_hls::{ByteRange, MapTag, MediaPlaylist, MediaSegment, OpenSegment, PreloadHintType};
9use transmux::{Fmp4Demux, TrackSpec, TsDemux};
10
11use super::action::{Action, BlockingReload, ResourceId};
12use super::error::{Error, Result};
13use super::output::Output;
14use super::url;
15
16/// First byte of every MPEG-2 TS packet (ITU-T H.222.0 / ISO/IEC 13818-1
17/// §2.4.3.2 `sync_byte`). Classic MPEG-TS-segment HLS (HLS v3, RFC 8216 —
18/// the dominant legacy/IPTV form) has no `EXT-X-MAP`/init segment at all:
19/// each `.ts` segment is a self-contained PAT/PMT/PES stream, so this byte
20/// is the only available signal to distinguish one from an fMP4/CMAF
21/// segment (which starts with an ISOBMFF box: `ftyp`/`styp`/`moof`) once the
22/// playlist itself has never advertised a Media Initialization Section.
23const TS_SYNC_BYTE: u8 = 0x47;
24
25/// A driveable, sans-IO Low-Latency HLS (RFC 8216bis) playback client.
26///
27/// `HlsClient` never touches a socket or a clock. The caller drives it:
28///
29/// 1. [`HlsClient::new`] seeds the first [`Action::FetchPlaylist`]; drain it
30///    with [`HlsClient::poll`] and perform the GET.
31/// 2. Feed the response back with [`HlsClient::on_playlist`] (playlist) or
32///    [`HlsClient::on_resource`] (init/part/segment bytes) —
33///    [`Action::FetchResource`]'s `id` correlates the two.
34/// 3. Drain [`HlsClient::poll`] again for the next round of actions (a new
35///    reload, newly discoverable parts, a preload-hint prefetch, ...) and
36///    [`HlsClient::next_output`] for newly available [`Output`]s.
37///
38/// # Behaviour
39///
40/// - **Reload scheduling** (issue #717 slice 2): once a playlist advertises
41///   `EXT-X-SERVER-CONTROL`/`EXT-X-PART-INF` **and** the origin's
42///   `CAN-BLOCK-RELOAD` attribute is `YES`
43///   ([`broadcast_hls::LowLatencyConfig::can_block_reload`] is `true` —
44///   *not* merely [`broadcast_hls::MediaPlaylist::low_latency`] being
45///   `Some`, since an origin may carry parts/PART-INF while still
46///   advertising `CAN-BLOCK-RELOAD=NO`), every reload is a Blocking
47///   Playlist Reload (RFC 8216bis §6.2.5.2) naming the next not-yet-seen
48///   Partial Segment's `_HLS_msn`/`_HLS_part`. Otherwise reloads are plain GETs
49///   paced by an [`Action::WaitMs`] hint derived from `#EXT-X-TARGETDURATION`.
50///   `EXT-X-SKIP`/`CAN-SKIP-UNTIL` Playlist Delta Updates (RFC 8216bis §4.4.5.2)
51///   are requested once a full-playlist baseline exists, and merged back into
52///   a full view before further processing — see `merge_delta` internally.
53/// - **Fetch pipeline** (slice 3): the `EXT-X-PRELOAD-HINT`ed part is fetched
54///   ahead of its own appearance as a numbered `EXT-X-PART`; `BYTERANGE`
55///   parts are supported, including the RFC 8216bis §4.4.4.9 "omitted offset
56///   means immediately after the previous sub-range of the same resource"
57///   rule (tracked per resource URL). The Media Initialization Section
58///   (`EXT-X-MAP`) is fetched once and reused for every following resource
59///   until the map changes.
60/// - **Dedup / coalescing**: once *any* of a segment's parts have been
61///   individually fetched, that segment is never re-fetched whole — when it
62///   later closes (`#EXTINF`+URI), the client only fetches whichever of its
63///   parts (if any) are still missing, and marks the segment "delivered" once
64///   every part is accounted for (fetched, or `GAP=YES`). A playlist whose
65///   segments carry **no** parts at all (a non-LL origin) falls back to
66///   fetching the whole segment resource — the two paths never overlap for a
67///   single segment, so a part's samples are never double-counted against its
68///   parent's.
69/// - **Output adapter** (slice 4): exactly one [`Output::Init`] precedes any
70///   [`Output::Samples`]; parts/segments are demuxed via
71///   [`transmux::Fmp4Demux`] (by concatenating the cached init bytes with the
72///   fetched resource — this crate never re-implements ISOBMFF box parsing,
73///   only reuses transmux's), so `Output::Samples` carries real access units,
74///   not opaque container bytes. `#EXT-X-DISCONTINUITY` on a segment surfaces
75///   as [`Output::Discontinuity`] immediately before that segment's first
76///   samples. **Known limitation**: an in-progress ([`OpenSegment`]) segment
77///   carries no discontinuity flag of its own (only a *closed*
78///   [`MediaSegment`] does) — if every part of a segment was already
79///   delivered while it was still open, a discontinuity revealed only once it
80///   closes is signalled late (after those parts' samples, not before). This
81///   is a gap in the current wire model ([`broadcast_hls::OpenSegment`]), not
82///   something this crate can fix locally.
83/// - **Classic MPEG-TS-segment HLS** (issue #760): a playlist that never
84///   advertises an `EXT-X-MAP` (HLS v3, the dominant legacy/IPTV form —
85///   self-contained `.ts` segments carrying their own PAT/PMT/PES, no
86///   separate init resource) routes each fetched Part/Segment through
87///   [`transmux::TsDemux`] instead, content-sniffed by the MPEG-TS sync byte
88///   rather than blocked on an init fetch that will never come. The first
89///   successfully demuxed segment's recovered
90///   [`TrackSpec`]s synthesize the one [`Output::Init`] this crate's contract
91///   requires (via [`transmux::build_init_segment`]) so downstream callers
92///   (e.g. `multimux`'s `HlsPull`, which recovers track specs from
93///   `Output::Init`) need no TS-specific handling of their own. The
94///   fMP4/CMAF plus LL (parts/preload-hint) path above is entirely
95///   unchanged; the two never overlap for a single playlist.
96#[derive(Debug)]
97pub struct HlsClient {
98    playlist_url: String,
99
100    pending_actions: VecDeque<Action>,
101    pending_outputs: VecDeque<Output>,
102
103    init_uri: Option<String>,
104    init_bytes: Option<Vec<u8>>,
105    init_emitted: bool,
106    /// Part/Segment resources delivered before the init segment arrived —
107    /// buffered (in arrival order) and replayed once [`Self::init_bytes`] is
108    /// set, so the caller's fetch/response IO can complete in any order
109    /// (a real HTTP client has no reason to serialize on init-first).
110    pending_demux: VecDeque<(ResourceId, Vec<u8>)>,
111
112    requested: BTreeSet<ResourceId>,
113    delivered_parts: BTreeSet<(u64, u64)>,
114    delivered_segments: BTreeSet<u64>,
115    discontinuous_msns: BTreeSet<u64>,
116    discontinuity_emitted: BTreeSet<u64>,
117    byte_range_cursor: BTreeMap<String, u64>,
118
119    outstanding_fetches: u64,
120    saw_endlist: bool,
121    end_emitted: bool,
122    last_full_playlist: Option<MediaPlaylist>,
123}
124
125impl HlsClient {
126    /// Create a new client for the Media Playlist at `playlist_url`, seeding
127    /// the first [`Action::FetchPlaylist`] (a plain, non-blocking GET — the
128    /// client does not yet know whether the origin supports blocking reload).
129    pub fn new(playlist_url: impl Into<String>) -> Self {
130        let playlist_url = playlist_url.into();
131        let mut pending_actions = VecDeque::new();
132        pending_actions.push_back(Action::FetchPlaylist {
133            url: playlist_url.clone(),
134            blocking: None,
135            skip: false,
136        });
137        Self {
138            playlist_url,
139            pending_actions,
140            pending_outputs: VecDeque::new(),
141            init_uri: None,
142            init_bytes: None,
143            init_emitted: false,
144            pending_demux: VecDeque::new(),
145            requested: BTreeSet::new(),
146            delivered_parts: BTreeSet::new(),
147            delivered_segments: BTreeSet::new(),
148            discontinuous_msns: BTreeSet::new(),
149            discontinuity_emitted: BTreeSet::new(),
150            byte_range_cursor: BTreeMap::new(),
151            outstanding_fetches: 0,
152            saw_endlist: false,
153            end_emitted: false,
154            last_full_playlist: None,
155        }
156    }
157
158    /// The Media Playlist URL this client is following.
159    pub fn playlist_url(&self) -> &str {
160        &self.playlist_url
161    }
162
163    /// Drain the next IO [`Action`] the caller must perform, if any.
164    pub fn poll(&mut self) -> Option<Action> {
165        self.pending_actions.pop_front()
166    }
167
168    /// Drain the next [`Output`] event, if any.
169    pub fn next_output(&mut self) -> Option<Output> {
170        self.pending_outputs.pop_front()
171    }
172
173    /// Feed a freshly fetched Media Playlist response.
174    ///
175    /// # Errors
176    /// [`Error::PlaylistNotUtf8`] / [`Error::PlaylistParse`] on malformed
177    /// input.
178    pub fn on_playlist(&mut self, bytes: &[u8]) -> Result<()> {
179        let text = core::str::from_utf8(bytes)?;
180        let playlist = MediaPlaylist::parse(text)?;
181        let playlist = self.merge_delta(playlist);
182
183        for (i, seg) in playlist.segments.iter().enumerate() {
184            let msn = playlist.media_sequence + i as u64;
185            self.process_closed_segment(msn, seg)?;
186        }
187
188        let next_msn = playlist.media_sequence + playlist.segments.len() as u64;
189        if let Some(open) = &playlist.open_segment {
190            self.process_open_segment(next_msn, open)?;
191        }
192
193        // Prefer the *open* segment's map when present: it's the most
194        // recent (`#EXT-X-MAP` carries forward, so the open segment's view
195        // is never older than the last closed segment's) and, crucially, is
196        // the only way to learn the init segment's URI at all when NO
197        // segment has closed yet (issue #717 slice 5 fix — previously this
198        // only ever looked at the last *closed* segment's map, so a client
199        // tuning into a stream mid-segment couldn't fetch the init segment,
200        // and therefore couldn't demux any of that segment's parts, until
201        // it closed — needlessly inflating glass-to-glass latency by up to
202        // a full segment duration on every fresh connection).
203        let map = playlist
204            .open_segment
205            .as_ref()
206            .and_then(|o| o.map.as_ref())
207            .or_else(|| playlist.segments.last().and_then(|s| s.map.as_ref()));
208        if let Some(map) = map {
209            self.ensure_init_requested(map)?;
210        }
211
212        if let Some(ll) = &playlist.low_latency
213            && let Some(hint_uri) = &ll.preload_hint_part
214        {
215            match ll.preload_hint_type {
216                PreloadHintType::Part => {
217                    let part_idx = playlist
218                        .open_segment
219                        .as_ref()
220                        .map(|o| o.parts.len() as u64)
221                        .unwrap_or(0);
222                    let id = ResourceId::Part {
223                        msn: next_msn,
224                        part: part_idx,
225                    };
226                    let url = url::resolve(&self.playlist_url, hint_uri);
227                    let byte_range = self.resolve_hint_byte_range(&url, ll)?;
228                    self.request_resource(id, url, byte_range);
229                }
230                PreloadHintType::Map => {
231                    let map = MapTag {
232                        uri: hint_uri.clone(),
233                        byte_range: ll.preload_hint_byte_range_length.map(|length| ByteRange {
234                            length,
235                            offset: ll.preload_hint_byte_range_start,
236                        }),
237                        extra_attrs: Vec::new(),
238                    };
239                    self.ensure_init_requested(&map)?;
240                }
241                _ => {
242                    // RFC 8216bis §4.4.5.3 defines only PART/MAP today; a
243                    // future hint type from a newer transmux is simply not
244                    // prefetched rather than treated as an error
245                    // (`PreloadHintType` is `#[non_exhaustive]`).
246                }
247            }
248        }
249
250        if playlist.endlist {
251            self.saw_endlist = true;
252        } else {
253            // Issue #717 slice 1 fix: block only when the origin actually
254            // advertises `CAN-BLOCK-RELOAD=YES` — `low_latency.is_some()`
255            // alone is not enough (an origin sending `CAN-BLOCK-RELOAD=NO`
256            // still carries parts/PART-INF, e.g. while ramping up support).
257            let blocking = playlist
258                .low_latency
259                .as_ref()
260                .filter(|ll| ll.can_block_reload)
261                .map(|_| {
262                    let part = playlist
263                        .open_segment
264                        .as_ref()
265                        .map(|o| o.parts.len() as u64)
266                        .unwrap_or(0);
267                    BlockingReload {
268                        msn: next_msn,
269                        part: Some(part),
270                    }
271                });
272            let can_skip = playlist
273                .low_latency
274                .as_ref()
275                .and_then(|ll| ll.can_skip_until)
276                .is_some();
277            let skip = can_skip && self.last_full_playlist.is_some();
278            self.pending_actions.push_back(Action::FetchPlaylist {
279                url: self.playlist_url.clone(),
280                blocking,
281                skip,
282            });
283            if blocking.is_none() {
284                // RFC 8216 §4.3.3.1: a client SHOULD NOT reload more
285                // frequently than once per Target Duration; half that as a
286                // reasonable non-blocking poll cadence.
287                let wait_ms = (u64::from(playlist.target_duration.max(1)) * 1000) / 2;
288                self.pending_actions.push_back(Action::WaitMs(wait_ms));
289            }
290        }
291
292        if playlist.skip.is_none() {
293            self.last_full_playlist = Some(playlist);
294        }
295
296        self.maybe_emit_end_of_stream();
297        Ok(())
298    }
299
300    /// Feed the bytes fetched for a previously requested [`ResourceId`]
301    /// (`init`/part/segment). Part/Segment resources delivered before the
302    /// init segment are buffered internally and demuxed once the init
303    /// arrives — the caller's fetches may complete in any order.
304    ///
305    /// # Errors
306    /// [`Error::UnrequestedResource`] if `id` was never requested (the
307    /// `requested` bookkeeping — or, for `Init`, `init_uri` — has no record
308    /// of it): a caller/driver bug, or a stale/duplicate delivery after the
309    /// client already moved past this id.
310    /// [`Error::Demux`] if `transmux::Fmp4Demux` rejects the concatenation of
311    /// the cached init + `bytes`.
312    pub fn on_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
313        let was_requested = match id {
314            ResourceId::Init => self.init_uri.is_some(),
315            ResourceId::Part { .. } | ResourceId::Segment { .. } => self.requested.contains(&id),
316        };
317        if !was_requested {
318            return Err(Error::UnrequestedResource { id });
319        }
320        self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
321        match id {
322            ResourceId::Init => {
323                self.init_bytes = Some(bytes.to_vec());
324                if !self.init_emitted {
325                    self.pending_outputs.push_back(Output::Init(bytes.to_vec()));
326                    self.init_emitted = true;
327                }
328                let buffered: Vec<_> = self.pending_demux.drain(..).collect();
329                for (bid, bbytes) in buffered {
330                    self.finish_media_resource(bid, &bbytes)?;
331                }
332            }
333            ResourceId::Part { .. } | ResourceId::Segment { .. } => {
334                if self.is_ts_segment(bytes) {
335                    // Classic MPEG-TS-segment HLS (issue #760): no init
336                    // resource will ever arrive for this playlist, so demux
337                    // this self-contained TS segment straight away rather
338                    // than buffering it forever waiting for one.
339                    self.finish_ts_resource(id, bytes)?;
340                } else if self.init_bytes.is_none() {
341                    self.pending_demux.push_back((id, bytes.to_vec()));
342                } else {
343                    self.finish_media_resource(id, bytes)?;
344                }
345            }
346        }
347        self.maybe_emit_end_of_stream();
348        Ok(())
349    }
350
351    /// Demux + emit + mark-delivered for a Part/Segment resource, once the
352    /// init segment is known to be available.
353    fn finish_media_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
354        match id {
355            ResourceId::Part { msn, part } => {
356                self.emit_discontinuity_if_needed(msn);
357                self.demux_and_emit(id, bytes)?;
358                self.delivered_parts.insert((msn, part));
359            }
360            ResourceId::Segment { msn } => {
361                self.emit_discontinuity_if_needed(msn);
362                self.demux_and_emit(id, bytes)?;
363                self.delivered_segments.insert(msn);
364            }
365            ResourceId::Init => {}
366        }
367        Ok(())
368    }
369
370    /// The classic-TS-HLS counterpart to [`Self::finish_media_resource`]:
371    /// demux + emit + mark-delivered for a self-contained MPEG-TS Part/
372    /// Segment resource — never buffered pending an init fetch, since
373    /// [`Self::is_ts_segment`] only routes here once this playlist is known
374    /// to advertise no `EXT-X-MAP` at all.
375    fn finish_ts_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
376        match id {
377            ResourceId::Part { msn, part } => {
378                self.emit_discontinuity_if_needed(msn);
379                self.demux_and_emit_ts(id, bytes)?;
380                self.delivered_parts.insert((msn, part));
381            }
382            ResourceId::Segment { msn } => {
383                self.emit_discontinuity_if_needed(msn);
384                self.demux_and_emit_ts(id, bytes)?;
385                self.delivered_segments.insert(msn);
386            }
387            ResourceId::Init => {}
388        }
389        Ok(())
390    }
391
392    /// `true` when `bytes` should be routed to [`Self::finish_ts_resource`]
393    /// (classic MPEG-TS-segment HLS, issue #760) rather than the fMP4/CMAF
394    /// path: this playlist has never advertised an `EXT-X-MAP` (no init
395    /// fetch is outstanding or cached — [`Self::init_uri`] is `None`; by the
396    /// time any Part/Segment fetch response reaches [`Self::on_resource`],
397    /// [`Self::on_playlist`] has already fully processed the playlist that
398    /// requested it, including any map it carries, so this check is never
399    /// stale) **and** `bytes` starts with the MPEG-TS sync byte — an
400    /// fMP4/CMAF resource always starts with an ISOBMFF box
401    /// (`ftyp`/`styp`/`moof`), never [`TS_SYNC_BYTE`].
402    fn is_ts_segment(&self, bytes: &[u8]) -> bool {
403        self.init_uri.is_none() && bytes.first() == Some(&TS_SYNC_BYTE)
404    }
405
406    /// Report that a previously requested [`ResourceId`] (or the playlist
407    /// itself, via [`None`]) failed. Clears the id's "requested" bookkeeping
408    /// so the next [`Self::on_playlist`] call naturally re-requests it (no
409    /// automatic retry timer — the caller drives retry cadence).
410    pub fn on_error(&mut self, id: Option<ResourceId>) {
411        if let Some(id) = id {
412            self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
413            match id {
414                ResourceId::Init => self.init_uri = None,
415                other => {
416                    self.requested.remove(&other);
417                }
418            }
419        }
420        self.maybe_emit_end_of_stream();
421    }
422
423    // -- internals ------------------------------------------------------
424
425    /// Reconstruct a full playlist view from an `EXT-X-SKIP` delta update
426    /// (RFC 8216bis §4.4.5.2), by splicing the skipped prefix back in from
427    /// the last full playlist this client observed. Best-effort: if there is
428    /// no cached baseline, or it doesn't cover the skipped range, the delta
429    /// is returned as-is (never an error — "at least don't break").
430    fn merge_delta(&self, playlist: MediaPlaylist) -> MediaPlaylist {
431        let Some(skip) = &playlist.skip else {
432            return playlist;
433        };
434        if skip.skipped_segments == 0 {
435            return playlist;
436        }
437        let Some(prev) = &self.last_full_playlist else {
438            return playlist;
439        };
440        if playlist.media_sequence < prev.media_sequence {
441            return playlist;
442        }
443        let prefix_start = (playlist.media_sequence - prev.media_sequence) as usize;
444        // `skip.skipped_segments` (`EXT-X-SKIP`'s `SKIPPED-SEGMENTS`,
445        // RFC 8216bis §4.4.5.2) is untrusted `u64` straight from the remote
446        // origin's playlist text, with no upper bound enforced by
447        // `broadcast_hls::MediaPlaylist::parse`. `usize::try_from` +
448        // `checked_add` guard both the u64->usize narrowing and the
449        // addition itself, so an adversarial/corrupt value falls through to
450        // the same "can't merge, return the delta as-is" fallback as every
451        // other guard in this function rather than panicking (debug) or
452        // wrapping to a bogus, silently-wrong slice bound (release).
453        let prefix_end = usize::try_from(skip.skipped_segments)
454            .ok()
455            .and_then(|skipped| prefix_start.checked_add(skipped));
456        let Some(prefix) = prefix_end.and_then(|end| prev.segments.get(prefix_start..end)) else {
457            return playlist;
458        };
459        let mut merged = playlist;
460        let mut segments = prefix.to_vec();
461        segments.extend(merged.segments);
462        merged.segments = segments;
463        merged
464    }
465
466    fn process_closed_segment(&mut self, msn: u64, seg: &MediaSegment) -> Result<()> {
467        if seg.discontinuous {
468            self.discontinuous_msns.insert(msn);
469        }
470        if self.delivered_segments.contains(&msn) {
471            return Ok(());
472        }
473        if seg.parts.is_empty() {
474            // Either a genuinely non-LL segment (never had parts), OR an LL
475            // segment whose parts were already fetched individually while it
476            // was still open and whose *closed* rendering simply omits them
477            // — RFC 8216bis does not require a closed segment to keep
478            // listing `#EXT-X-PART` lines, and real origins commonly don't
479            // (e.g. `multimux`'s: `MediaSegment.parts` is always empty for a
480            // closed segment; only the still-open segment carries parts).
481            // Detect the latter via `delivered_parts`: if any part for this
482            // `msn` was ever delivered, every one of its non-`GAP` parts was
483            // already requested while it was open (`process_open_segment`
484            // requests every known part each time it's polled, so by the
485            // time the segment closes none can have been missed) — fetching
486            // the whole segment *as well* would demux and emit its samples a
487            // second time. Caught by `hls-runtime/tests/glass_to_glass.rs`
488            // (issue #717 slice 5): every sample was double-delivered for
489            // the first two segments of a real, live-paced run.
490            let already_have_parts = self
491                .delivered_parts
492                .range((msn, 0)..(msn + 1, 0))
493                .next()
494                .is_some();
495            if already_have_parts {
496                self.delivered_segments.insert(msn);
497                return Ok(());
498            }
499            let id = ResourceId::Segment { msn };
500            if !self.requested.contains(&id) {
501                let url = url::resolve(&self.playlist_url, &seg.uri);
502                let byte_range = self.resolve_byte_range(&url, &seg.byte_range)?;
503                self.request_resource(id, url, byte_range);
504            }
505            return Ok(());
506        }
507
508        let mut fully_accounted = true;
509        for (i, part) in seg.parts.iter().enumerate() {
510            let i = i as u64;
511            if part.gap || self.delivered_parts.contains(&(msn, i)) {
512                continue;
513            }
514            fully_accounted = false;
515            let id = ResourceId::Part { msn, part: i };
516            if !self.requested.contains(&id) {
517                let url = url::resolve(&self.playlist_url, &part.uri);
518                let byte_range = self.resolve_byte_range(&url, &part.byte_range)?;
519                self.request_resource(id, url, byte_range);
520            }
521        }
522        if fully_accounted {
523            self.delivered_segments.insert(msn);
524        }
525        Ok(())
526    }
527
528    fn process_open_segment(&mut self, msn: u64, open: &OpenSegment) -> Result<()> {
529        for (i, part) in open.parts.iter().enumerate() {
530            let i = i as u64;
531            if part.gap || self.delivered_parts.contains(&(msn, i)) {
532                continue;
533            }
534            let id = ResourceId::Part { msn, part: i };
535            if !self.requested.contains(&id) {
536                let url = url::resolve(&self.playlist_url, &part.uri);
537                let byte_range = self.resolve_byte_range(&url, &part.byte_range)?;
538                self.request_resource(id, url, byte_range);
539            }
540        }
541        Ok(())
542    }
543
544    fn ensure_init_requested(&mut self, map: &MapTag) -> Result<()> {
545        let url = url::resolve(&self.playlist_url, &map.uri);
546        if self.init_uri.as_deref() == Some(url.as_str()) {
547            return Ok(());
548        }
549        self.init_uri = Some(url.clone());
550        self.init_bytes = None;
551        self.init_emitted = false;
552        let byte_range = self.resolve_byte_range(&url, &map.byte_range)?;
553        self.pending_actions.push_back(Action::FetchResource {
554            id: ResourceId::Init,
555            url,
556            byte_range,
557        });
558        self.outstanding_fetches += 1;
559        Ok(())
560    }
561
562    fn request_resource(&mut self, id: ResourceId, url: String, byte_range: Option<(u64, u64)>) {
563        self.requested.insert(id);
564        self.outstanding_fetches += 1;
565        self.pending_actions.push_back(Action::FetchResource {
566            id,
567            url,
568            byte_range,
569        });
570    }
571
572    /// Resolve a `PartSpec`/`MediaSegment`/`MapTag` `BYTERANGE` into an
573    /// absolute `(offset, length)`, honouring the "omitted offset continues
574    /// the previous sub-range of the same resource" rule (tracked per
575    /// resolved URL).
576    ///
577    /// # Errors
578    /// [`Error::ByteRangeOverflow`] if `offset + length` (both taken
579    /// straight from the untrusted remote playlist) overflows `u64` — see
580    /// that variant's doc for why this is rejected rather than saturated.
581    fn resolve_byte_range(
582        &mut self,
583        url: &str,
584        br: &Option<ByteRange>,
585    ) -> Result<Option<(u64, u64)>> {
586        let Some(br) = br.as_ref() else {
587            return Ok(None);
588        };
589        let offset = br
590            .offset
591            .unwrap_or_else(|| *self.byte_range_cursor.get(url).unwrap_or(&0));
592        let next_cursor =
593            offset
594                .checked_add(br.length)
595                .ok_or_else(|| Error::ByteRangeOverflow {
596                    url: url.to_string(),
597                    offset,
598                    length: br.length,
599                })?;
600        self.byte_range_cursor.insert(url.to_string(), next_cursor);
601        Ok(Some((offset, br.length)))
602    }
603
604    /// Same overflow contract as [`Self::resolve_byte_range`] — see
605    /// [`Error::ByteRangeOverflow`].
606    fn resolve_hint_byte_range(
607        &mut self,
608        url: &str,
609        ll: &broadcast_hls::LowLatencyConfig,
610    ) -> Result<Option<(u64, u64)>> {
611        let Some(length) = ll.preload_hint_byte_range_length else {
612            return Ok(None);
613        };
614        let br = ByteRange {
615            length,
616            offset: ll.preload_hint_byte_range_start,
617        };
618        self.resolve_byte_range(url, &Some(br))
619    }
620
621    fn demux_and_emit(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
622        let init = self
623            .init_bytes
624            .as_ref()
625            .ok_or(Error::InitNotYetAvailable { id })?;
626        let mut combined = Vec::with_capacity(init.len() + bytes.len());
627        combined.extend_from_slice(init);
628        combined.extend_from_slice(bytes);
629        let mut demux = Fmp4Demux::new();
630        let media = demux
631            .unpackage(combined.as_slice())
632            .map_err(|source| Error::Demux { id, source })?;
633        for track in media.tracks {
634            if !track.samples.is_empty() {
635                self.pending_outputs.push_back(Output::Samples {
636                    track_id: track.spec.track_id,
637                    samples: track.samples,
638                });
639            }
640        }
641        Ok(())
642    }
643
644    /// The classic-TS-HLS counterpart to [`Self::demux_and_emit`]: demux a
645    /// self-contained MPEG-TS Part/Segment resource via [`TsDemux`] directly
646    /// (no init bytes to concatenate — each `.ts` segment carries its own
647    /// PAT/PMT/PES). On the very first such resource this client demuxes,
648    /// also synthesizes the one [`Output::Init`] the crate's output contract
649    /// requires ("exactly one `Init` precedes any `Samples`") from the
650    /// recovered [`TrackSpec`]s via [`transmux::build_init_segment`] — a real
651    /// `ftyp`+fragmented-`moov`, byte-for-byte demuxable by
652    /// `transmux::Fmp4Demux` like any other init segment, so callers built
653    /// against the fMP4 path (e.g. `multimux`'s `HlsPull`, which recovers
654    /// track specs from `Output::Init`) need no TS-specific handling.
655    fn demux_and_emit_ts(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
656        let mut demux = TsDemux::new();
657        let media = demux
658            .demux(bytes)
659            .map_err(|source| Error::Demux { id, source })?;
660        if !self.init_emitted {
661            let specs: Vec<TrackSpec> = media.tracks.iter().map(|t| t.spec.clone()).collect();
662            let init_bytes = transmux::build_init_segment(&specs, media.movie_timescale)
663                .map_err(|source| Error::Demux { id, source })?;
664            self.pending_outputs.push_back(Output::Init(init_bytes));
665            self.init_emitted = true;
666        }
667        for track in media.tracks {
668            if !track.samples.is_empty() {
669                self.pending_outputs.push_back(Output::Samples {
670                    track_id: track.spec.track_id,
671                    samples: track.samples,
672                });
673            }
674        }
675        Ok(())
676    }
677
678    fn emit_discontinuity_if_needed(&mut self, msn: u64) {
679        if self.discontinuous_msns.contains(&msn) && !self.discontinuity_emitted.contains(&msn) {
680            self.pending_outputs.push_back(Output::Discontinuity);
681            self.discontinuity_emitted.insert(msn);
682        }
683    }
684
685    fn maybe_emit_end_of_stream(&mut self) {
686        if self.saw_endlist && !self.end_emitted && self.outstanding_fetches == 0 {
687            self.pending_outputs.push_back(Output::EndOfStream);
688            self.end_emitted = true;
689        }
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    // Regression: `on_resource` documents (see `Error::UnrequestedResource`)
698    // that it rejects a `ResourceId` the client never requested, but
699    // previously never actually checked — any bytes for any id (a
700    // caller/driver bug, or a stale/duplicate delivery) were silently
701    // accepted. Must FAIL if that check is ever removed.
702    #[test]
703    fn on_resource_rejects_a_never_requested_id() {
704        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
705        let id = ResourceId::Segment { msn: 0 };
706
707        let err = client
708            .on_resource(id, b"some bytes")
709            .expect_err("an id the client never requested must be rejected");
710        assert!(
711            matches!(err, Error::UnrequestedResource { id: got } if got == id),
712            "wrong error variant: {err:?}"
713        );
714
715        // Init is checked too (tracked via `init_uri` rather than
716        // `requested`, since it's never inserted into that set).
717        let err = client
718            .on_resource(ResourceId::Init, b"init bytes")
719            .expect_err("an unrequested Init must be rejected");
720        assert!(
721            matches!(
722                err,
723                Error::UnrequestedResource {
724                    id: ResourceId::Init
725                }
726            ),
727            "wrong error variant: {err:?}"
728        );
729    }
730
731    // The flip side of the regression above: a `ResourceId` the client
732    // actually asked for (via its own internal `request_resource`
733    // bookkeeping, mirroring what a real `poll()`-driven fetch populates)
734    // must still be accepted, not spuriously rejected.
735    #[test]
736    fn on_resource_accepts_a_previously_requested_id() {
737        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
738        let id = ResourceId::Segment { msn: 0 };
739        client.request_resource(id, "http://example.com/seg0.m4s".to_string(), None);
740
741        // No init segment cached yet, so this is buffered rather than
742        // demuxed — the point here is only that it is *not* rejected as
743        // unrequested.
744        let result = client.on_resource(id, b"some bytes");
745        assert!(
746            result.is_ok(),
747            "a requested id must be accepted: {result:?}"
748        );
749        assert!(
750            client.pending_demux.iter().any(|(bid, _)| *bid == id),
751            "expected the resource to be buffered pending the init segment"
752        );
753    }
754
755    // Issue #760: classic MPEG-TS-segment HLS routing. `is_ts_segment` must
756    // say yes to a genuine TS resource (sync byte, no map ever seen)...
757    #[test]
758    fn is_ts_segment_true_when_no_map_seen_and_sync_byte_present() {
759        let client = HlsClient::new("http://example.com/playlist.m3u8");
760        assert!(client.is_ts_segment(&[TS_SYNC_BYTE, 0x40, 0x11, 0x00]));
761    }
762
763    // ...but say no to an ISOBMFF (fMP4/CMAF) resource even when no map has
764    // been seen yet — the content itself is never TS, so it must fall
765    // through to the ordinary init-buffering path rather than being
766    // misrouted into `TsDemux` (which would reject it as malformed TS).
767    #[test]
768    fn is_ts_segment_false_for_an_isobmff_resource_with_no_map_seen() {
769        let client = HlsClient::new("http://example.com/playlist.m3u8");
770        let ftyp_box = b"\x00\x00\x00\x18ftypiso5\x00\x00\x02\x00iso5iso6mp41";
771        assert!(!client.is_ts_segment(ftyp_box));
772    }
773
774    // The playlist signal takes precedence over content-sniffing: once this
775    // playlist is known to advertise an `EXT-X-MAP` (an init fetch has been
776    // requested/cached), even a resource whose first byte happens to be
777    // `0x47` must NOT be misrouted through `TsDemux` -- it is that
778    // playlist's own fMP4/CMAF init + part/segment concatenation the
779    // fetched bytes belong with.
780    #[test]
781    fn is_ts_segment_false_once_a_map_has_been_requested() {
782        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
783        client
784            .ensure_init_requested(&MapTag {
785                uri: "init.mp4".to_string(),
786                byte_range: None,
787                extra_attrs: Vec::new(),
788            })
789            .expect("ensure_init_requested succeeds");
790        assert!(!client.is_ts_segment(&[TS_SYNC_BYTE, 0x40, 0x11, 0x00]));
791    }
792
793    // Biting test for the u64-overflow defect: a remote origin's
794    // `#EXT-X-BYTERANGE` (or preload-hint byte range) is untrusted text —
795    // `broadcast_hls::MediaPlaylist::parse` places no upper bound on either
796    // the offset or the length (confirmed: bare `str::parse::<u64>()`). A
797    // playlist advertising `BYTERANGE:18446744073709551615@1` must be
798    // rejected with `Error::ByteRangeOverflow`, not panic (debug) or
799    // silently wrap `offset + length` into a bogus cursor (release).
800    //
801    // MUTATION VERIFIED: reverting `resolve_byte_range`'s
802    // `offset.checked_add(br.length).ok_or_else(...)` back to the original
803    // `offset + br.length` makes this test fail — the debug build panics
804    // with "attempt to add with overflow" before `expect_err` ever runs
805    // (confirmed by running it), rather than the release build's silent
806    // wraparound the issue actually reports. Recompiled and re-ran to
807    // observe that exact panic, then restored the checked_add.
808    #[test]
809    fn resolve_byte_range_rejects_an_offset_plus_length_that_overflows_u64() {
810        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
811        let br = Some(ByteRange {
812            length: u64::MAX,
813            offset: Some(1),
814        });
815        let err = client
816            .resolve_byte_range("http://example.com/seg0.m4s", &br)
817            .expect_err("offset 1 + length u64::MAX must overflow and be rejected");
818        assert!(
819            matches!(
820                err,
821                Error::ByteRangeOverflow {
822                    offset: 1,
823                    length: u64::MAX,
824                    ..
825                }
826            ),
827            "wrong error variant/fields: {err:?}"
828        );
829    }
830
831    // Same defect, the other trigger named in the issue: repeated
832    // omitted-offset ranges on the *same* resource URL accumulate via
833    // `byte_range_cursor` (RFC 8216bis §4.4.4.9's "omitted offset continues
834    // the previous sub-range" rule) — a long-lived pull can walk that
835    // cursor arbitrarily close to `u64::MAX` before a single request's
836    // `offset + length` itself overflows.
837    #[test]
838    fn resolve_byte_range_rejects_a_cursor_accumulation_that_overflows_u64() {
839        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
840        let url = "http://example.com/seg0.m4s";
841        // Seed the cursor near the top of the u64 range with an explicit
842        // offset, then the next omitted-offset range pushes it over.
843        let seed = Some(ByteRange {
844            length: u64::MAX - 10,
845            offset: Some(5),
846        });
847        let (offset, length) = client
848            .resolve_byte_range(url, &seed)
849            .expect("seed range does not itself overflow")
850            .expect("Some for a Some(ByteRange)");
851        assert_eq!((offset, length), (5, u64::MAX - 10));
852
853        let next = Some(ByteRange {
854            length: 100,
855            offset: None, // continues the cursor left at 5 + (u64::MAX - 10)
856        });
857        let err = client
858            .resolve_byte_range(url, &next)
859            .expect_err("cursor + next length must overflow and be rejected");
860        assert!(
861            matches!(err, Error::ByteRangeOverflow { length: 100, .. }),
862            "wrong error variant/fields: {err:?}"
863        );
864    }
865
866    // Biting test for the `merge_delta` defect: an `#EXT-X-SKIP` delta's
867    // `SKIPPED-SEGMENTS` is untrusted `u64` text with no upper bound
868    // enforced by `broadcast_hls::MediaPlaylist::parse`. A malicious/corrupt
869    // origin claiming a `SKIPPED-SEGMENTS` value that overflows
870    // `prefix_start + skipped_segments` (here: `prefix_start == 10` from a
871    // 10-segment `media_sequence` advance, plus `skipped_segments ==
872    // u64::MAX - 5`) must fall through to the existing "can't merge, return
873    // the delta as-is" fallback, not panic. `prefix_start` is deliberately
874    // nonzero: `0 + u64::MAX` does not overflow, so a `prefix_start == 0`
875    // case would pass even with the guard removed, for the wrong reason
876    // (the subsequent `.get()` bounds check catching it) rather than the
877    // one this test targets (the addition itself).
878    //
879    // MUTATION VERIFIED: reverting the guard back to the original
880    // `prefix_start + skip.skipped_segments as usize` makes this test fail:
881    // the debug build panics with "attempt to add with overflow" inside
882    // `merge_delta` before the `let merged = ...` assertions ever run
883    // (confirmed by running it), rather than returning the delta unmerged.
884    // Recompiled and re-ran to observe that exact panic, then restored the
885    // checked/try_from guard.
886    #[test]
887    fn merge_delta_does_not_panic_on_an_overflowing_skipped_segments() {
888        let mut client = HlsClient::new("http://example.com/playlist.m3u8");
889        let prev = MediaPlaylist {
890            media_sequence: 0,
891            segments: vec![MediaSegment::default(); 2],
892            ..Default::default()
893        };
894        // `last_full_playlist` is set directly (private field, same module)
895        // rather than driven through a full `on_playlist` round-trip — the
896        // point under test is purely `merge_delta`'s own arithmetic guard.
897        client.last_full_playlist = Some(prev);
898
899        let delta = MediaPlaylist {
900            media_sequence: 10, // prefix_start == 10 - 0 == 10
901            skip: Some(broadcast_hls::SkipInfo {
902                skipped_segments: u64::MAX - 5,
903                ..Default::default()
904            }),
905            segments: Vec::new(),
906            ..Default::default()
907        };
908
909        let merged = client.merge_delta(delta.clone());
910        assert_eq!(
911            merged, delta,
912            "an unmergeable skip count must fall back to the delta as-is, not panic"
913        );
914    }
915}