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, 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/// `LlHlsClient` never touches a socket or a clock. The caller drives it:
28///
29/// 1. [`LlHlsClient::new`] seeds the first [`Action::FetchPlaylist`]; drain it
30/// with [`LlHlsClient::poll`] and perform the GET.
31/// 2. Feed the response back with [`LlHlsClient::on_playlist`] (playlist) or
32/// [`LlHlsClient::on_resource`] (init/part/segment bytes) —
33/// [`Action::FetchResource`]'s `id` correlates the two.
34/// 3. Drain [`LlHlsClient::poll`] again for the next round of actions (a new
35/// reload, newly discoverable parts, a preload-hint prefetch, ...) and
36/// [`LlHlsClient::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/// ([`transmux::hls::LowLatencyConfig::can_block_reload`] is `true` —
44/// *not* merely [`transmux::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 ([`transmux::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 LlHlsClient {
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 LlHlsClient {
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 if let Some(hint_uri) = &ll.preload_hint_part {
214 match ll.preload_hint_type {
215 PreloadHintType::Part => {
216 let part_idx = playlist
217 .open_segment
218 .as_ref()
219 .map(|o| o.parts.len() as u64)
220 .unwrap_or(0);
221 let id = ResourceId::Part {
222 msn: next_msn,
223 part: part_idx,
224 };
225 let url = url::resolve(&self.playlist_url, hint_uri);
226 let byte_range = self.resolve_hint_byte_range(&url, ll);
227 self.request_resource(id, url, byte_range);
228 }
229 PreloadHintType::Map => {
230 let map = MapTag {
231 uri: hint_uri.clone(),
232 byte_range: ll.preload_hint_byte_range_length.map(|length| ByteRange {
233 length,
234 offset: ll.preload_hint_byte_range_start,
235 }),
236 };
237 self.ensure_init_requested(&map)?;
238 }
239 _ => {
240 // RFC 8216bis §4.4.5.3 defines only PART/MAP today; a
241 // future hint type from a newer transmux is simply not
242 // prefetched rather than treated as an error
243 // (`PreloadHintType` is `#[non_exhaustive]`).
244 }
245 }
246 }
247 }
248
249 if playlist.endlist {
250 self.saw_endlist = true;
251 } else {
252 // Issue #717 slice 1 fix: block only when the origin actually
253 // advertises `CAN-BLOCK-RELOAD=YES` — `low_latency.is_some()`
254 // alone is not enough (an origin sending `CAN-BLOCK-RELOAD=NO`
255 // still carries parts/PART-INF, e.g. while ramping up support).
256 let blocking = playlist
257 .low_latency
258 .as_ref()
259 .filter(|ll| ll.can_block_reload)
260 .map(|_| {
261 let part = playlist
262 .open_segment
263 .as_ref()
264 .map(|o| o.parts.len() as u64)
265 .unwrap_or(0);
266 BlockingReload {
267 msn: next_msn,
268 part: Some(part),
269 }
270 });
271 let can_skip = playlist
272 .low_latency
273 .as_ref()
274 .and_then(|ll| ll.can_skip_until)
275 .is_some();
276 let skip = can_skip && self.last_full_playlist.is_some();
277 self.pending_actions.push_back(Action::FetchPlaylist {
278 url: self.playlist_url.clone(),
279 blocking,
280 skip,
281 });
282 if blocking.is_none() {
283 // RFC 8216 §4.3.3.1: a client SHOULD NOT reload more
284 // frequently than once per Target Duration; half that as a
285 // reasonable non-blocking poll cadence.
286 let wait_ms = (u64::from(playlist.target_duration.max(1)) * 1000) / 2;
287 self.pending_actions.push_back(Action::WaitMs(wait_ms));
288 }
289 }
290
291 if playlist.skip.is_none() {
292 self.last_full_playlist = Some(playlist);
293 }
294
295 self.maybe_emit_end_of_stream();
296 Ok(())
297 }
298
299 /// Feed the bytes fetched for a previously requested [`ResourceId`]
300 /// (`init`/part/segment). Part/Segment resources delivered before the
301 /// init segment are buffered internally and demuxed once the init
302 /// arrives — the caller's fetches may complete in any order.
303 ///
304 /// # Errors
305 /// [`Error::UnrequestedResource`] if `id` was never requested (the
306 /// `requested` bookkeeping — or, for `Init`, `init_uri` — has no record
307 /// of it): a caller/driver bug, or a stale/duplicate delivery after the
308 /// client already moved past this id.
309 /// [`Error::Demux`] if `transmux::Fmp4Demux` rejects the concatenation of
310 /// the cached init + `bytes`.
311 pub fn on_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
312 let was_requested = match id {
313 ResourceId::Init => self.init_uri.is_some(),
314 ResourceId::Part { .. } | ResourceId::Segment { .. } => self.requested.contains(&id),
315 };
316 if !was_requested {
317 return Err(Error::UnrequestedResource { id });
318 }
319 self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
320 match id {
321 ResourceId::Init => {
322 self.init_bytes = Some(bytes.to_vec());
323 if !self.init_emitted {
324 self.pending_outputs.push_back(Output::Init(bytes.to_vec()));
325 self.init_emitted = true;
326 }
327 let buffered: Vec<_> = self.pending_demux.drain(..).collect();
328 for (bid, bbytes) in buffered {
329 self.finish_media_resource(bid, &bbytes)?;
330 }
331 }
332 ResourceId::Part { .. } | ResourceId::Segment { .. } => {
333 if self.is_ts_segment(bytes) {
334 // Classic MPEG-TS-segment HLS (issue #760): no init
335 // resource will ever arrive for this playlist, so demux
336 // this self-contained TS segment straight away rather
337 // than buffering it forever waiting for one.
338 self.finish_ts_resource(id, bytes)?;
339 } else if self.init_bytes.is_none() {
340 self.pending_demux.push_back((id, bytes.to_vec()));
341 } else {
342 self.finish_media_resource(id, bytes)?;
343 }
344 }
345 }
346 self.maybe_emit_end_of_stream();
347 Ok(())
348 }
349
350 /// Demux + emit + mark-delivered for a Part/Segment resource, once the
351 /// init segment is known to be available.
352 fn finish_media_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
353 match id {
354 ResourceId::Part { msn, part } => {
355 self.emit_discontinuity_if_needed(msn);
356 self.demux_and_emit(id, bytes)?;
357 self.delivered_parts.insert((msn, part));
358 }
359 ResourceId::Segment { msn } => {
360 self.emit_discontinuity_if_needed(msn);
361 self.demux_and_emit(id, bytes)?;
362 self.delivered_segments.insert(msn);
363 }
364 ResourceId::Init => {}
365 }
366 Ok(())
367 }
368
369 /// The classic-TS-HLS counterpart to [`Self::finish_media_resource`]:
370 /// demux + emit + mark-delivered for a self-contained MPEG-TS Part/
371 /// Segment resource — never buffered pending an init fetch, since
372 /// [`Self::is_ts_segment`] only routes here once this playlist is known
373 /// to advertise no `EXT-X-MAP` at all.
374 fn finish_ts_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
375 match id {
376 ResourceId::Part { msn, part } => {
377 self.emit_discontinuity_if_needed(msn);
378 self.demux_and_emit_ts(id, bytes)?;
379 self.delivered_parts.insert((msn, part));
380 }
381 ResourceId::Segment { msn } => {
382 self.emit_discontinuity_if_needed(msn);
383 self.demux_and_emit_ts(id, bytes)?;
384 self.delivered_segments.insert(msn);
385 }
386 ResourceId::Init => {}
387 }
388 Ok(())
389 }
390
391 /// `true` when `bytes` should be routed to [`Self::finish_ts_resource`]
392 /// (classic MPEG-TS-segment HLS, issue #760) rather than the fMP4/CMAF
393 /// path: this playlist has never advertised an `EXT-X-MAP` (no init
394 /// fetch is outstanding or cached — [`Self::init_uri`] is `None`; by the
395 /// time any Part/Segment fetch response reaches [`Self::on_resource`],
396 /// [`Self::on_playlist`] has already fully processed the playlist that
397 /// requested it, including any map it carries, so this check is never
398 /// stale) **and** `bytes` starts with the MPEG-TS sync byte — an
399 /// fMP4/CMAF resource always starts with an ISOBMFF box
400 /// (`ftyp`/`styp`/`moof`), never [`TS_SYNC_BYTE`].
401 fn is_ts_segment(&self, bytes: &[u8]) -> bool {
402 self.init_uri.is_none() && bytes.first() == Some(&TS_SYNC_BYTE)
403 }
404
405 /// Report that a previously requested [`ResourceId`] (or the playlist
406 /// itself, via [`None`]) failed. Clears the id's "requested" bookkeeping
407 /// so the next [`Self::on_playlist`] call naturally re-requests it (no
408 /// automatic retry timer — the caller drives retry cadence).
409 pub fn on_error(&mut self, id: Option<ResourceId>) {
410 if let Some(id) = id {
411 self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
412 match id {
413 ResourceId::Init => self.init_uri = None,
414 other => {
415 self.requested.remove(&other);
416 }
417 }
418 }
419 self.maybe_emit_end_of_stream();
420 }
421
422 // -- internals ------------------------------------------------------
423
424 /// Reconstruct a full playlist view from an `EXT-X-SKIP` delta update
425 /// (RFC 8216bis §4.4.5.2), by splicing the skipped prefix back in from
426 /// the last full playlist this client observed. Best-effort: if there is
427 /// no cached baseline, or it doesn't cover the skipped range, the delta
428 /// is returned as-is (never an error — "at least don't break").
429 fn merge_delta(&self, playlist: MediaPlaylist) -> MediaPlaylist {
430 let Some(skip) = &playlist.skip else {
431 return playlist;
432 };
433 if skip.skipped_segments == 0 {
434 return playlist;
435 }
436 let Some(prev) = &self.last_full_playlist else {
437 return playlist;
438 };
439 if playlist.media_sequence < prev.media_sequence {
440 return playlist;
441 }
442 let prefix_start = (playlist.media_sequence - prev.media_sequence) as usize;
443 let prefix_end = prefix_start + skip.skipped_segments as usize;
444 let Some(prefix) = prev.segments.get(prefix_start..prefix_end) else {
445 return playlist;
446 };
447 let mut merged = playlist;
448 let mut segments = prefix.to_vec();
449 segments.extend(merged.segments);
450 merged.segments = segments;
451 merged
452 }
453
454 fn process_closed_segment(&mut self, msn: u64, seg: &MediaSegment) -> Result<()> {
455 if seg.discontinuous {
456 self.discontinuous_msns.insert(msn);
457 }
458 if self.delivered_segments.contains(&msn) {
459 return Ok(());
460 }
461 if seg.parts.is_empty() {
462 // Either a genuinely non-LL segment (never had parts), OR an LL
463 // segment whose parts were already fetched individually while it
464 // was still open and whose *closed* rendering simply omits them
465 // — RFC 8216bis does not require a closed segment to keep
466 // listing `#EXT-X-PART` lines, and real origins commonly don't
467 // (e.g. `multimux`'s: `MediaSegment.parts` is always empty for a
468 // closed segment; only the still-open segment carries parts).
469 // Detect the latter via `delivered_parts`: if any part for this
470 // `msn` was ever delivered, every one of its non-`GAP` parts was
471 // already requested while it was open (`process_open_segment`
472 // requests every known part each time it's polled, so by the
473 // time the segment closes none can have been missed) — fetching
474 // the whole segment *as well* would demux and emit its samples a
475 // second time. Caught by `ll-hls-runtime/tests/glass_to_glass.rs`
476 // (issue #717 slice 5): every sample was double-delivered for
477 // the first two segments of a real, live-paced run.
478 let already_have_parts = self
479 .delivered_parts
480 .range((msn, 0)..(msn + 1, 0))
481 .next()
482 .is_some();
483 if already_have_parts {
484 self.delivered_segments.insert(msn);
485 return Ok(());
486 }
487 let id = ResourceId::Segment { msn };
488 if !self.requested.contains(&id) {
489 let url = url::resolve(&self.playlist_url, &seg.uri);
490 let byte_range = self.resolve_byte_range(&url, &seg.byte_range);
491 self.request_resource(id, url, byte_range);
492 }
493 return Ok(());
494 }
495
496 let mut fully_accounted = true;
497 for (i, part) in seg.parts.iter().enumerate() {
498 let i = i as u64;
499 if part.gap || self.delivered_parts.contains(&(msn, i)) {
500 continue;
501 }
502 fully_accounted = false;
503 let id = ResourceId::Part { msn, part: i };
504 if !self.requested.contains(&id) {
505 let url = url::resolve(&self.playlist_url, &part.uri);
506 let byte_range = self.resolve_byte_range(&url, &part.byte_range);
507 self.request_resource(id, url, byte_range);
508 }
509 }
510 if fully_accounted {
511 self.delivered_segments.insert(msn);
512 }
513 Ok(())
514 }
515
516 fn process_open_segment(&mut self, msn: u64, open: &OpenSegment) -> Result<()> {
517 for (i, part) in open.parts.iter().enumerate() {
518 let i = i as u64;
519 if part.gap || self.delivered_parts.contains(&(msn, i)) {
520 continue;
521 }
522 let id = ResourceId::Part { msn, part: i };
523 if !self.requested.contains(&id) {
524 let url = url::resolve(&self.playlist_url, &part.uri);
525 let byte_range = self.resolve_byte_range(&url, &part.byte_range);
526 self.request_resource(id, url, byte_range);
527 }
528 }
529 Ok(())
530 }
531
532 fn ensure_init_requested(&mut self, map: &MapTag) -> Result<()> {
533 let url = url::resolve(&self.playlist_url, &map.uri);
534 if self.init_uri.as_deref() == Some(url.as_str()) {
535 return Ok(());
536 }
537 self.init_uri = Some(url.clone());
538 self.init_bytes = None;
539 self.init_emitted = false;
540 let byte_range = self.resolve_byte_range(&url, &map.byte_range);
541 self.pending_actions.push_back(Action::FetchResource {
542 id: ResourceId::Init,
543 url,
544 byte_range,
545 });
546 self.outstanding_fetches += 1;
547 Ok(())
548 }
549
550 fn request_resource(&mut self, id: ResourceId, url: String, byte_range: Option<(u64, u64)>) {
551 self.requested.insert(id);
552 self.outstanding_fetches += 1;
553 self.pending_actions.push_back(Action::FetchResource {
554 id,
555 url,
556 byte_range,
557 });
558 }
559
560 /// Resolve a `PartSpec`/`MediaSegment`/`MapTag` `BYTERANGE` into an
561 /// absolute `(offset, length)`, honouring the "omitted offset continues
562 /// the previous sub-range of the same resource" rule (tracked per
563 /// resolved URL).
564 fn resolve_byte_range(&mut self, url: &str, br: &Option<ByteRange>) -> Option<(u64, u64)> {
565 let br = br.as_ref()?;
566 let offset = br
567 .offset
568 .unwrap_or_else(|| *self.byte_range_cursor.get(url).unwrap_or(&0));
569 self.byte_range_cursor
570 .insert(url.to_string(), offset + br.length);
571 Some((offset, br.length))
572 }
573
574 fn resolve_hint_byte_range(
575 &mut self,
576 url: &str,
577 ll: &transmux::hls::LowLatencyConfig,
578 ) -> Option<(u64, u64)> {
579 let length = ll.preload_hint_byte_range_length?;
580 let br = ByteRange {
581 length,
582 offset: ll.preload_hint_byte_range_start,
583 };
584 self.resolve_byte_range(url, &Some(br))
585 }
586
587 fn demux_and_emit(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
588 let init = self
589 .init_bytes
590 .as_ref()
591 .ok_or(Error::InitNotYetAvailable { id })?;
592 let mut combined = Vec::with_capacity(init.len() + bytes.len());
593 combined.extend_from_slice(init);
594 combined.extend_from_slice(bytes);
595 let mut demux = Fmp4Demux::new();
596 let media = demux
597 .unpackage(combined.as_slice())
598 .map_err(|source| Error::Demux { id, source })?;
599 for track in media.tracks {
600 if !track.samples.is_empty() {
601 self.pending_outputs.push_back(Output::Samples {
602 track_id: track.spec.track_id,
603 samples: track.samples,
604 });
605 }
606 }
607 Ok(())
608 }
609
610 /// The classic-TS-HLS counterpart to [`Self::demux_and_emit`]: demux a
611 /// self-contained MPEG-TS Part/Segment resource via [`TsDemux`] directly
612 /// (no init bytes to concatenate — each `.ts` segment carries its own
613 /// PAT/PMT/PES). On the very first such resource this client demuxes,
614 /// also synthesizes the one [`Output::Init`] the crate's output contract
615 /// requires ("exactly one `Init` precedes any `Samples`") from the
616 /// recovered [`TrackSpec`]s via [`transmux::build_init_segment`] — a real
617 /// `ftyp`+fragmented-`moov`, byte-for-byte demuxable by
618 /// `transmux::Fmp4Demux` like any other init segment, so callers built
619 /// against the fMP4 path (e.g. `multimux`'s `HlsPull`, which recovers
620 /// track specs from `Output::Init`) need no TS-specific handling.
621 fn demux_and_emit_ts(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
622 let mut demux = TsDemux::new();
623 let media = demux
624 .demux(bytes)
625 .map_err(|source| Error::Demux { id, source })?;
626 if !self.init_emitted {
627 let specs: Vec<TrackSpec> = media.tracks.iter().map(|t| t.spec.clone()).collect();
628 let init_bytes = transmux::build_init_segment(&specs, media.movie_timescale)
629 .map_err(|source| Error::Demux { id, source })?;
630 self.pending_outputs.push_back(Output::Init(init_bytes));
631 self.init_emitted = true;
632 }
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 fn emit_discontinuity_if_needed(&mut self, msn: u64) {
645 if self.discontinuous_msns.contains(&msn) && !self.discontinuity_emitted.contains(&msn) {
646 self.pending_outputs.push_back(Output::Discontinuity);
647 self.discontinuity_emitted.insert(msn);
648 }
649 }
650
651 fn maybe_emit_end_of_stream(&mut self) {
652 if self.saw_endlist && !self.end_emitted && self.outstanding_fetches == 0 {
653 self.pending_outputs.push_back(Output::EndOfStream);
654 self.end_emitted = true;
655 }
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 // Regression: `on_resource` documents (see `Error::UnrequestedResource`)
664 // that it rejects a `ResourceId` the client never requested, but
665 // previously never actually checked — any bytes for any id (a
666 // caller/driver bug, or a stale/duplicate delivery) were silently
667 // accepted. Must FAIL if that check is ever removed.
668 #[test]
669 fn on_resource_rejects_a_never_requested_id() {
670 let mut client = LlHlsClient::new("http://example.com/playlist.m3u8");
671 let id = ResourceId::Segment { msn: 0 };
672
673 let err = client
674 .on_resource(id, b"some bytes")
675 .expect_err("an id the client never requested must be rejected");
676 assert!(
677 matches!(err, Error::UnrequestedResource { id: got } if got == id),
678 "wrong error variant: {err:?}"
679 );
680
681 // Init is checked too (tracked via `init_uri` rather than
682 // `requested`, since it's never inserted into that set).
683 let err = client
684 .on_resource(ResourceId::Init, b"init bytes")
685 .expect_err("an unrequested Init must be rejected");
686 assert!(
687 matches!(
688 err,
689 Error::UnrequestedResource {
690 id: ResourceId::Init
691 }
692 ),
693 "wrong error variant: {err:?}"
694 );
695 }
696
697 // The flip side of the regression above: a `ResourceId` the client
698 // actually asked for (via its own internal `request_resource`
699 // bookkeeping, mirroring what a real `poll()`-driven fetch populates)
700 // must still be accepted, not spuriously rejected.
701 #[test]
702 fn on_resource_accepts_a_previously_requested_id() {
703 let mut client = LlHlsClient::new("http://example.com/playlist.m3u8");
704 let id = ResourceId::Segment { msn: 0 };
705 client.request_resource(id, "http://example.com/seg0.m4s".to_string(), None);
706
707 // No init segment cached yet, so this is buffered rather than
708 // demuxed — the point here is only that it is *not* rejected as
709 // unrequested.
710 let result = client.on_resource(id, b"some bytes");
711 assert!(
712 result.is_ok(),
713 "a requested id must be accepted: {result:?}"
714 );
715 assert!(
716 client.pending_demux.iter().any(|(bid, _)| *bid == id),
717 "expected the resource to be buffered pending the init segment"
718 );
719 }
720
721 // Issue #760: classic MPEG-TS-segment HLS routing. `is_ts_segment` must
722 // say yes to a genuine TS resource (sync byte, no map ever seen)...
723 #[test]
724 fn is_ts_segment_true_when_no_map_seen_and_sync_byte_present() {
725 let client = LlHlsClient::new("http://example.com/playlist.m3u8");
726 assert!(client.is_ts_segment(&[TS_SYNC_BYTE, 0x40, 0x11, 0x00]));
727 }
728
729 // ...but say no to an ISOBMFF (fMP4/CMAF) resource even when no map has
730 // been seen yet — the content itself is never TS, so it must fall
731 // through to the ordinary init-buffering path rather than being
732 // misrouted into `TsDemux` (which would reject it as malformed TS).
733 #[test]
734 fn is_ts_segment_false_for_an_isobmff_resource_with_no_map_seen() {
735 let client = LlHlsClient::new("http://example.com/playlist.m3u8");
736 let ftyp_box = b"\x00\x00\x00\x18ftypiso5\x00\x00\x02\x00iso5iso6mp41";
737 assert!(!client.is_ts_segment(ftyp_box));
738 }
739
740 // The playlist signal takes precedence over content-sniffing: once this
741 // playlist is known to advertise an `EXT-X-MAP` (an init fetch has been
742 // requested/cached), even a resource whose first byte happens to be
743 // `0x47` must NOT be misrouted through `TsDemux` -- it is that
744 // playlist's own fMP4/CMAF init + part/segment concatenation the
745 // fetched bytes belong with.
746 #[test]
747 fn is_ts_segment_false_once_a_map_has_been_requested() {
748 let mut client = LlHlsClient::new("http://example.com/playlist.m3u8");
749 client
750 .ensure_init_requested(&MapTag {
751 uri: "init.mp4".to_string(),
752 byte_range: None,
753 })
754 .expect("ensure_init_requested succeeds");
755 assert!(!client.is_ts_segment(&[TS_SYNC_BYTE, 0x40, 0x11, 0x00]));
756 }
757}