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}