Skip to main content

ll_hls_runtime/client/
engine.rs

1//! [`LlHlsClient`] — 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 transmux::hls::{ByteRange, MapTag, PreloadHintType};
9use transmux::{Fmp4Demux, MediaPlaylist, MediaSegment, OpenSegment};
10
11use super::action::{Action, BlockingReload, ResourceId};
12use super::error::{Error, Result};
13use super::output::Output;
14use super::url;
15
16/// A driveable, sans-IO Low-Latency HLS (RFC 8216bis) playback client.
17///
18/// `LlHlsClient` never touches a socket or a clock. The caller drives it:
19///
20/// 1. [`LlHlsClient::new`] seeds the first [`Action::FetchPlaylist`]; drain it
21///    with [`LlHlsClient::poll`] and perform the GET.
22/// 2. Feed the response back with [`LlHlsClient::on_playlist`] (playlist) or
23///    [`LlHlsClient::on_resource`] (init/part/segment bytes) —
24///    [`Action::FetchResource`]'s `id` correlates the two.
25/// 3. Drain [`LlHlsClient::poll`] again for the next round of actions (a new
26///    reload, newly discoverable parts, a preload-hint prefetch, ...) and
27///    [`LlHlsClient::next_output`] for newly available [`Output`]s.
28///
29/// # Behaviour
30///
31/// - **Reload scheduling** (issue #717 slice 2): once a playlist advertises
32///   `EXT-X-SERVER-CONTROL`/`EXT-X-PART-INF` **and** the origin's
33///   `CAN-BLOCK-RELOAD` attribute is `YES`
34///   ([`transmux::hls::LowLatencyConfig::can_block_reload`] is `true` —
35///   *not* merely [`transmux::hls::MediaPlaylist::low_latency`] being
36///   `Some`, since an origin may carry parts/PART-INF while still
37///   advertising `CAN-BLOCK-RELOAD=NO`), every reload is a Blocking
38///   Playlist Reload (RFC 8216bis §6.2.5.2) naming the next not-yet-seen
39///   Partial Segment's `_HLS_msn`/`_HLS_part`. Otherwise reloads are plain GETs
40///   paced by an [`Action::WaitMs`] hint derived from `#EXT-X-TARGETDURATION`.
41///   `EXT-X-SKIP`/`CAN-SKIP-UNTIL` Playlist Delta Updates (RFC 8216bis §4.4.5.2)
42///   are requested once a full-playlist baseline exists, and merged back into
43///   a full view before further processing — see `merge_delta` internally.
44/// - **Fetch pipeline** (slice 3): the `EXT-X-PRELOAD-HINT`ed part is fetched
45///   ahead of its own appearance as a numbered `EXT-X-PART`; `BYTERANGE`
46///   parts are supported, including the RFC 8216bis §4.4.4.9 "omitted offset
47///   means immediately after the previous sub-range of the same resource"
48///   rule (tracked per resource URL). The Media Initialization Section
49///   (`EXT-X-MAP`) is fetched once and reused for every following resource
50///   until the map changes.
51/// - **Dedup / coalescing**: once *any* of a segment's parts have been
52///   individually fetched, that segment is never re-fetched whole — when it
53///   later closes (`#EXTINF`+URI), the client only fetches whichever of its
54///   parts (if any) are still missing, and marks the segment "delivered" once
55///   every part is accounted for (fetched, or `GAP=YES`). A playlist whose
56///   segments carry **no** parts at all (a non-LL origin) falls back to
57///   fetching the whole segment resource — the two paths never overlap for a
58///   single segment, so a part's samples are never double-counted against its
59///   parent's.
60/// - **Output adapter** (slice 4): exactly one [`Output::Init`] precedes any
61///   [`Output::Samples`]; parts/segments are demuxed via
62///   [`transmux::Fmp4Demux`] (by concatenating the cached init bytes with the
63///   fetched resource — this crate never re-implements ISOBMFF box parsing,
64///   only reuses transmux's), so `Output::Samples` carries real access units,
65///   not opaque container bytes. `#EXT-X-DISCONTINUITY` on a segment surfaces
66///   as [`Output::Discontinuity`] immediately before that segment's first
67///   samples. **Known limitation**: an in-progress ([`OpenSegment`]) segment
68///   carries no discontinuity flag of its own (only a *closed*
69///   [`MediaSegment`] does) — if every part of a segment was already
70///   delivered while it was still open, a discontinuity revealed only once it
71///   closes is signalled late (after those parts' samples, not before). This
72///   is a gap in the current wire model ([`transmux::hls::OpenSegment`]), not
73///   something this crate can fix locally.
74#[derive(Debug)]
75pub struct LlHlsClient {
76    playlist_url: String,
77
78    pending_actions: VecDeque<Action>,
79    pending_outputs: VecDeque<Output>,
80
81    init_uri: Option<String>,
82    init_bytes: Option<Vec<u8>>,
83    init_emitted: bool,
84    /// Part/Segment resources delivered before the init segment arrived —
85    /// buffered (in arrival order) and replayed once [`Self::init_bytes`] is
86    /// set, so the caller's fetch/response IO can complete in any order
87    /// (a real HTTP client has no reason to serialize on init-first).
88    pending_demux: VecDeque<(ResourceId, Vec<u8>)>,
89
90    requested: BTreeSet<ResourceId>,
91    delivered_parts: BTreeSet<(u64, u64)>,
92    delivered_segments: BTreeSet<u64>,
93    discontinuous_msns: BTreeSet<u64>,
94    discontinuity_emitted: BTreeSet<u64>,
95    byte_range_cursor: BTreeMap<String, u64>,
96
97    outstanding_fetches: u64,
98    saw_endlist: bool,
99    end_emitted: bool,
100    last_full_playlist: Option<MediaPlaylist>,
101}
102
103impl LlHlsClient {
104    /// Create a new client for the Media Playlist at `playlist_url`, seeding
105    /// the first [`Action::FetchPlaylist`] (a plain, non-blocking GET — the
106    /// client does not yet know whether the origin supports blocking reload).
107    pub fn new(playlist_url: impl Into<String>) -> Self {
108        let playlist_url = playlist_url.into();
109        let mut pending_actions = VecDeque::new();
110        pending_actions.push_back(Action::FetchPlaylist {
111            url: playlist_url.clone(),
112            blocking: None,
113            skip: false,
114        });
115        Self {
116            playlist_url,
117            pending_actions,
118            pending_outputs: VecDeque::new(),
119            init_uri: None,
120            init_bytes: None,
121            init_emitted: false,
122            pending_demux: VecDeque::new(),
123            requested: BTreeSet::new(),
124            delivered_parts: BTreeSet::new(),
125            delivered_segments: BTreeSet::new(),
126            discontinuous_msns: BTreeSet::new(),
127            discontinuity_emitted: BTreeSet::new(),
128            byte_range_cursor: BTreeMap::new(),
129            outstanding_fetches: 0,
130            saw_endlist: false,
131            end_emitted: false,
132            last_full_playlist: None,
133        }
134    }
135
136    /// The Media Playlist URL this client is following.
137    pub fn playlist_url(&self) -> &str {
138        &self.playlist_url
139    }
140
141    /// Drain the next IO [`Action`] the caller must perform, if any.
142    pub fn poll(&mut self) -> Option<Action> {
143        self.pending_actions.pop_front()
144    }
145
146    /// Drain the next [`Output`] event, if any.
147    pub fn next_output(&mut self) -> Option<Output> {
148        self.pending_outputs.pop_front()
149    }
150
151    /// Feed a freshly fetched Media Playlist response.
152    ///
153    /// # Errors
154    /// [`Error::PlaylistNotUtf8`] / [`Error::PlaylistParse`] on malformed
155    /// input.
156    pub fn on_playlist(&mut self, bytes: &[u8]) -> Result<()> {
157        let text = core::str::from_utf8(bytes)?;
158        let playlist = MediaPlaylist::parse(text)?;
159        let playlist = self.merge_delta(playlist);
160
161        for (i, seg) in playlist.segments.iter().enumerate() {
162            let msn = playlist.media_sequence + i as u64;
163            self.process_closed_segment(msn, seg)?;
164        }
165
166        let next_msn = playlist.media_sequence + playlist.segments.len() as u64;
167        if let Some(open) = &playlist.open_segment {
168            self.process_open_segment(next_msn, open)?;
169        }
170
171        // Prefer the *open* segment's map when present: it's the most
172        // recent (`#EXT-X-MAP` carries forward, so the open segment's view
173        // is never older than the last closed segment's) and, crucially, is
174        // the only way to learn the init segment's URI at all when NO
175        // segment has closed yet (issue #717 slice 5 fix — previously this
176        // only ever looked at the last *closed* segment's map, so a client
177        // tuning into a stream mid-segment couldn't fetch the init segment,
178        // and therefore couldn't demux any of that segment's parts, until
179        // it closed — needlessly inflating glass-to-glass latency by up to
180        // a full segment duration on every fresh connection).
181        let map = playlist
182            .open_segment
183            .as_ref()
184            .and_then(|o| o.map.as_ref())
185            .or_else(|| playlist.segments.last().and_then(|s| s.map.as_ref()));
186        if let Some(map) = map {
187            self.ensure_init_requested(map)?;
188        }
189
190        if let Some(ll) = &playlist.low_latency {
191            if let Some(hint_uri) = &ll.preload_hint_part {
192                match ll.preload_hint_type {
193                    PreloadHintType::Part => {
194                        let part_idx = playlist
195                            .open_segment
196                            .as_ref()
197                            .map(|o| o.parts.len() as u64)
198                            .unwrap_or(0);
199                        let id = ResourceId::Part {
200                            msn: next_msn,
201                            part: part_idx,
202                        };
203                        let url = url::resolve(&self.playlist_url, hint_uri);
204                        let byte_range = self.resolve_hint_byte_range(&url, ll);
205                        self.request_resource(id, url, byte_range);
206                    }
207                    PreloadHintType::Map => {
208                        let map = MapTag {
209                            uri: hint_uri.clone(),
210                            byte_range: ll.preload_hint_byte_range_length.map(|length| ByteRange {
211                                length,
212                                offset: ll.preload_hint_byte_range_start,
213                            }),
214                        };
215                        self.ensure_init_requested(&map)?;
216                    }
217                }
218            }
219        }
220
221        if playlist.endlist {
222            self.saw_endlist = true;
223        } else {
224            // Issue #717 slice 1 fix: block only when the origin actually
225            // advertises `CAN-BLOCK-RELOAD=YES` — `low_latency.is_some()`
226            // alone is not enough (an origin sending `CAN-BLOCK-RELOAD=NO`
227            // still carries parts/PART-INF, e.g. while ramping up support).
228            let blocking = playlist
229                .low_latency
230                .as_ref()
231                .filter(|ll| ll.can_block_reload)
232                .map(|_| {
233                    let part = playlist
234                        .open_segment
235                        .as_ref()
236                        .map(|o| o.parts.len() as u64)
237                        .unwrap_or(0);
238                    BlockingReload {
239                        msn: next_msn,
240                        part: Some(part),
241                    }
242                });
243            let can_skip = playlist
244                .low_latency
245                .as_ref()
246                .and_then(|ll| ll.can_skip_until)
247                .is_some();
248            let skip = can_skip && self.last_full_playlist.is_some();
249            self.pending_actions.push_back(Action::FetchPlaylist {
250                url: self.playlist_url.clone(),
251                blocking,
252                skip,
253            });
254            if blocking.is_none() {
255                // RFC 8216 §4.3.3.1: a client SHOULD NOT reload more
256                // frequently than once per Target Duration; half that as a
257                // reasonable non-blocking poll cadence.
258                let wait_ms = (u64::from(playlist.target_duration.max(1)) * 1000) / 2;
259                self.pending_actions.push_back(Action::WaitMs(wait_ms));
260            }
261        }
262
263        if playlist.skip.is_none() {
264            self.last_full_playlist = Some(playlist);
265        }
266
267        self.maybe_emit_end_of_stream();
268        Ok(())
269    }
270
271    /// Feed the bytes fetched for a previously requested [`ResourceId`]
272    /// (`init`/part/segment). Part/Segment resources delivered before the
273    /// init segment are buffered internally and demuxed once the init
274    /// arrives — the caller's fetches may complete in any order.
275    ///
276    /// # Errors
277    /// [`Error::UnrequestedResource`] if `id` was never requested (the
278    /// `requested` bookkeeping — or, for `Init`, `init_uri` — has no record
279    /// of it): a caller/driver bug, or a stale/duplicate delivery after the
280    /// client already moved past this id.
281    /// [`Error::Demux`] if `transmux::Fmp4Demux` rejects the concatenation of
282    /// the cached init + `bytes`.
283    pub fn on_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
284        let was_requested = match id {
285            ResourceId::Init => self.init_uri.is_some(),
286            ResourceId::Part { .. } | ResourceId::Segment { .. } => self.requested.contains(&id),
287        };
288        if !was_requested {
289            return Err(Error::UnrequestedResource { id });
290        }
291        self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
292        match id {
293            ResourceId::Init => {
294                self.init_bytes = Some(bytes.to_vec());
295                if !self.init_emitted {
296                    self.pending_outputs.push_back(Output::Init(bytes.to_vec()));
297                    self.init_emitted = true;
298                }
299                let buffered: Vec<_> = self.pending_demux.drain(..).collect();
300                for (bid, bbytes) in buffered {
301                    self.finish_media_resource(bid, &bbytes)?;
302                }
303            }
304            ResourceId::Part { .. } | ResourceId::Segment { .. } => {
305                if self.init_bytes.is_none() {
306                    self.pending_demux.push_back((id, bytes.to_vec()));
307                } else {
308                    self.finish_media_resource(id, bytes)?;
309                }
310            }
311        }
312        self.maybe_emit_end_of_stream();
313        Ok(())
314    }
315
316    /// Demux + emit + mark-delivered for a Part/Segment resource, once the
317    /// init segment is known to be available.
318    fn finish_media_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
319        match id {
320            ResourceId::Part { msn, part } => {
321                self.emit_discontinuity_if_needed(msn);
322                self.demux_and_emit(id, bytes)?;
323                self.delivered_parts.insert((msn, part));
324            }
325            ResourceId::Segment { msn } => {
326                self.emit_discontinuity_if_needed(msn);
327                self.demux_and_emit(id, bytes)?;
328                self.delivered_segments.insert(msn);
329            }
330            ResourceId::Init => {}
331        }
332        Ok(())
333    }
334
335    /// Report that a previously requested [`ResourceId`] (or the playlist
336    /// itself, via [`None`]) failed. Clears the id's "requested" bookkeeping
337    /// so the next [`Self::on_playlist`] call naturally re-requests it (no
338    /// automatic retry timer — the caller drives retry cadence).
339    pub fn on_error(&mut self, id: Option<ResourceId>) {
340        if let Some(id) = id {
341            self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
342            match id {
343                ResourceId::Init => self.init_uri = None,
344                other => {
345                    self.requested.remove(&other);
346                }
347            }
348        }
349        self.maybe_emit_end_of_stream();
350    }
351
352    // -- internals ------------------------------------------------------
353
354    /// Reconstruct a full playlist view from an `EXT-X-SKIP` delta update
355    /// (RFC 8216bis §4.4.5.2), by splicing the skipped prefix back in from
356    /// the last full playlist this client observed. Best-effort: if there is
357    /// no cached baseline, or it doesn't cover the skipped range, the delta
358    /// is returned as-is (never an error — "at least don't break").
359    fn merge_delta(&self, playlist: MediaPlaylist) -> MediaPlaylist {
360        let Some(skip) = &playlist.skip else {
361            return playlist;
362        };
363        if skip.skipped_segments == 0 {
364            return playlist;
365        }
366        let Some(prev) = &self.last_full_playlist else {
367            return playlist;
368        };
369        if playlist.media_sequence < prev.media_sequence {
370            return playlist;
371        }
372        let prefix_start = (playlist.media_sequence - prev.media_sequence) as usize;
373        let prefix_end = prefix_start + skip.skipped_segments as usize;
374        let Some(prefix) = prev.segments.get(prefix_start..prefix_end) else {
375            return playlist;
376        };
377        let mut merged = playlist;
378        let mut segments = prefix.to_vec();
379        segments.extend(merged.segments);
380        merged.segments = segments;
381        merged
382    }
383
384    fn process_closed_segment(&mut self, msn: u64, seg: &MediaSegment) -> Result<()> {
385        if seg.discontinuous {
386            self.discontinuous_msns.insert(msn);
387        }
388        if self.delivered_segments.contains(&msn) {
389            return Ok(());
390        }
391        if seg.parts.is_empty() {
392            // Either a genuinely non-LL segment (never had parts), OR an LL
393            // segment whose parts were already fetched individually while it
394            // was still open and whose *closed* rendering simply omits them
395            // — RFC 8216bis does not require a closed segment to keep
396            // listing `#EXT-X-PART` lines, and real origins commonly don't
397            // (e.g. `multimux`'s: `MediaSegment.parts` is always empty for a
398            // closed segment; only the still-open segment carries parts).
399            // Detect the latter via `delivered_parts`: if any part for this
400            // `msn` was ever delivered, every one of its non-`GAP` parts was
401            // already requested while it was open (`process_open_segment`
402            // requests every known part each time it's polled, so by the
403            // time the segment closes none can have been missed) — fetching
404            // the whole segment *as well* would demux and emit its samples a
405            // second time. Caught by `ll-hls-runtime/tests/glass_to_glass.rs`
406            // (issue #717 slice 5): every sample was double-delivered for
407            // the first two segments of a real, live-paced run.
408            let already_have_parts = self
409                .delivered_parts
410                .range((msn, 0)..(msn + 1, 0))
411                .next()
412                .is_some();
413            if already_have_parts {
414                self.delivered_segments.insert(msn);
415                return Ok(());
416            }
417            let id = ResourceId::Segment { msn };
418            if !self.requested.contains(&id) {
419                let url = url::resolve(&self.playlist_url, &seg.uri);
420                let byte_range = self.resolve_byte_range(&url, &seg.byte_range);
421                self.request_resource(id, url, byte_range);
422            }
423            return Ok(());
424        }
425
426        let mut fully_accounted = true;
427        for (i, part) in seg.parts.iter().enumerate() {
428            let i = i as u64;
429            if part.gap || self.delivered_parts.contains(&(msn, i)) {
430                continue;
431            }
432            fully_accounted = false;
433            let id = ResourceId::Part { msn, part: i };
434            if !self.requested.contains(&id) {
435                let url = url::resolve(&self.playlist_url, &part.uri);
436                let byte_range = self.resolve_byte_range(&url, &part.byte_range);
437                self.request_resource(id, url, byte_range);
438            }
439        }
440        if fully_accounted {
441            self.delivered_segments.insert(msn);
442        }
443        Ok(())
444    }
445
446    fn process_open_segment(&mut self, msn: u64, open: &OpenSegment) -> Result<()> {
447        for (i, part) in open.parts.iter().enumerate() {
448            let i = i as u64;
449            if part.gap || self.delivered_parts.contains(&(msn, i)) {
450                continue;
451            }
452            let id = ResourceId::Part { msn, part: i };
453            if !self.requested.contains(&id) {
454                let url = url::resolve(&self.playlist_url, &part.uri);
455                let byte_range = self.resolve_byte_range(&url, &part.byte_range);
456                self.request_resource(id, url, byte_range);
457            }
458        }
459        Ok(())
460    }
461
462    fn ensure_init_requested(&mut self, map: &MapTag) -> Result<()> {
463        let url = url::resolve(&self.playlist_url, &map.uri);
464        if self.init_uri.as_deref() == Some(url.as_str()) {
465            return Ok(());
466        }
467        self.init_uri = Some(url.clone());
468        self.init_bytes = None;
469        self.init_emitted = false;
470        let byte_range = self.resolve_byte_range(&url, &map.byte_range);
471        self.pending_actions.push_back(Action::FetchResource {
472            id: ResourceId::Init,
473            url,
474            byte_range,
475        });
476        self.outstanding_fetches += 1;
477        Ok(())
478    }
479
480    fn request_resource(&mut self, id: ResourceId, url: String, byte_range: Option<(u64, u64)>) {
481        self.requested.insert(id);
482        self.outstanding_fetches += 1;
483        self.pending_actions.push_back(Action::FetchResource {
484            id,
485            url,
486            byte_range,
487        });
488    }
489
490    /// Resolve a `PartSpec`/`MediaSegment`/`MapTag` `BYTERANGE` into an
491    /// absolute `(offset, length)`, honouring the "omitted offset continues
492    /// the previous sub-range of the same resource" rule (tracked per
493    /// resolved URL).
494    fn resolve_byte_range(&mut self, url: &str, br: &Option<ByteRange>) -> Option<(u64, u64)> {
495        let br = br.as_ref()?;
496        let offset = br
497            .offset
498            .unwrap_or_else(|| *self.byte_range_cursor.get(url).unwrap_or(&0));
499        self.byte_range_cursor
500            .insert(url.to_string(), offset + br.length);
501        Some((offset, br.length))
502    }
503
504    fn resolve_hint_byte_range(
505        &mut self,
506        url: &str,
507        ll: &transmux::hls::LowLatencyConfig,
508    ) -> Option<(u64, u64)> {
509        let length = ll.preload_hint_byte_range_length?;
510        let br = ByteRange {
511            length,
512            offset: ll.preload_hint_byte_range_start,
513        };
514        self.resolve_byte_range(url, &Some(br))
515    }
516
517    fn demux_and_emit(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
518        let init = self
519            .init_bytes
520            .as_ref()
521            .ok_or(Error::InitNotYetAvailable { id })?;
522        let mut combined = Vec::with_capacity(init.len() + bytes.len());
523        combined.extend_from_slice(init);
524        combined.extend_from_slice(bytes);
525        let mut demux = Fmp4Demux::new();
526        let media = demux
527            .unpackage(combined.as_slice())
528            .map_err(|source| Error::Demux { id, source })?;
529        for track in media.tracks {
530            if !track.samples.is_empty() {
531                self.pending_outputs.push_back(Output::Samples {
532                    track_id: track.spec.track_id,
533                    samples: track.samples,
534                });
535            }
536        }
537        Ok(())
538    }
539
540    fn emit_discontinuity_if_needed(&mut self, msn: u64) {
541        if self.discontinuous_msns.contains(&msn) && !self.discontinuity_emitted.contains(&msn) {
542            self.pending_outputs.push_back(Output::Discontinuity);
543            self.discontinuity_emitted.insert(msn);
544        }
545    }
546
547    fn maybe_emit_end_of_stream(&mut self) {
548        if self.saw_endlist && !self.end_emitted && self.outstanding_fetches == 0 {
549            self.pending_outputs.push_back(Output::EndOfStream);
550            self.end_emitted = true;
551        }
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    // Regression: `on_resource` documents (see `Error::UnrequestedResource`)
560    // that it rejects a `ResourceId` the client never requested, but
561    // previously never actually checked — any bytes for any id (a
562    // caller/driver bug, or a stale/duplicate delivery) were silently
563    // accepted. Must FAIL if that check is ever removed.
564    #[test]
565    fn on_resource_rejects_a_never_requested_id() {
566        let mut client = LlHlsClient::new("http://example.com/playlist.m3u8");
567        let id = ResourceId::Segment { msn: 0 };
568
569        let err = client
570            .on_resource(id, b"some bytes")
571            .expect_err("an id the client never requested must be rejected");
572        assert!(
573            matches!(err, Error::UnrequestedResource { id: got } if got == id),
574            "wrong error variant: {err:?}"
575        );
576
577        // Init is checked too (tracked via `init_uri` rather than
578        // `requested`, since it's never inserted into that set).
579        let err = client
580            .on_resource(ResourceId::Init, b"init bytes")
581            .expect_err("an unrequested Init must be rejected");
582        assert!(
583            matches!(
584                err,
585                Error::UnrequestedResource {
586                    id: ResourceId::Init
587                }
588            ),
589            "wrong error variant: {err:?}"
590        );
591    }
592
593    // The flip side of the regression above: a `ResourceId` the client
594    // actually asked for (via its own internal `request_resource`
595    // bookkeeping, mirroring what a real `poll()`-driven fetch populates)
596    // must still be accepted, not spuriously rejected.
597    #[test]
598    fn on_resource_accepts_a_previously_requested_id() {
599        let mut client = LlHlsClient::new("http://example.com/playlist.m3u8");
600        let id = ResourceId::Segment { msn: 0 };
601        client.request_resource(id, "http://example.com/seg0.m4s".to_string(), None);
602
603        // No init segment cached yet, so this is buffered rather than
604        // demuxed — the point here is only that it is *not* rejected as
605        // unrequested.
606        let result = client.on_resource(id, b"some bytes");
607        assert!(
608            result.is_ok(),
609            "a requested id must be accepted: {result:?}"
610        );
611        assert!(
612            client.pending_demux.iter().any(|(bid, _)| *bid == id),
613            "expected the resource to be buffered pending the init segment"
614        );
615    }
616}