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::format;
5use alloc::string::{String, ToString};
6use alloc::vec::Vec;
7use container_probe::segment;
8use mpeg_ts::ts::TS_SYNC_BYTE;
9use transmux::TrackId;
10
11use broadcast_common::Unpackage;
12use broadcast_common::url_ref;
13use broadcast_hls::{
14    ByteRange, EncryptionMethod, MapTag, MasterPlaylist, MediaKey, MediaPlaylist, MediaSegment,
15    OpenSegment, PreloadHintType,
16};
17use transmux::sample_aes::{BLOCK_LEN, KEY_LEN, aes128_decrypt_segment};
18use transmux::{DemuxEvent, Fmp4Demux, InputDegradation, Sample, StreamingTsDemux, TrackSpec};
19
20use super::action::{Action, BlockingReload, KeyId, ResourceId};
21use super::error::{Error, Result};
22use super::output::{MasterSelection, Output, RenditionInfo, VariantInfo, VariantPolicy};
23use super::url;
24use broadcast_common::units::MILLIS_PER_SEC;
25
26/// Movie timescale of the init segment synthesized for classic TS-segment
27/// HLS: the 90 kHz MPEG-2 system clock the PES PTS/DTS are expressed in
28/// (ISO/IEC 13818-1 §2.4.3.7), the same clock `transmux`'s TS demuxer stamps
29/// its samples with.
30const TS_MOVIE_TIMESCALE: u32 = 90_000;
31
32/// How many Target Durations before the end of a live Playlist the first
33/// segment the client plays must start at the latest (RFC 8216 §6.3.3: "the
34/// client SHOULD NOT choose a segment that starts less than three target
35/// durations from the end of the Playlist file").
36const LIVE_EDGE_TARGET_DURATIONS: f64 = 3.0;
37
38/// Ceiling on ciphertext held while its key is still missing (total bytes).
39const MAX_PENDING_KEYED_BYTES: usize = 64 * 1024 * 1024;
40
41/// Longest the non-blocking reload cadence may wait, however large the
42/// untrusted `EXT-X-TARGETDURATION` says the target duration is.
43const MAX_RELOAD_WAIT_MS: u64 = 60_000;
44
45/// `EXT-X-VERSION` of the one-segment playlist a single `EXT-X-MAP` line is
46/// parsed in: the lowest version that allows `EXT-X-MAP` without
47/// `EXT-X-I-FRAMES-ONLY` (RFC 8216 §4.3.3.6).
48/// `moov` (ISO/IEC 14496-12:2026 §8.2.1).
49const MOVIE_BOX: [u8; 4] = *b"moov";
50
51const MAP_PROBE_VERSION: u32 = 6;
52
53/// [`StreamingTsDemux`] with a `Debug` impl (the transmux type has none), so
54/// [`HlsClient`] can keep deriving it.
55struct TsDemuxState(StreamingTsDemux);
56
57impl core::fmt::Debug for TsDemuxState {
58    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
59        f.write_str("TsDemuxState(StreamingTsDemux)")
60    }
61}
62
63/// `usize` index -> `u64` (lossless on every supported target; saturates
64/// rather than casting if a target ever had a wider `usize`).
65fn index_u64(i: usize) -> u64 {
66    u64::try_from(i).unwrap_or(u64::MAX)
67}
68
69/// Half the Target Duration, the non-blocking reload cadence (RFC 8216
70/// §4.3.3.1: no more often than once per Target Duration), clamped because
71/// the Target Duration is untrusted.
72fn reload_wait_ms(target_duration: u32) -> u64 {
73    (u64::from(target_duration.max(1)).saturating_mul(MILLIS_PER_SEC) / 2).min(MAX_RELOAD_WAIT_MS)
74}
75
76/// Every `EXT-X-MAP` line of `text` with the identity-`KEYFORMAT` key in effect
77/// at it (RFC 8216 §4.3.2.5: a key tag applies to a Media Initialization
78/// Section only when it precedes the `EXT-X-MAP` tag), in playlist order. The
79/// key is `None` when that map is not encrypted.
80fn keys_at_maps(text: &str) -> Vec<(MapTag, Option<MediaKey>)> {
81    const MAP_PREFIX: &str = "#EXT-X-MAP:";
82    const KEY_PREFIX: &str = "#EXT-X-KEY:";
83    let mut current: Option<MediaKey> = None;
84    let mut out = Vec::new();
85    for line in text.lines() {
86        if line.starts_with(KEY_PREFIX) {
87            let probe = format!(
88                "#EXTM3U\n#EXT-X-TARGETDURATION:1\n{}\n#EXTINF:1,\nx\n",
89                line.trim_end()
90            );
91            if let Ok(parsed) = MediaPlaylist::parse(&probe)
92                && let Ok(mut keys) = parsed.segment_keys()
93                && let Some(key) = keys.pop()
94            {
95                current = key;
96            }
97        } else if line.starts_with(MAP_PREFIX) {
98            let probe = format!(
99                "#EXTM3U\n#EXT-X-VERSION:{MAP_PROBE_VERSION}\n#EXT-X-TARGETDURATION:1\n{}\n#EXTINF:1,\nx\n",
100                line.trim_end()
101            );
102            if let Ok(parsed) = MediaPlaylist::parse(&probe)
103                && let Some(map) = parsed.segments.first().and_then(|seg| seg.map.clone())
104            {
105                out.push((map, current.clone()));
106            }
107        }
108    }
109    out
110}
111
112/// `true` when `bytes` holds a complete `moov` box: what every Media
113/// Initialization Section of fragmented MP4 carries.
114fn has_movie_box(bytes: &[u8]) -> bool {
115    for step in transmux::box_iter(bytes) {
116        let Ok((boxed, _)) = step else { return false };
117        if boxed.header.box_type.0 == MOVIE_BOX {
118            return true;
119        }
120    }
121    false
122}
123
124/// `uri` up to its query string: a CDN that re-signs a segment URL on every
125/// reload changes only the query, on every segment of the window at once.
126fn without_query(uri: &str) -> &str {
127    uri.split_once('?').map_or(uri, |(path, _)| path)
128}
129
130/// The `EXT-X-KEY` tags in effect after `dropped` segments, one per
131/// `KEYFORMAT`: when a playlist's window slides past the segment carrying a
132/// key tag, a merged view must state that key again on its new first segment.
133fn carried_key_tags(dropped: &[MediaSegment]) -> Vec<String> {
134    const KEY_PREFIX: &str = "#EXT-X-KEY:";
135    const KEYFORMAT_ATTR: &str = "KEYFORMAT=\"";
136    const IDENTITY: &str = "identity";
137    let mut latest: BTreeMap<&str, &String> = BTreeMap::new();
138    for tag in dropped.iter().flat_map(|seg| &seg.pre_tags) {
139        if !tag.starts_with(KEY_PREFIX) {
140            continue;
141        }
142        let format = tag
143            .split_once(KEYFORMAT_ATTR)
144            .and_then(|(_, rest)| rest.split_once('"'))
145            .map_or(IDENTITY, |(format, _)| format);
146        latest.insert(format, tag);
147    }
148    latest.into_values().cloned().collect()
149}
150
151fn decrypt_segment(
152    id: ResourceId,
153    crypto: SegmentCrypto,
154    key: &[u8; KEY_LEN],
155    ciphertext: &[u8],
156) -> Result<Vec<u8>> {
157    aes128_decrypt_segment(key, &crypto.iv, ciphertext).map_err(|_| Error::DecryptFailed {
158        id,
159        key_id: crypto.key_id,
160    })
161}
162
163/// The Media Sequence Number a tracked resource belongs to, `None` for the
164/// MSN-less init segment.
165fn resource_msn(id: &ResourceId) -> Option<u64> {
166    match id {
167        ResourceId::Part { msn, .. } | ResourceId::Segment { msn } => Some(*msn),
168        _ => None,
169    }
170}
171
172/// Identifies one Media Initialization Section the client holds or wants. Ids
173/// are never reused, so a stale id can not alias a later init.
174type InitId = u32;
175
176/// Where a Media Initialization Section is in its fetch.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178enum InitFetch {
179    /// Known and needed, waiting for the one init fetch in flight to finish.
180    Wanted,
181    /// Requested ([`ResourceId::Init`] refers to it).
182    InFlight,
183    /// Its fetch failed; the next playlist (or [`HlsClient::retry_init`]) asks again.
184    Failed,
185    /// Arrived encrypted, waiting for its key.
186    Arrived,
187    /// Held in plain.
188    Ready,
189}
190
191/// One Media Initialization Section: the `EXT-X-MAP` of one map-run
192/// (RFC 8216bis §4.4.4.5 — a map applies to every following segment until the
193/// next map).
194#[derive(Debug)]
195struct InitSlot {
196    map: MapTag,
197    url: String,
198    byte_range: Option<(u64, u64)>,
199    encryption: Option<MediaKey>,
200    crypto: Option<SegmentCrypto>,
201    fetch: InitFetch,
202    cipher: Option<Vec<u8>>,
203    bytes: Option<Vec<u8>>,
204    skipped_reported: BTreeSet<(Option<u32>, String)>,
205}
206
207/// An `EXT-X-MAP` of the playlist being processed, with the key that encrypts
208/// it and the Media Sequence Number of the first segment it applies to.
209#[derive(Debug)]
210struct MapEntry {
211    map: MapTag,
212    encryption: Option<MediaKey>,
213    first_msn: u64,
214}
215
216impl MapEntry {
217    fn unusable(&self) -> Option<Error> {
218        let key = self.encryption.as_ref()?;
219        let what = if key.method != EncryptionMethod::Aes128 {
220            "encrypted EXT-X-MAP: only METHOD=AES-128 is decrypted"
221        } else if key.uri.is_none() {
222            "encrypted EXT-X-MAP: key without URI"
223        } else {
224            return None;
225        };
226        Some(Error::UnsupportedEncryption {
227            method: key.method,
228            what,
229        })
230    }
231}
232
233/// An AES-128 key whose `Debug` never prints the key octets.
234#[derive(Clone, Copy)]
235struct KeyBytes([u8; KEY_LEN]);
236
237impl core::fmt::Debug for KeyBytes {
238    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
239        f.write_str("KeyBytes(<redacted>)")
240    }
241}
242
243/// The decryption parameters of one AES-128 segment (RFC 8216 §5.2).
244#[derive(Debug, Clone, Copy)]
245struct SegmentCrypto {
246    key_id: KeyId,
247    iv: [u8; BLOCK_LEN],
248}
249
250/// RFC 8216 §5.2: the Media Sequence Number as a big-endian 128-bit number,
251/// zero-padded on the left.
252fn media_sequence_iv(msn: u64) -> [u8; BLOCK_LEN] {
253    let mut iv = [0u8; BLOCK_LEN];
254    let msn_bytes = msn.to_be_bytes();
255    let start = BLOCK_LEN - msn_bytes.len();
256    iv[start..].copy_from_slice(&msn_bytes);
257    iv
258}
259
260/// How a new playlist relates to the previous one (see
261/// [`HlsClient::continuity`]).
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263enum Continuity {
264    /// The same stream, moved on (or the same window again).
265    Continues,
266    /// The same stream, but an older view of it that still overlaps the
267    /// previous window.
268    Lagging,
269    /// The same stream, but a view wholly *older* than the previous window
270    /// (nothing in common): its segments are skipped, never delivered behind
271    /// what was already delivered.
272    OlderWindow,
273    /// A different stream: an origin restart.
274    Restart,
275}
276
277/// The `EXT-X-PROGRAM-DATE-TIME` value attached to `seg`, if any.
278fn program_date_time(seg: &MediaSegment) -> Option<broadcast_hls::ProgramDateTime> {
279    seg.program_date_time
280}
281
282/// [`program_date_time`] as milliseconds since the Unix epoch.
283fn program_date_time_ms(seg: &MediaSegment) -> Option<i64> {
284    program_date_time(seg).map(|pdt| pdt.timestamp().as_millisecond())
285}
286
287/// A driveable, sans-IO Low-Latency HLS (RFC 8216bis) playback client.
288///
289/// `HlsClient` never touches a socket or a clock. The caller drives it:
290///
291/// 1. [`HlsClient::new`] seeds the first [`Action::FetchPlaylist`]; drain it
292///    with [`HlsClient::poll`] and perform the GET.
293/// 2. Feed the response back with [`HlsClient::on_playlist`] (playlist) or
294///    [`HlsClient::on_resource`] (init/part/segment bytes) —
295///    [`Action::FetchResource`]'s `id` correlates the two.
296/// 3. Drain [`HlsClient::poll`] again for the next round of actions (a new
297///    reload, newly discoverable parts, a preload-hint prefetch, ...) and
298///    [`HlsClient::next_output`] for newly available [`Output`]s.
299///
300/// # Behaviour
301///
302/// - **Reload scheduling** (issue #717 slice 2): once a playlist advertises
303///   `EXT-X-SERVER-CONTROL`/`EXT-X-PART-INF` **and** the origin's
304///   `CAN-BLOCK-RELOAD` attribute is `YES`
305///   ([`broadcast_hls::LowLatencyConfig::can_block_reload`] is `true` —
306///   *not* merely [`broadcast_hls::MediaPlaylist::low_latency`] being
307///   `Some`, since an origin may carry parts/PART-INF while still
308///   advertising `CAN-BLOCK-RELOAD=NO`), every reload is a Blocking
309///   Playlist Reload (RFC 8216bis §6.2.5.2) naming the next not-yet-seen
310///   Partial Segment's `_HLS_msn`/`_HLS_part`. Otherwise reloads are plain GETs
311///   paced by an [`Action::WaitMs`] hint derived from `#EXT-X-TARGETDURATION`.
312///   `EXT-X-SKIP`/`CAN-SKIP-UNTIL` Playlist Delta Updates (RFC 8216bis §4.4.5.2)
313///   are requested once a full-playlist baseline exists, and merged back into
314///   a full view before further processing — see `merge_delta` internally.
315/// - **Fetch pipeline** (slice 3): the `EXT-X-PRELOAD-HINT`ed part is fetched
316///   ahead of its own appearance as a numbered `EXT-X-PART`; `BYTERANGE`
317///   parts are supported, including the RFC 8216bis §4.4.4.9 "omitted offset
318///   means immediately after the previous sub-range of the same resource"
319///   rule (tracked per resource URL). Each Media Initialization Section
320///   (`EXT-X-MAP`) is fetched once, one at a time, and used for exactly the
321///   segments of its own map-run (RFC 8216bis §4.4.4.5: a map applies to every
322///   following segment until the next map).
323/// - **Dedup / coalescing**: once *any* of a segment's parts have been
324///   individually fetched, that segment is never re-fetched whole — when it
325///   later closes (`#EXTINF`+URI), the client only fetches whichever of its
326///   parts (if any) are still missing, and marks the segment "delivered" once
327///   every part is accounted for (fetched, or `GAP=YES`). A playlist whose
328///   segments carry **no** parts at all (a non-LL origin) falls back to
329///   fetching the whole segment resource — the two paths never overlap for a
330///   single segment, so a part's samples are never double-counted against its
331///   parent's.
332/// - **Output adapter** (slice 4): exactly one [`Output::Init`] precedes any
333///   [`Output::Samples`]; parts/segments are demuxed via
334///   [`transmux::Fmp4Demux`] (by concatenating the cached init bytes with the
335///   fetched resource — this crate never re-implements ISOBMFF box parsing,
336///   only reuses transmux's), so `Output::Samples` carries real access units,
337///   not opaque container bytes. `#EXT-X-DISCONTINUITY` on a segment surfaces
338///   as [`Output::Discontinuity`] immediately before that segment's first
339///   samples. **Known limitation**: an in-progress ([`OpenSegment`]) segment
340///   carries no discontinuity flag of its own (only a *closed*
341///   [`MediaSegment`] does) — if every part of a segment was already
342///   delivered while it was still open, a discontinuity revealed only once it
343///   closes is signalled late (after those parts' samples, not before). This
344///   is a gap in the current wire model ([`broadcast_hls::OpenSegment`]), not
345///   something this crate can fix locally.
346/// - **Classic MPEG-TS-segment HLS** (issue #760): a playlist that never
347///   advertises an `EXT-X-MAP` (HLS v3, the dominant legacy/IPTV form —
348///   self-contained `.ts` segments carrying their own PAT/PMT/PES, no
349///   separate init resource) routes each fetched Part/Segment through
350///   [`transmux::TsDemux`] instead, content-sniffed by the MPEG-TS sync byte
351///   rather than blocked on an init fetch that will never come. The first
352///   successfully demuxed segment's recovered
353///   [`TrackSpec`]s synthesize the one [`Output::Init`] this crate's contract
354///   requires (via [`transmux::build_init_segment`]) so downstream callers
355///   (e.g. `multimux`'s `HlsPull`, which recovers track specs from
356///   `Output::Init`) need no TS-specific handling of their own. The
357///   fMP4/CMAF plus LL (parts/preload-hint) path above is entirely
358///   unchanged; the two never overlap for a single playlist.
359#[derive(Debug)]
360pub struct HlsClient {
361    playlist_url: String,
362    base_override: Option<String>,
363    variant_policy: VariantPolicy,
364    master: Option<MasterSelection>,
365
366    pending_actions: VecDeque<Action>,
367    /// The caller-clock instant at which the `WaitMs` now at the front of
368    /// `pending_actions` was first observed by [`Self::poll_timeout`]; the
369    /// wait's absolute deadline is `anchor + wait`. Cleared whenever an action
370    /// is drained, so a later wait is anchored afresh.
371    #[cfg(feature = "std")]
372    wait_anchor: Option<std::time::Instant>,
373    pending_outputs: VecDeque<Output>,
374    /// Playlist references the guarded resolver refused (issue #2458), reported
375    /// through [`Self::next_error`] in the order the playlist named them.
376    pending_errors: VecDeque<Error>,
377
378    /// Every Media Initialization Section the client holds or wants, by id.
379    inits: BTreeMap<InitId, InitSlot>,
380    next_init_id: InitId,
381    /// The init [`ResourceId::Init`] currently refers to: the one in flight, or
382    /// the last one that was.
383    init_target: Option<InitId>,
384    /// The init whose [`Output::Init`] the consumer saw last.
385    announced_init: Option<InitId>,
386    /// The init that applies to each Media Sequence Number being fetched (RFC
387    /// 8216 §4.4.4.5: the map preceding the segment).
388    msn_init: BTreeMap<u64, InitId>,
389    /// The maps of the playlist being processed.
390    playlist_maps: Vec<MapEntry>,
391    /// Last fMP4 track spec set for which [`Output::Tracks`] was emitted, so a
392    /// repeat is only emitted when a new init yields a different set.
393    last_fmp4_specs: Option<Vec<TrackSpec>>,
394    /// Part/Segment resources delivered before the init segment arrived —
395    /// buffered (in arrival order) and replayed once their init is ready, so
396    /// the caller's fetch/response IO can complete in any order (a real HTTP
397    /// client has no reason to serialize on init-first).
398    pending_demux: VecDeque<(ResourceId, Vec<u8>)>,
399
400    /// Key id per distinct absolute key URI.
401    key_ids: BTreeMap<String, KeyId>,
402    /// The URI each assigned id was issued for.
403    key_urls: BTreeMap<KeyId, String>,
404    /// Keys fetched so far, cached for the life of the client.
405    key_material: BTreeMap<KeyId, KeyBytes>,
406    /// Keys whose fetch is outstanding.
407    keys_in_flight: BTreeSet<KeyId>,
408    /// Decryption parameters of every encrypted segment requested, by MSN.
409    segment_crypto: BTreeMap<u64, SegmentCrypto>,
410    /// Encrypted segments delivered before their key arrived (ciphertext).
411    pending_keyed: Vec<(ResourceId, Vec<u8>)>,
412    /// Next [`KeyId`] to hand out; never reused, so evicting a key can not
413    /// make a stale id alias a live one.
414    next_key_id: u32,
415    /// A full playlist was asked for after an unmergeable delta; a repeat
416    /// is paced by a wait instead of hot-looping.
417    full_reload_requested: bool,
418
419    requested: BTreeSet<ResourceId>,
420    /// URL and byte range of every Part/Segment fetch issued, so [`Self::retry_resource`] can re-request a failed one
421    /// without a playlist reload. Pruned with the other MSN-keyed records.
422    resource_requests: BTreeMap<ResourceId, (String, Option<(u64, u64)>)>,
423    /// Ids whose bytes `on_resource` has accepted (delivered *or* buffered in
424    /// [`Self::pending_demux`]) — the exactly-once guard against a second
425    /// delivery of the same id. Pruned with the other MSN-keyed sets.
426    fulfilled: BTreeSet<ResourceId>,
427    /// `true` once the first playlist has been processed (the live-edge join
428    /// of RFC 8216 §6.3.3 applies to that one only).
429    joined: bool,
430    /// Classic-TS-HLS demuxer kept across segments, so the 33-bit PTS/DTS
431    /// unwrap state and the PMT-derived track set survive a segment
432    /// boundary. Replaced by a fresh one at `EXT-X-DISCONTINUITY`.
433    ts_demux: Option<TsDemuxState>,
434    /// The track specs `ts_demux` has reported, by track id.
435    ts_specs: BTreeMap<TrackId, TrackSpec>,
436    /// The init segment last synthesized from `ts_specs`; a new
437    /// [`Output::Init`] goes out only when a re-synthesis differs from it.
438    last_ts_init: Option<Vec<u8>>,
439    delivered_parts: BTreeSet<(u64, u64)>,
440    delivered_segments: BTreeSet<u64>,
441    discontinuous_msns: BTreeSet<u64>,
442    discontinuity_emitted: BTreeSet<u64>,
443    byte_range_cursor: BTreeMap<String, u64>,
444
445    outstanding_fetches: u64,
446    saw_endlist: bool,
447    end_emitted: bool,
448    last_full_playlist: Option<MediaPlaylist>,
449    /// `EXT-X-MEDIA-SEQUENCE` of the previous playlist: RFC 8216 §6.2.2
450    /// forbids it ever decreasing, so a smaller one is an origin restart
451    /// (see [`Self::restart_after_regression`]).
452    last_media_sequence: Option<u64>,
453}
454
455/// Choose a variant index according to the policy: the highest-bandwidth variant the policy
456/// allows, else the lowest-bandwidth one when it allows none; `0` for an empty list.
457#[must_use]
458pub fn choose_variant(variants: &[VariantInfo], policy: &VariantPolicy) -> usize {
459    if variants.is_empty() {
460        return 0;
461    }
462    let mut candidates: Vec<usize> = Vec::new();
463    for (i, v) in variants.iter().enumerate() {
464        let mut ok = true;
465        if let Some(max_bw) = policy.max_bandwidth
466            && v.bandwidth > max_bw
467        {
468            ok = false;
469        }
470        if ok
471            && let Some(max_h) = policy.max_height
472            && let Some(res) = v.resolution
473            && res.1 > max_h
474        {
475            ok = false;
476        }
477        if ok
478            && let Some(supported) = &policy.supported_codecs
479            && let Some(codecs) = &v.codecs
480        {
481            for entry in codecs.split(',') {
482                let entry = entry.trim();
483                if entry.is_empty() {
484                    continue;
485                }
486                if !supported.iter().any(|s| entry.starts_with(s)) {
487                    ok = false;
488                    break;
489                }
490            }
491        }
492        if ok {
493            candidates.push(i);
494        }
495    }
496    if !candidates.is_empty() {
497        let mut best = candidates[0];
498        for &i in &candidates[1..] {
499            if variants[i].bandwidth > variants[best].bandwidth {
500                best = i;
501            }
502        }
503        best
504    } else {
505        let mut best = 0usize;
506        for i in 1..variants.len() {
507            if variants[i].bandwidth < variants[best].bandwidth {
508                best = i;
509            }
510        }
511        best
512    }
513}
514
515/// What an [`HlsClient`] is configured by: the playlist it follows and how it
516/// chooses variants.
517#[derive(Debug, Clone)]
518#[non_exhaustive]
519pub struct HlsClientConfig {
520    /// The Media (or Master) Playlist URL to follow.
521    pub playlist_url: String,
522    /// How a Master Playlist's variant is chosen.
523    pub variant_policy: VariantPolicy,
524    /// The URL relative playlist URIs resolve against until the first playlist
525    /// response says otherwise; `None` resolves against `playlist_url`.
526    pub playlist_base: Option<String>,
527}
528
529impl HlsClientConfig {
530    /// A config following `playlist_url` with the default [`VariantPolicy`].
531    pub fn new(playlist_url: impl Into<String>) -> Self {
532        HlsClientConfig {
533            playlist_url: playlist_url.into(),
534            variant_policy: VariantPolicy::default(),
535            playlist_base: None,
536        }
537    }
538
539    /// Replace [`HlsClientConfig::variant_policy`].
540    #[must_use]
541    pub fn with_variant_policy(mut self, variant_policy: VariantPolicy) -> Self {
542        self.variant_policy = variant_policy;
543        self
544    }
545
546    /// Replace [`HlsClientConfig::playlist_base`].
547    #[must_use]
548    pub fn with_playlist_base(mut self, playlist_base: impl Into<String>) -> Self {
549        self.playlist_base = Some(playlist_base.into());
550        self
551    }
552}
553
554impl HlsClient {
555    /// Create a new client from `config`, seeding the first
556    /// [`Action::FetchPlaylist`] (a plain, non-blocking GET — the client does
557    /// not yet know whether the origin supports blocking reload).
558    pub fn new(config: HlsClientConfig) -> Self {
559        let playlist_url = config.playlist_url;
560        let mut pending_actions = VecDeque::new();
561        pending_actions.push_back(Action::FetchPlaylist {
562            url: playlist_url.clone(),
563            blocking: None,
564            skip: false,
565        });
566        Self {
567            playlist_url,
568            base_override: config.playlist_base,
569            variant_policy: config.variant_policy,
570            master: None,
571            pending_actions,
572            pending_errors: VecDeque::new(),
573            #[cfg(feature = "std")]
574            wait_anchor: None,
575            pending_outputs: VecDeque::new(),
576            inits: BTreeMap::new(),
577            next_init_id: 0,
578            init_target: None,
579            announced_init: None,
580            msn_init: BTreeMap::new(),
581            playlist_maps: Vec::new(),
582            last_fmp4_specs: None,
583            pending_demux: VecDeque::new(),
584            key_ids: BTreeMap::new(),
585            key_urls: BTreeMap::new(),
586            key_material: BTreeMap::new(),
587            keys_in_flight: BTreeSet::new(),
588            segment_crypto: BTreeMap::new(),
589            pending_keyed: Vec::new(),
590            next_key_id: 0,
591            full_reload_requested: false,
592            requested: BTreeSet::new(),
593            resource_requests: BTreeMap::new(),
594            fulfilled: BTreeSet::new(),
595            joined: false,
596            ts_demux: None,
597            ts_specs: BTreeMap::new(),
598            last_ts_init: None,
599            delivered_parts: BTreeSet::new(),
600            delivered_segments: BTreeSet::new(),
601            discontinuous_msns: BTreeSet::new(),
602            discontinuity_emitted: BTreeSet::new(),
603            byte_range_cursor: BTreeMap::new(),
604            outstanding_fetches: 0,
605            saw_endlist: false,
606            end_emitted: false,
607            last_full_playlist: None,
608            last_media_sequence: None,
609        }
610    }
611
612    /// The Media Playlist URL this client is following.
613    pub fn playlist_url(&self) -> &str {
614        &self.playlist_url
615    }
616
617    /// Set the URL relative playlist URIs resolve against: the final URL of the next playlist
618    /// response after redirects (RFC 3986 section 5.1.3). Reloads keep requesting
619    /// [`HlsClient::playlist_url`]; the base is cleared when the client switches to another
620    /// playlist URL.
621    pub fn set_playlist_base(&mut self, base: impl Into<String>) {
622        self.base_override = Some(base.into());
623    }
624
625    fn base(&self) -> &str {
626        self.base_override.as_deref().unwrap_or(&self.playlist_url)
627    }
628
629    /// Whether a playlist carrying `EXT-X-ENDLIST` has been processed (the stream is VOD or
630    /// has finished).
631    pub fn endlist_seen(&self) -> bool {
632        self.saw_endlist
633    }
634
635    /// `EXT-X-TARGETDURATION` in seconds of the last full media playlist, `None` before one
636    /// was processed.
637    pub fn target_duration_secs(&self) -> Option<u32> {
638        self.last_full_playlist.as_ref().map(|p| p.target_duration)
639    }
640
641    /// Switch to a different variant by index (stall switching).
642    pub fn select_variant(&mut self, index: usize) -> Result<()> {
643        let variants_len = self
644            .master
645            .as_ref()
646            .ok_or(Error::NoSuchVariant { index })?
647            .variants
648            .len();
649        if index >= variants_len {
650            return Err(Error::NoSuchVariant { index });
651        }
652        let url = self
653            .master
654            .as_ref()
655            .and_then(|m| m.variants.get(index))
656            .map(|v| v.url.clone())
657            .ok_or(Error::NoSuchVariant { index })?;
658        self.restart_after_regression()?;
659        self.pending_actions.clear();
660        self.playlist_url = url.clone();
661        self.base_override = None;
662        self.pending_actions.push_back(Action::FetchPlaylist {
663            url,
664            blocking: None,
665            skip: false,
666        });
667        if let Some(m) = &mut self.master {
668            m.chosen = index;
669        }
670        Ok(())
671    }
672
673    /// Drain the next IO [`Action`] the caller must perform, if any.
674    pub fn poll(&mut self) -> Option<Action> {
675        #[cfg(feature = "std")]
676        {
677            self.wait_anchor = None;
678        }
679        self.pending_actions.pop_front()
680    }
681
682    /// The `WaitMs` hint at the front of the action queue, as a `Duration`;
683    /// `None` when the next queued action is a fetch (or nothing is queued). A
684    /// wait is queued BEHIND the fetches `on_playlist` queued with it, so it is
685    /// reported only once those have been drained with [`Self::poll`].
686    /// Peeking never consumes it, and the core never reads a clock.
687    pub fn next_wait(&self) -> Option<core::time::Duration> {
688        match self.pending_actions.front() {
689            Some(Action::WaitMs(ms)) => Some(core::time::Duration::from_millis(*ms)),
690            _ => None,
691        }
692    }
693
694    /// The absolute deadline of the queued `WaitMs`, for callers that schedule
695    /// on `std::time::Instant`: `None` unless a wait is at the front of the
696    /// queue (see [`Self::next_wait`]).
697    ///
698    /// The deadline is anchored the first time this is called for a given wait
699    /// (`now` + the wait) and every later call returns that SAME instant, until
700    /// the wait is drained with [`Self::poll`]. Re-querying after unrelated
701    /// wake-ups therefore never re-arms the timer. The core has no clock of its
702    /// own, so "when the wait was scheduled" is the caller's `now` at the first
703    /// query: query as soon as the wait reaches the front.
704    #[cfg(feature = "std")]
705    pub fn poll_timeout(&mut self, now: std::time::Instant) -> Option<std::time::Instant> {
706        let wait = self.next_wait()?;
707        let anchor = *self.wait_anchor.get_or_insert(now);
708        Some(anchor + wait)
709    }
710
711    /// Drain the next [`Output`] event, if any.
712    pub fn next_output(&mut self) -> Option<Output> {
713        self.pending_outputs.pop_front()
714    }
715
716    /// Feed a freshly fetched Media Playlist response.
717    ///
718    /// # Errors
719    /// [`Error::PlaylistNotUtf8`] / [`Error::PlaylistParse`] on malformed
720    /// input.
721    pub fn on_playlist(&mut self, bytes: &[u8]) -> Result<()> {
722        let text = core::str::from_utf8(bytes)?;
723        if text.contains("#EXT-X-STREAM-INF") {
724            let master = MasterPlaylist::parse(text)?;
725            let base = self.base().to_string();
726            let mut variants: Vec<VariantInfo> = Vec::new();
727            for v in &master.variants {
728                let Some(resolved) = self.fetch_url(&base, &v.uri) else {
729                    continue;
730                };
731                variants.push(VariantInfo {
732                    url: resolved,
733                    bandwidth: v.bandwidth,
734                    average_bandwidth: v.average_bandwidth,
735                    resolution: v.resolution,
736                    codecs: v.codecs.clone(),
737                    audio_group: v.audio.clone(),
738                    subtitles_group: v.subtitles.clone(),
739                });
740            }
741            let mut renditions: Vec<RenditionInfo> = Vec::new();
742            for m in &master.media {
743                renditions.push(RenditionInfo {
744                    media_type: m.media_type,
745                    group_id: m.group_id.clone(),
746                    name: m.name.clone(),
747                    language: m.language.clone(),
748                    default: m.default,
749                    autoselect: m.autoselect,
750                    url: match m.uri.as_deref() {
751                        Some(u) => self.fetch_url(&base, u),
752                        None => None,
753                    },
754                });
755            }
756            let chosen = choose_variant(&variants, &self.variant_policy);
757            let selection = MasterSelection {
758                variants,
759                chosen,
760                renditions,
761            };
762            self.pending_outputs
763                .push_back(Output::Master(selection.clone()));
764            self.master = Some(selection);
765            let Some(chosen_url) = self
766                .master
767                .as_ref()
768                .and_then(|m| m.variants.get(chosen))
769                .map(|v| v.url.clone())
770            else {
771                return Err(Error::NoSuchVariant { index: chosen });
772            };
773            self.playlist_url = chosen_url.clone();
774            self.base_override = None;
775            self.pending_actions.clear();
776            self.pending_actions.push_back(Action::FetchPlaylist {
777                url: chosen_url,
778                blocking: None,
779                skip: false,
780            });
781            return Ok(());
782        }
783        let playlist = MediaPlaylist::parse(text)?;
784        let playlist = self.merge_delta(playlist);
785        if playlist.skip.is_some() {
786            return Err(self.request_full_playlist(&playlist));
787        }
788        self.full_reload_requested = false;
789        let segment_keys = playlist.segment_key_results()?;
790        if let Some(Err(e)) = segment_keys.first()
791            && segment_keys.iter().all(|r| r.is_err())
792        {
793            return Err(Error::PlaylistParse(e.clone()));
794        }
795        let open_key = if playlist.open_segment.is_some() {
796            playlist.open_segment_key()?
797        } else {
798            None
799        };
800        let mut first_error: Option<Error> = None;
801        // An older view of the same stream must never make the client deliver
802        // backwards: its segments below the previous window are skipped.
803        let mut lag_floor: Option<u64> = None;
804        if self.last_media_sequence.is_some() {
805            match self.continuity(&playlist) {
806                Continuity::Restart => self.restart_after_regression()?,
807                Continuity::OlderWindow => {
808                    lag_floor = self.last_full_playlist.as_ref().map(|p| p.media_sequence);
809                }
810                Continuity::Lagging | Continuity::Continues => {}
811            }
812        }
813        // A lagging copy never lowers the high-water mark.
814        self.last_media_sequence = Some(
815            self.last_media_sequence
816                .map_or(playlist.media_sequence, |p| p.max(playlist.media_sequence)),
817        );
818        self.forget_keys_below(playlist.media_sequence);
819
820        // `EXT-X-MEDIA-SEQUENCE` is an unbounded remote `u64`: every MSN
821        // derived below is `media_sequence + index` with `index <
822        // segments.len()`, so proving the end fits proves them all.
823        let next_msn = playlist
824            .media_sequence
825            .checked_add(index_u64(playlist.segments.len()))
826            .ok_or(Error::MediaSequenceOverflow {
827                media_sequence: playlist.media_sequence,
828                segments: playlist.segments.len(),
829            })?;
830
831        if !self.joined {
832            self.joined = true;
833            self.join_start(&playlist)?;
834        }
835
836        self.playlist_maps = self.map_entries(&playlist, text, next_msn);
837
838        for (i, seg) in playlist.segments.iter().enumerate() {
839            let msn = playlist.media_sequence.saturating_add(index_u64(i));
840            if lag_floor.is_some_and(|floor| msn < floor) {
841                self.skip_segment(msn, seg)?;
842                continue;
843            }
844            if let Some(e) = seg.map.as_ref().and_then(|m| self.unusable_map(m)) {
845                self.skip_segment(msn, seg)?;
846                first_error.get_or_insert(e);
847                continue;
848            }
849            let key = match segment_keys.get(i) {
850                Some(Ok(key)) => key.as_ref(),
851                Some(Err(e)) => {
852                    self.skip_segment(msn, seg)?;
853                    first_error.get_or_insert(Error::PlaylistParse(e.clone()));
854                    continue;
855                }
856                None => None,
857            };
858            match self.process_closed_segment(msn, seg, key, playlist.iframes_only) {
859                Ok(()) => {}
860                Err(e @ Error::UnsupportedEncryption { .. }) => {
861                    self.skip_segment(msn, seg)?;
862                    first_error.get_or_insert(e);
863                }
864                Err(e) => return Err(e),
865            }
866        }
867
868        if let Some(open) = &playlist.open_segment {
869            if let Some(e) = open.map.as_ref().and_then(|m| self.unusable_map(m)) {
870                first_error.get_or_insert(e);
871            } else {
872                match &open_key {
873                    Some(key) => {
874                        first_error.get_or_insert(Error::UnsupportedEncryption {
875                            method: key.method,
876                            what: "encrypted partial segments",
877                        });
878                    }
879                    None => self.process_open_segment(next_msn, open)?,
880                }
881            }
882        }
883
884        self.pump_init_fetch();
885
886        if let Some(ll) = &playlist.low_latency
887            && let Some(hint_uri) = &ll.preload_hint_part
888        {
889            match ll.preload_hint_type {
890                PreloadHintType::Part => {
891                    let part_idx = playlist
892                        .open_segment
893                        .as_ref()
894                        .map(|o| index_u64(o.parts.len()))
895                        .unwrap_or(0);
896                    let id = ResourceId::Part {
897                        msn: next_msn,
898                        part: part_idx,
899                    };
900                    if !self.requested.contains(&id)
901                        && !self.delivered_parts.contains(&(next_msn, part_idx))
902                    {
903                        let url = url::resolve(self.base(), hint_uri);
904                        let byte_range = self.resolve_hint_byte_range(&url, ll)?;
905                        self.request_resource(id, url, byte_range);
906                    }
907                }
908                PreloadHintType::Map => {
909                    let map = MapTag {
910                        uri: hint_uri.clone(),
911                        byte_range: ll.preload_hint_byte_range_length.map(|length| ByteRange {
912                            length,
913                            offset: ll.preload_hint_byte_range_start,
914                        }),
915                        extra_attrs: Vec::new(),
916                    };
917                    self.ensure_init_requested(&map)?;
918                }
919                _ => {
920                    // RFC 8216bis §4.4.5.3 defines only PART/MAP today; a
921                    // future hint type from a newer transmux is simply not
922                    // prefetched rather than treated as an error
923                    // (`PreloadHintType` is `#[non_exhaustive]`).
924                }
925            }
926        }
927
928        if playlist.endlist {
929            self.saw_endlist = true;
930        } else {
931            // Issue #717 slice 1 fix: block only when the origin actually
932            // advertises `CAN-BLOCK-RELOAD=YES` — `low_latency.is_some()`
933            // alone is not enough (an origin sending `CAN-BLOCK-RELOAD=NO`
934            // still carries parts/PART-INF, e.g. while ramping up support).
935            let blocking = playlist
936                .low_latency
937                .as_ref()
938                .filter(|ll| ll.can_block_reload)
939                .map(|_| {
940                    let part = playlist
941                        .open_segment
942                        .as_ref()
943                        .map(|o| index_u64(o.parts.len()))
944                        .unwrap_or(0);
945                    BlockingReload {
946                        msn: next_msn,
947                        part: Some(part),
948                    }
949                });
950            let can_skip = playlist
951                .low_latency
952                .as_ref()
953                .and_then(|ll| ll.can_skip_until)
954                .is_some();
955            let skip = can_skip && self.last_full_playlist.is_some();
956            self.pending_actions.push_back(Action::FetchPlaylist {
957                url: self.playlist_url.clone(),
958                blocking,
959                skip,
960            });
961            if blocking.is_none() {
962                self.pending_actions
963                    .push_back(Action::WaitMs(reload_wait_ms(playlist.target_duration)));
964            }
965        }
966
967        self.prune(&playlist);
968        self.last_full_playlist = Some(playlist);
969
970        self.maybe_emit_end_of_stream()?;
971        match first_error {
972            Some(e) => Err(e),
973            None => Ok(()),
974        }
975    }
976
977    /// Queue a reload that does not ask for a delta, because the delta just
978    /// received cannot be merged into a full view (no baseline, or it no
979    /// longer covers the skipped range). Returns the error to report.
980    fn request_full_playlist(&mut self, delta: &MediaPlaylist) -> Error {
981        if self.full_reload_requested {
982            self.pending_actions
983                .push_back(Action::WaitMs(reload_wait_ms(delta.target_duration)));
984        }
985        self.full_reload_requested = true;
986        self.pending_actions.push_back(Action::FetchPlaylist {
987            url: self.playlist_url.clone(),
988            blocking: None,
989            skip: false,
990        });
991        Error::DeltaNotMergeable {
992            media_sequence: delta.media_sequence,
993            skipped_segments: delta.skip.as_ref().map_or(0, |s| s.skipped_segments),
994        }
995    }
996
997    /// Queue a plain playlist reload unless one is already queued. For a
998    /// driver that has just dropped a response the core rejected before it
999    /// could schedule the next reload itself.
1000    pub fn ensure_playlist_reload(&mut self) -> bool {
1001        if self
1002            .pending_actions
1003            .iter()
1004            .any(|a| matches!(a, Action::FetchPlaylist { .. }))
1005        {
1006            return false;
1007        }
1008        self.pending_actions.push_back(Action::FetchPlaylist {
1009            url: self.playlist_url.clone(),
1010            blocking: None,
1011            skip: false,
1012        });
1013        true
1014    }
1015
1016    /// Request a Media Initialization Section again after a failed fetch
1017    /// ([`Self::on_error`] with [`ResourceId::Init`]). A live playlist's next
1018    /// reload does this by itself; a VOD playlist (`EXT-X-ENDLIST`) has no
1019    /// further reload, so its driver calls this. `false` when there is
1020    /// nothing to retry (an init is in flight, or none failed).
1021    pub fn retry_init(&mut self) -> bool {
1022        if self
1023            .inits
1024            .values()
1025            .any(|slot| slot.fetch == InitFetch::InFlight)
1026        {
1027            return false;
1028        }
1029        let failed: Vec<InitId> = self
1030            .inits
1031            .iter()
1032            .filter(|(_, slot)| slot.fetch == InitFetch::Failed)
1033            .map(|(id, _)| *id)
1034            .collect();
1035        for id in &failed {
1036            if let Some(slot) = self.inits.get_mut(id) {
1037                slot.fetch = InitFetch::Wanted;
1038            }
1039        }
1040        self.pump_init_fetch();
1041        !failed.is_empty()
1042    }
1043
1044    /// Request a Part/Segment again after a failed fetch ([`Self::on_error`]
1045    /// or an [`Error`] from [`Self::on_resource`]) without a playlist reload:
1046    /// a VOD playlist (`EXT-X-ENDLIST`) has no further reload to do it. `false`
1047    /// when there is nothing to retry (the fetch is in flight, the resource
1048    /// was accepted, or it left the window).
1049    pub fn retry_resource(&mut self, id: ResourceId) -> bool {
1050        if self.requested.contains(&id) {
1051            return false;
1052        }
1053        let Some((url, byte_range)) = self.resource_requests.get(&id).cloned() else {
1054            return false;
1055        };
1056        self.request_resource(id, url, byte_range);
1057        true
1058    }
1059
1060    /// Give up on a Part/Segment that cannot be fetched and carry on with the
1061    /// next one: it is never requested again, the timeline restarts (held-back
1062    /// final access units of the old one are flushed first) and
1063    /// [`Output::Discontinuity`] follows, so the consumer re-syncs across the
1064    /// gap. No-op for [`ResourceId::Init`], which cannot be skipped.
1065    pub fn skip_resource(&mut self, id: ResourceId) {
1066        match id {
1067            ResourceId::Init => return,
1068            ResourceId::Segment { msn } => {
1069                self.delivered_segments.insert(msn);
1070            }
1071            ResourceId::Part { msn, part } => {
1072                self.delivered_parts.insert((msn, part));
1073            }
1074        }
1075        self.requested.remove(&id);
1076        self.resource_requests.remove(&id);
1077        // Flushing can only fail to rebuild a track init; the gap is reported either way.
1078        let _ = self.restart_ts_demux(id);
1079        self.pending_outputs.push_back(Output::Discontinuity);
1080        let _ = self.maybe_emit_end_of_stream();
1081    }
1082
1083    /// The `EXT-X-MAP`s of `playlist`, in playlist order, each with the key
1084    /// that encrypts it and the Media Sequence Number it first applies to.
1085    fn map_entries(&self, playlist: &MediaPlaylist, text: &str, next_msn: u64) -> Vec<MapEntry> {
1086        let keyed = keys_at_maps(text);
1087        let mut entries: Vec<MapEntry> = Vec::new();
1088        let listed = playlist
1089            .segments
1090            .iter()
1091            .enumerate()
1092            .filter_map(|(i, s)| {
1093                s.map
1094                    .as_ref()
1095                    .map(|m| (m, playlist.media_sequence.saturating_add(index_u64(i))))
1096            })
1097            .chain(
1098                playlist
1099                    .open_segment
1100                    .as_ref()
1101                    .and_then(|o| o.map.as_ref())
1102                    .map(|m| (m, next_msn)),
1103            );
1104        for (map, msn) in listed {
1105            if entries.iter().any(|e| e.map == *map) {
1106                continue;
1107            }
1108            let key = match keyed.iter().rev().find(|(m, _)| m == map) {
1109                Some((_, key)) => key.clone(),
1110                None => self
1111                    .resolved_url(self.base(), &map.uri)
1112                    .ok()
1113                    .and_then(|url| self.init_slot_for(&url, &map.byte_range))
1114                    .and_then(|id| self.inits.get(&id))
1115                    .and_then(|slot| slot.encryption.clone()),
1116            };
1117            entries.push(MapEntry {
1118                map: map.clone(),
1119                encryption: key,
1120                first_msn: msn,
1121            });
1122        }
1123        entries
1124    }
1125
1126    fn unusable_map(&self, map: &MapTag) -> Option<Error> {
1127        self.playlist_maps
1128            .iter()
1129            .find(|e| e.map == *map)
1130            .and_then(MapEntry::unusable)
1131    }
1132
1133    /// The init slot holding the resource at `url` (resolved) with `byte_range`.
1134    /// The query is not part of the identity: a CDN that re-signs the map URI
1135    /// on every reload (as it does segment URIs, see [`without_query`]) still
1136    /// names the same init.
1137    fn init_slot_for(&self, url: &str, byte_range: &Option<ByteRange>) -> Option<InitId> {
1138        self.inits
1139            .iter()
1140            .find(|(_, slot)| {
1141                without_query(&slot.url) == without_query(url) && slot.map.byte_range == *byte_range
1142            })
1143            .map(|(id, _)| *id)
1144    }
1145
1146    /// The init slot for `map`, created (and queued for fetching) when the
1147    /// client has none for that resource yet; a slot whose fetch failed is
1148    /// queued again.
1149    fn init_for_map(&mut self, map: &MapTag) -> Result<InitId> {
1150        let url = self.resolved_url(self.base(), &map.uri)?;
1151        if let Some(id) = self.init_slot_for(&url, &map.byte_range)
1152            && let Some(slot) = self.inits.get_mut(&id)
1153        {
1154            if slot.url != url {
1155                slot.url = url;
1156                slot.map = map.clone();
1157            }
1158            if slot.fetch == InitFetch::Failed {
1159                slot.fetch = InitFetch::Wanted;
1160            }
1161            return Ok(id);
1162        }
1163        let (key, first_msn) = self
1164            .playlist_maps
1165            .iter()
1166            .find(|e| e.map == *map)
1167            .map_or((None, 0), |e| (e.encryption.clone(), e.first_msn));
1168        let byte_range = self.resolve_byte_range(&url, &map.byte_range)?;
1169        let crypto = match key.as_ref().and_then(|k| k.uri.as_deref().map(|u| (k, u))) {
1170            Some((key, uri)) => {
1171                let key_id = self.ensure_key_requested(uri)?;
1172                let iv = key.iv.unwrap_or_else(|| media_sequence_iv(first_msn));
1173                Some(SegmentCrypto { key_id, iv })
1174            }
1175            None => None,
1176        };
1177        let id = self.next_init_id;
1178        self.next_init_id = self.next_init_id.saturating_add(1);
1179        self.inits.insert(
1180            id,
1181            InitSlot {
1182                map: map.clone(),
1183                url,
1184                byte_range,
1185                encryption: key,
1186                crypto,
1187                fetch: InitFetch::Wanted,
1188                cipher: None,
1189                bytes: None,
1190                skipped_reported: BTreeSet::new(),
1191            },
1192        );
1193        Ok(id)
1194    }
1195
1196    /// Start the next wanted init fetch unless one is already in flight:
1197    /// [`ResourceId::Init`] names the one in flight, so inits go one at a time,
1198    /// in the order the playlist needs them.
1199    fn pump_init_fetch(&mut self) {
1200        if self
1201            .inits
1202            .values()
1203            .any(|slot| slot.fetch == InitFetch::InFlight)
1204        {
1205            return;
1206        }
1207        let Some((id, slot)) = self
1208            .inits
1209            .iter_mut()
1210            .find(|(_, slot)| slot.fetch == InitFetch::Wanted)
1211        else {
1212            return;
1213        };
1214        slot.fetch = InitFetch::InFlight;
1215        let id = *id;
1216        let fetch = Action::FetchResource {
1217            id: ResourceId::Init,
1218            url: slot.url.clone(),
1219            byte_range: slot.byte_range,
1220        };
1221        self.init_target = Some(id);
1222        self.pending_actions.push_back(fetch);
1223        self.outstanding_fetches += 1;
1224    }
1225
1226    /// Bind Media Sequence Number `msn` to the init of `map`, the one that
1227    /// applies to it (RFC 8216bis §4.4.4.5), creating that init on first use.
1228    fn bind_init(&mut self, msn: u64, map: Option<&MapTag>) -> Result<()> {
1229        if let Some(map) = map {
1230            let id = self.init_for_map(map)?;
1231            self.msn_init.insert(msn, id);
1232        }
1233        Ok(())
1234    }
1235
1236    /// The init slot a Part/Segment resource is demuxed with: the one its
1237    /// Media Sequence Number is bound to, else the newest.
1238    fn init_of(&self, id: &ResourceId) -> Option<InitId> {
1239        resource_msn(id)
1240            .and_then(|msn| self.msn_init.get(&msn).copied())
1241            .or_else(|| self.inits.keys().next_back().copied())
1242    }
1243
1244    fn init_ready(&self, slot: Option<InitId>) -> bool {
1245        slot.and_then(|id| self.inits.get(&id))
1246            .is_some_and(|slot| slot.bytes.is_some())
1247    }
1248
1249    /// Feed the bytes fetched for a previously requested [`ResourceId`]
1250    /// (`init`/part/segment). Part/Segment resources delivered before the
1251    /// init segment are buffered internally and demuxed once the init
1252    /// arrives — the caller's fetches may complete in any order.
1253    ///
1254    /// # Errors
1255    /// [`Error::UnrequestedResource`] if `id` was never requested (the
1256    /// `requested` bookkeeping — or, for `Init`, `init_uri` — has no record
1257    /// of it): a caller/driver bug, or a stale/duplicate delivery after the
1258    /// client already moved past this id.
1259    /// [`Error::Demux`] if `transmux::Fmp4Demux` rejects the concatenation of
1260    /// the cached init + `bytes`.
1261    pub fn on_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
1262        let was_requested = match id {
1263            ResourceId::Init => self.init_target.is_some(),
1264            ResourceId::Part { .. } | ResourceId::Segment { .. } => self.requested.contains(&id),
1265        };
1266        if !was_requested {
1267            return Err(Error::UnrequestedResource { id });
1268        }
1269        let duplicate = match id {
1270            ResourceId::Init => !self
1271                .init_target
1272                .and_then(|t| self.inits.get(&t))
1273                .is_some_and(|slot| slot.fetch == InitFetch::InFlight),
1274            ResourceId::Part { .. } | ResourceId::Segment { .. } => self.fulfilled.contains(&id),
1275        };
1276        if duplicate {
1277            return Err(Error::DuplicateResource { id });
1278        }
1279        self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
1280        if id != ResourceId::Init {
1281            self.fulfilled.insert(id);
1282        }
1283        match id {
1284            ResourceId::Init => self.accept_init(bytes)?,
1285            ResourceId::Part { .. } | ResourceId::Segment { .. } => {
1286                let processed = self.accept_media_resource(id, bytes);
1287                match processed {
1288                    Ok(()) => {}
1289                    Err(e @ Error::PendingKeyedOverflow { .. }) => return Err(e),
1290                    Err(e) => {
1291                        self.fulfilled.remove(&id);
1292                        self.requested.remove(&id);
1293                        return Err(e);
1294                    }
1295                }
1296            }
1297        }
1298        self.maybe_emit_end_of_stream()?;
1299        Ok(())
1300    }
1301
1302    fn accept_init(&mut self, bytes: &[u8]) -> Result<()> {
1303        let Some(target) = self.init_target else {
1304            return Ok(());
1305        };
1306        let Some(slot) = self.inits.get_mut(&target) else {
1307            return Ok(());
1308        };
1309        if slot.crypto.is_none() && !has_movie_box(bytes) {
1310            slot.fetch = InitFetch::Failed;
1311            return Err(Error::InitUndecodable);
1312        }
1313        match slot.crypto {
1314            Some(crypto) => match self.key_material.get(&crypto.key_id).copied() {
1315                Some(KeyBytes(key)) => self.complete_encrypted_init(target, crypto, &key, bytes),
1316                None => {
1317                    slot.cipher = Some(bytes.to_vec());
1318                    slot.fetch = InitFetch::Arrived;
1319                    Ok(())
1320                }
1321            },
1322            None => self.complete_init(target, bytes),
1323        }
1324    }
1325
1326    /// Take `plain` as the Media Initialization Section of `target`: emit it
1327    /// when it is the first the consumer sees, and replay the media that
1328    /// arrived before it.
1329    fn complete_init(&mut self, target: InitId, plain: &[u8]) -> Result<()> {
1330        if let Some(slot) = self.inits.get_mut(&target) {
1331            slot.bytes = Some(plain.to_vec());
1332            slot.cipher = None;
1333            slot.fetch = InitFetch::Ready;
1334        }
1335        if self.announced_init.is_none() {
1336            self.announce_init(target);
1337        }
1338        self.pump_init_fetch();
1339        let buffered: Vec<_> = self.pending_demux.drain(..).collect();
1340        let mut waiting = VecDeque::new();
1341        let mut failure = None;
1342        for (bid, bbytes) in buffered {
1343            if failure.is_some() || !self.init_ready(self.init_of(&bid)) {
1344                waiting.push_back((bid, bbytes));
1345                continue;
1346            }
1347            if let Err(e) = self.finish_media_resource(bid, &bbytes) {
1348                self.fulfilled.remove(&bid);
1349                self.requested.remove(&bid);
1350                failure = Some(e);
1351            }
1352        }
1353        self.pending_demux = waiting;
1354        match failure {
1355            Some(e) => Err(e),
1356            None => Ok(()),
1357        }
1358    }
1359
1360    /// Make `slot` the init the consumer sees: its [`Output::Init`] goes out
1361    /// unless it was the last one announced.
1362    fn announce_init(&mut self, slot: InitId) {
1363        if self.announced_init == Some(slot) {
1364            return;
1365        }
1366        let Some(bytes) = self.inits.get(&slot).and_then(|s| s.bytes.clone()) else {
1367            return;
1368        };
1369        self.pending_outputs.push_back(Output::Init(bytes));
1370        self.announced_init = Some(slot);
1371        self.last_fmp4_specs = None;
1372    }
1373
1374    fn complete_encrypted_init(
1375        &mut self,
1376        target: InitId,
1377        crypto: SegmentCrypto,
1378        key: &[u8; KEY_LEN],
1379        cipher: &[u8],
1380    ) -> Result<()> {
1381        match decrypt_segment(ResourceId::Init, crypto, key, cipher) {
1382            Ok(plain) if !has_movie_box(&plain) => {
1383                if let Some(slot) = self.inits.get_mut(&target) {
1384                    slot.fetch = InitFetch::Failed;
1385                }
1386                Err(Error::InitUndecodable)
1387            }
1388            Ok(plain) => self.complete_init(target, &plain),
1389            Err(e) => {
1390                if let Some(slot) = self.inits.get_mut(&target) {
1391                    slot.fetch = InitFetch::Failed;
1392                }
1393                Err(e)
1394            }
1395        }
1396    }
1397
1398    /// Route a delivered Part/Segment: decrypt it first when its segment is
1399    /// AES-128 encrypted (holding the ciphertext until the key arrives).
1400    fn accept_media_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
1401        if let ResourceId::Segment { msn } = id
1402            && let Some(crypto) = self.segment_crypto.get(&msn).copied()
1403        {
1404            return match self.key_material.get(&crypto.key_id).copied() {
1405                Some(KeyBytes(key)) => {
1406                    let plain = decrypt_segment(id, crypto, &key, bytes)?;
1407                    self.route_media_resource(id, &plain)
1408                }
1409                None => self.buffer_until_keyed(id, bytes),
1410            };
1411        }
1412        self.route_media_resource(id, bytes)
1413    }
1414
1415    /// Hold ciphertext until its key arrives, within
1416    /// [`MAX_PENDING_KEYED_BYTES`]: the oldest waiting segments are dropped
1417    /// (and become requestable again) to make room, reported as
1418    /// [`Error::PendingKeyedOverflow`].
1419    fn buffer_until_keyed(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
1420        if bytes.len() > MAX_PENDING_KEYED_BYTES {
1421            self.fulfilled.remove(&id);
1422            self.requested.remove(&id);
1423            return Err(Error::PendingKeyedOverflow { dropped: id });
1424        }
1425        let mut held: usize = self.pending_keyed.iter().map(|(_, b)| b.len()).sum();
1426        let mut dropped: Option<ResourceId> = None;
1427        while held.saturating_add(bytes.len()) > MAX_PENDING_KEYED_BYTES
1428            && !self.pending_keyed.is_empty()
1429        {
1430            let (old_id, old) = self.pending_keyed.remove(0);
1431            held = held.saturating_sub(old.len());
1432            self.fulfilled.remove(&old_id);
1433            self.requested.remove(&old_id);
1434            dropped.get_or_insert(old_id);
1435        }
1436        self.pending_keyed.push((id, bytes.to_vec()));
1437        match dropped {
1438            Some(dropped) => Err(Error::PendingKeyedOverflow { dropped }),
1439            None => Ok(()),
1440        }
1441    }
1442
1443    fn route_media_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
1444        let recognised = if self.is_ts_segment(bytes) {
1445            segment::ts_structure(bytes)
1446        } else {
1447            segment::media_fragment(bytes) || segment::progressive_movie(bytes)
1448        };
1449        if !recognised {
1450            return Err(Error::NotMedia { id });
1451        }
1452        if self.is_ts_segment(bytes) {
1453            // Classic MPEG-TS-segment HLS (issue #760): no init
1454            // resource will ever arrive for this playlist, so demux
1455            // this self-contained TS segment straight away rather
1456            // than buffering it forever waiting for one.
1457            self.finish_ts_resource(id, bytes)
1458        } else if !self.init_ready(self.init_of(&id)) {
1459            self.pending_demux.push_back((id, bytes.to_vec()));
1460            Ok(())
1461        } else {
1462            self.finish_media_resource(id, bytes)
1463        }
1464    }
1465
1466    /// Feed the key file fetched for an [`Action::FetchKey`]. Encrypted
1467    /// segments that arrived earlier are decrypted and demuxed in Media
1468    /// Sequence Number order.
1469    ///
1470    /// # Errors
1471    /// [`Error::UnrequestedKey`] for an unknown id or a key already held;
1472    /// [`Error::BadKeyLength`] unless `bytes` is exactly 16 octets;
1473    /// [`Error::DecryptFailed`] if a waiting segment does not decrypt.
1474    pub fn on_key(&mut self, id: KeyId, bytes: &[u8]) -> Result<()> {
1475        if !self.key_urls.contains_key(&id) || self.key_material.contains_key(&id) {
1476            return Err(Error::UnrequestedKey { id });
1477        }
1478        if self.keys_in_flight.remove(&id) {
1479            self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
1480        }
1481        let key: [u8; KEY_LEN] = bytes.try_into().map_err(|_| Error::BadKeyLength {
1482            id,
1483            len: bytes.len(),
1484        })?;
1485        self.key_material.insert(id, KeyBytes(key));
1486        let waiting_inits: Vec<(InitId, SegmentCrypto)> = self
1487            .inits
1488            .iter()
1489            .filter(|(_, slot)| slot.fetch == InitFetch::Arrived)
1490            .filter_map(|(init, slot)| slot.crypto.filter(|c| c.key_id == id).map(|c| (*init, c)))
1491            .collect();
1492        for (init, crypto) in waiting_inits {
1493            let cipher = self
1494                .inits
1495                .get_mut(&init)
1496                .and_then(|slot| slot.cipher.take());
1497            if let Some(cipher) = cipher {
1498                self.complete_encrypted_init(init, crypto, &key, &cipher)?;
1499            }
1500        }
1501        let (mut ready, rest): (Vec<_>, Vec<_>) = core::mem::take(&mut self.pending_keyed)
1502            .into_iter()
1503            .partition(|(rid, _)| {
1504                resource_msn(rid)
1505                    .and_then(|msn| self.segment_crypto.get(&msn))
1506                    .is_some_and(|c| c.key_id == id)
1507            });
1508        self.pending_keyed = rest;
1509        ready.sort_by_key(|(rid, _)| resource_msn(rid));
1510        let mut ready = ready.into_iter();
1511        while let Some((rid, cipher)) = ready.next() {
1512            let crypto = resource_msn(&rid).and_then(|msn| self.segment_crypto.get(&msn).copied());
1513            let result = match crypto {
1514                Some(crypto) => decrypt_segment(rid, crypto, &key, &cipher)
1515                    .and_then(|plain| self.route_media_resource(rid, &plain)),
1516                None => Ok(()),
1517            };
1518            if let Err(e) = result {
1519                self.fulfilled.remove(&rid);
1520                self.requested.remove(&rid);
1521                self.pending_keyed.extend(ready);
1522                return Err(e);
1523            }
1524        }
1525        self.maybe_emit_end_of_stream()?;
1526        Ok(())
1527    }
1528
1529    /// Report that fetching key `id` failed. The next [`Self::on_playlist`]
1530    /// requests it again; segments already waiting on it keep waiting.
1531    pub fn on_key_error(&mut self, id: KeyId) {
1532        if self.keys_in_flight.remove(&id) {
1533            self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
1534        }
1535    }
1536
1537    /// Request key `uri` once per distinct URI; the id is stable for the life
1538    /// of the client.
1539    fn ensure_key_requested(&mut self, uri: &str) -> Result<KeyId> {
1540        let url = self.resolved_url(self.base(), uri)?;
1541        let id = match self.key_ids.get(&url) {
1542            Some(id) => *id,
1543            None => {
1544                let id = KeyId(self.next_key_id);
1545                self.next_key_id = self.next_key_id.saturating_add(1);
1546                self.key_ids.insert(url.clone(), id);
1547                self.key_urls.insert(id, url);
1548                id
1549            }
1550        };
1551        self.request_key(id);
1552        Ok(id)
1553    }
1554
1555    /// Queue the fetch of key `id` unless it is held or already in flight.
1556    fn request_key(&mut self, id: KeyId) {
1557        let Some(url) = self.key_urls.get(&id).cloned() else {
1558            return;
1559        };
1560        if !self.key_material.contains_key(&id) && self.keys_in_flight.insert(id) {
1561            self.outstanding_fetches += 1;
1562            self.pending_actions.push_back(Action::FetchKey { id, url });
1563        }
1564    }
1565
1566    /// Drop every key no segment of the window (nor the init) refers to, its
1567    /// in-flight fetch included: the per-URL maps would otherwise grow
1568    /// with every tokenised key URL, and a key behind a URI that returns
1569    /// after its segments left the window is fetched afresh.
1570    fn forget_keys_below(&mut self, first_msn: u64) {
1571        self.segment_crypto.retain(|&m, _| m >= first_msn);
1572        let mut live: BTreeSet<KeyId> = self.segment_crypto.values().map(|c| c.key_id).collect();
1573        live.extend(
1574            self.inits
1575                .values()
1576                .filter_map(|slot| slot.crypto.map(|c| c.key_id)),
1577        );
1578        let abandoned: Vec<KeyId> = self
1579            .keys_in_flight
1580            .iter()
1581            .copied()
1582            .filter(|id| !live.contains(id))
1583            .collect();
1584        for id in abandoned {
1585            self.keys_in_flight.remove(&id);
1586            self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
1587            self.pending_actions
1588                .retain(|a| !matches!(a, Action::FetchKey { id: queued, .. } if *queued == id));
1589        }
1590        self.key_ids.retain(|_, id| live.contains(id));
1591        self.key_urls.retain(|id, _| live.contains(id));
1592        self.key_material.retain(|id, _| live.contains(id));
1593    }
1594
1595    /// Record the decryption parameters of encrypted segment `msn` and make
1596    /// sure its key is requested.
1597    fn prepare_encrypted_segment(
1598        &mut self,
1599        msn: u64,
1600        seg: &MediaSegment,
1601        key: &MediaKey,
1602        iframes_only: bool,
1603    ) -> Result<()> {
1604        let unsupported = |what| Error::UnsupportedEncryption {
1605            method: key.method,
1606            what,
1607        };
1608        if key.method != EncryptionMethod::Aes128 {
1609            return Err(unsupported("only METHOD=AES-128 is decrypted"));
1610        }
1611        if iframes_only {
1612            return Err(unsupported("EXT-X-I-FRAMES-ONLY with encryption"));
1613        }
1614        if !seg.parts.is_empty() {
1615            return Err(unsupported("encrypted partial segments"));
1616        }
1617        let Some(uri) = key.uri.as_deref() else {
1618            return Err(unsupported("key without URI"));
1619        };
1620        if let Some(existing) = self.segment_crypto.get(&msn).copied() {
1621            self.request_key(existing.key_id);
1622            return Ok(());
1623        }
1624        let id = self.ensure_key_requested(uri)?;
1625        let iv = key.iv.unwrap_or_else(|| media_sequence_iv(msn));
1626        self.segment_crypto
1627            .insert(msn, SegmentCrypto { key_id: id, iv });
1628        Ok(())
1629    }
1630
1631    /// Demux + emit + mark-delivered for a Part/Segment resource, once the
1632    /// init segment is known to be available.
1633    fn finish_media_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
1634        match id {
1635            ResourceId::Part { msn, part } => {
1636                self.emit_discontinuity_if_needed(msn);
1637                self.demux_and_emit(id, bytes)?;
1638                self.delivered_parts.insert((msn, part));
1639            }
1640            ResourceId::Segment { msn } => {
1641                self.emit_discontinuity_if_needed(msn);
1642                self.demux_and_emit(id, bytes)?;
1643                self.delivered_segments.insert(msn);
1644            }
1645            ResourceId::Init => {}
1646        }
1647        Ok(())
1648    }
1649
1650    /// The classic-TS-HLS counterpart to [`Self::finish_media_resource`]:
1651    /// demux + emit + mark-delivered for a self-contained MPEG-TS Part/
1652    /// Segment resource — never buffered pending an init fetch, since
1653    /// [`Self::is_ts_segment`] only routes here once this playlist is known
1654    /// to advertise no `EXT-X-MAP` at all.
1655    fn finish_ts_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
1656        match id {
1657            ResourceId::Part { msn, part } => {
1658                self.begin_ts_resource(id, msn)?;
1659                self.demux_and_emit_ts(id, bytes)?;
1660                self.delivered_parts.insert((msn, part));
1661            }
1662            ResourceId::Segment { msn } => {
1663                self.begin_ts_resource(id, msn)?;
1664                self.demux_and_emit_ts(id, bytes)?;
1665                self.release_ts_segment_end(id)?;
1666                self.delivered_segments.insert(msn);
1667            }
1668            ResourceId::Init => {}
1669        }
1670        Ok(())
1671    }
1672
1673    /// `true` when `bytes` should be routed to [`Self::finish_ts_resource`]
1674    /// (classic MPEG-TS-segment HLS, issue #760) rather than the fMP4/CMAF
1675    /// path: this playlist has never advertised an `EXT-X-MAP` (no init
1676    /// fetch is outstanding or cached — [`Self::inits`] is empty; by the
1677    /// time any Part/Segment fetch response reaches [`Self::on_resource`],
1678    /// [`Self::on_playlist`] has already fully processed the playlist that
1679    /// requested it, including any map it carries, so this check is never
1680    /// stale) **and** `bytes` starts with the MPEG-TS sync byte — an
1681    /// fMP4/CMAF resource always starts with an ISOBMFF box
1682    /// (`ftyp`/`styp`/`moof`), never [`TS_SYNC_BYTE`] — the classic TS
1683    /// form has no `EXT-X-MAP` at all.
1684    fn is_ts_segment(&self, bytes: &[u8]) -> bool {
1685        self.inits.is_empty()
1686            && (bytes.first() == Some(&TS_SYNC_BYTE) || segment::ts_structure(bytes))
1687    }
1688
1689    /// Report that a previously requested [`ResourceId`] (or the playlist
1690    /// itself, via [`None`]) failed. Clears the id's "requested" bookkeeping
1691    /// so the next [`Self::on_playlist`] call naturally re-requests it (no
1692    /// automatic retry timer — the caller drives retry cadence).
1693    pub fn on_error(&mut self, id: Option<ResourceId>) {
1694        if let Some(id) = id {
1695            self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
1696            match id {
1697                ResourceId::Init => {
1698                    if let Some(slot) = self
1699                        .init_target
1700                        .and_then(|target| self.inits.get_mut(&target))
1701                        .filter(|slot| slot.fetch == InitFetch::InFlight)
1702                    {
1703                        slot.fetch = InitFetch::Failed;
1704                    }
1705                    self.pump_init_fetch();
1706                }
1707                other => {
1708                    self.requested.remove(&other);
1709                }
1710            }
1711        }
1712        // `on_error` has no way to report: a failure here can only mean the
1713        // held-back final TS access units could not be flushed, and the
1714        // stream still ends.
1715        let _ = self.maybe_emit_end_of_stream();
1716    }
1717
1718    // -- internals ------------------------------------------------------
1719
1720    /// Reconstruct a full playlist view from an `EXT-X-SKIP` delta update
1721    /// (RFC 8216bis §4.4.5.2), by splicing the skipped prefix back in from
1722    /// the last playlist this client processed (itself a full view, merged or
1723    /// not). A merged playlist has `skip` cleared; if there is no baseline,
1724    /// or it doesn't cover the skipped range, the delta is returned as-is
1725    /// with `skip` still set, which [`Self::on_playlist`] answers by
1726    /// requesting a full playlist.
1727    fn merge_delta(&self, playlist: MediaPlaylist) -> MediaPlaylist {
1728        let Some(skip) = &playlist.skip else {
1729            return playlist;
1730        };
1731        if skip.skipped_segments == 0 {
1732            let mut merged = playlist;
1733            merged.skip = None;
1734            return merged;
1735        }
1736        let Some(prev) = &self.last_full_playlist else {
1737            return playlist;
1738        };
1739        if playlist.media_sequence < prev.media_sequence {
1740            return playlist;
1741        }
1742        let Ok(prefix_start) = usize::try_from(playlist.media_sequence - prev.media_sequence)
1743        else {
1744            return playlist;
1745        };
1746        // `skip.skipped_segments` (`EXT-X-SKIP`'s `SKIPPED-SEGMENTS`,
1747        // RFC 8216bis §4.4.5.2) is untrusted `u64` straight from the remote
1748        // origin's playlist text, with no upper bound enforced by
1749        // `broadcast_hls::MediaPlaylist::parse`. `usize::try_from` +
1750        // `checked_add` guard both the u64->usize narrowing and the
1751        // addition itself, so an adversarial/corrupt value falls through to
1752        // the same "can't merge, return the delta as-is" fallback as every
1753        // other guard in this function rather than panicking (debug) or
1754        // wrapping to a bogus, silently-wrong slice bound (release).
1755        let prefix_end = usize::try_from(skip.skipped_segments)
1756            .ok()
1757            .and_then(|skipped| prefix_start.checked_add(skipped));
1758        let Some(prefix) = prefix_end.and_then(|end| prev.segments.get(prefix_start..end)) else {
1759            return playlist;
1760        };
1761        let mut merged = playlist;
1762        let mut segments = prefix.to_vec();
1763        if let (Some(first), Some(dropped)) =
1764            (segments.first_mut(), prev.segments.get(..prefix_start))
1765        {
1766            let mut carried = carried_key_tags(dropped);
1767            carried.append(&mut first.pre_tags);
1768            first.pre_tags = carried;
1769        }
1770        segments.extend(merged.segments);
1771        merged.segments = segments;
1772        merged.skip = None;
1773        merged
1774    }
1775
1776    fn process_closed_segment(
1777        &mut self,
1778        msn: u64,
1779        seg: &MediaSegment,
1780        key: Option<&MediaKey>,
1781        iframes_only: bool,
1782    ) -> Result<()> {
1783        if seg.discontinuous {
1784            self.discontinuous_msns.insert(msn);
1785        }
1786        if self.delivered_segments.contains(&msn) {
1787            return Ok(());
1788        }
1789        match key {
1790            Some(key) => self.prepare_encrypted_segment(msn, seg, key, iframes_only)?,
1791            None => {
1792                self.segment_crypto.remove(&msn);
1793            }
1794        }
1795        if seg.parts.is_empty() {
1796            // Either a genuinely non-LL segment (never had parts), OR an LL
1797            // segment whose parts were already fetched individually while it
1798            // was still open and whose *closed* rendering simply omits them
1799            // — RFC 8216bis does not require a closed segment to keep
1800            // listing `#EXT-X-PART` lines, and real origins commonly don't
1801            // (e.g. `multimux`'s: `MediaSegment.parts` is always empty for a
1802            // closed segment; only the still-open segment carries parts).
1803            // Detect the latter via `delivered_parts`: if any part for this
1804            // `msn` was ever delivered, every one of its non-`GAP` parts was
1805            // already requested while it was open (`process_open_segment`
1806            // requests every known part each time it's polled, so by the
1807            // time the segment closes none can have been missed) — fetching
1808            // the whole segment *as well* would demux and emit its samples a
1809            // second time. Caught by `multimux/tests/it/glass_to_glass.rs`
1810            // (issue #717 slice 5): every sample was double-delivered for
1811            // the first two segments of a real, live-paced run.
1812            if self.has_delivered_part(msn) {
1813                self.begin_ts_resource(ResourceId::Segment { msn }, msn)?;
1814                self.retry_undelivered_parts(msn);
1815                if self.undelivered_parts(msn).is_empty() {
1816                    self.delivered_segments.insert(msn);
1817                }
1818                return Ok(());
1819            }
1820            let id = ResourceId::Segment { msn };
1821            if !self.requested.contains(&id) {
1822                let Some(url) = self.segment_url(msn, &seg.uri) else {
1823                    return Ok(());
1824                };
1825                self.bind_init(msn, seg.map.as_ref())?;
1826                let byte_range = self.request_byte_range(id, &url, &seg.byte_range)?;
1827                self.request_resource(id, url, byte_range);
1828            }
1829            return Ok(());
1830        }
1831
1832        let mut fully_accounted = true;
1833        for (i, part) in seg.parts.iter().enumerate() {
1834            let i = index_u64(i);
1835            if part.gap || self.delivered_parts.contains(&(msn, i)) {
1836                continue;
1837            }
1838            fully_accounted = false;
1839            let id = ResourceId::Part { msn, part: i };
1840            if !self.requested.contains(&id) {
1841                let Some(url) = self.segment_url(msn, &part.uri) else {
1842                    return Ok(());
1843                };
1844                self.bind_init(msn, seg.map.as_ref())?;
1845                let byte_range = self.request_byte_range(id, &url, &part.byte_range)?;
1846                self.request_resource(id, url, byte_range);
1847            }
1848        }
1849        if fully_accounted {
1850            self.delivered_segments.insert(msn);
1851        }
1852        Ok(())
1853    }
1854
1855    fn undelivered_parts(&self, msn: u64) -> Vec<ResourceId> {
1856        self.resource_requests
1857            .range(
1858                ResourceId::Part { msn, part: 0 }..=ResourceId::Part {
1859                    msn,
1860                    part: u64::MAX,
1861                },
1862            )
1863            .map(|(id, _)| *id)
1864            .filter(|id| match id {
1865                ResourceId::Part { part, .. } => !self.delivered_parts.contains(&(msn, *part)),
1866                _ => false,
1867            })
1868            .collect()
1869    }
1870
1871    fn retry_undelivered_parts(&mut self, msn: u64) {
1872        for id in self.undelivered_parts(msn) {
1873            self.retry_resource(id);
1874        }
1875    }
1876
1877    fn has_delivered_part(&self, msn: u64) -> bool {
1878        self.delivered_parts
1879            .range((msn, 0)..=(msn, u64::MAX))
1880            .next()
1881            .is_some()
1882    }
1883
1884    fn covered_by_parts(&self, msn: u64) -> bool {
1885        self.has_delivered_part(msn) && self.undelivered_parts(msn).is_empty()
1886    }
1887
1888    fn process_open_segment(&mut self, msn: u64, open: &OpenSegment) -> Result<()> {
1889        for (i, part) in open.parts.iter().enumerate() {
1890            let i = index_u64(i);
1891            if part.gap || self.delivered_parts.contains(&(msn, i)) {
1892                continue;
1893            }
1894            let id = ResourceId::Part { msn, part: i };
1895            if !self.requested.contains(&id) {
1896                let Some(url) = self.segment_url(msn, &part.uri) else {
1897                    return Ok(());
1898                };
1899                self.bind_init(msn, open.map.as_ref())?;
1900                let byte_range = self.request_byte_range(id, &url, &part.byte_range)?;
1901                self.request_resource(id, url, byte_range);
1902            }
1903        }
1904        Ok(())
1905    }
1906
1907    fn ensure_init_requested(&mut self, map: &MapTag) -> Result<()> {
1908        self.init_for_map(map)?;
1909        self.pump_init_fetch();
1910        Ok(())
1911    }
1912
1913    /// The URL `uri` resolves to against `base`, or `None` (with the typed
1914    /// [`Error::UnresolvableReference`] pushed as this client's pending error)
1915    /// when the guarded resolver in `broadcast_common::url_ref` refuses it:
1916    /// a reference carrying an ASCII control character is never sanitised into
1917    /// a different URL and never requested (issue #2458).
1918    fn fetch_url(&mut self, base: &str, uri: &str) -> Option<String> {
1919        match self.resolved_url(base, uri) {
1920            Ok(url) => Some(url),
1921            Err(report) => {
1922                self.pending_errors.push_back(report);
1923                None
1924            }
1925        }
1926    }
1927
1928    /// The resolved URL, or the [`Error::UnresolvableReference`] to report for
1929    /// it — without reporting it, so a caller that fails the whole playlist
1930    /// (an unusable init) can return the error directly.
1931    fn resolved_url(&self, base: &str, uri: &str) -> core::result::Result<String, Error> {
1932        url_ref::resolve_str(base, uri).map_err(|source| Error::UnresolvableReference {
1933            uri: uri.to_string(),
1934            source,
1935        })
1936    }
1937
1938    /// The URL segment URI `uri` (with its query, which the byte-range cursor
1939    /// keeps) resolves to, or `None` (with the report queued) when the guarded
1940    /// resolver refuses it: the segment stays unrequested instead of being
1941    /// silently renamed (issue #2458).
1942    fn segment_url(&mut self, msn: u64, uri: &str) -> Option<String> {
1943        match url_ref::resolve_str(self.base(), uri) {
1944            Ok(url) => Some(url),
1945            Err(source) => {
1946                self.report_unresolvable(msn, uri, source);
1947                None
1948            }
1949        }
1950    }
1951
1952    /// Report an unresolvable reference naming segment `msn`, and drop the
1953    /// segment's decryption parameters so a key it would have fetched is not
1954    /// left stranded.
1955    fn report_unresolvable(
1956        &mut self,
1957        msn: u64,
1958        uri: &str,
1959        source: broadcast_common::url_ref::Error,
1960    ) {
1961        self.segment_crypto.remove(&msn);
1962        self.pending_errors.push_back(Error::UnresolvableReference {
1963            uri: uri.to_string(),
1964            source,
1965        });
1966    }
1967
1968    /// The errors [`Self::poll`] and [`Self::next_output`] raise on the
1969    /// caller's behalf: every playlist reference the guarded resolver refused
1970    /// (issue #2458), in playlist order. Reported once, in order, and never
1971    /// turned into a fetch.
1972    pub fn next_error(&mut self) -> Option<Error> {
1973        self.pending_errors.pop_front()
1974    }
1975
1976    fn request_resource(&mut self, id: ResourceId, url: String, byte_range: Option<(u64, u64)>) {
1977        self.requested.insert(id);
1978        self.resource_requests.insert(id, (url.clone(), byte_range));
1979        self.outstanding_fetches += 1;
1980        self.pending_actions.push_back(Action::FetchResource {
1981            id,
1982            url,
1983            byte_range,
1984        });
1985    }
1986
1987    /// The range to fetch for `id`: the one already resolved when it was first
1988    /// requested, so a re-request after a failure cannot continue from a
1989    /// cursor the first request advanced.
1990    fn request_byte_range(
1991        &mut self,
1992        id: ResourceId,
1993        url: &str,
1994        br: &Option<ByteRange>,
1995    ) -> Result<Option<(u64, u64)>> {
1996        if let Some((_, stored)) = self.resource_requests.get(&id) {
1997            return Ok(*stored);
1998        }
1999        self.resolve_byte_range(url, br)
2000    }
2001
2002    /// Resolve a `PartSpec`/`MediaSegment`/`MapTag` `BYTERANGE` into an
2003    /// absolute `(offset, length)`, honouring the "omitted offset continues
2004    /// the previous sub-range of the same resource" rule (tracked per
2005    /// resolved URL).
2006    ///
2007    /// # Errors
2008    /// [`Error::ByteRangeOverflow`] if `offset + length` (both taken
2009    /// straight from the untrusted remote playlist) overflows `u64` — see
2010    /// that variant's doc for why this is rejected rather than saturated.
2011    fn resolve_byte_range(
2012        &mut self,
2013        url: &str,
2014        br: &Option<ByteRange>,
2015    ) -> Result<Option<(u64, u64)>> {
2016        let Some(br) = br.as_ref() else {
2017            return Ok(None);
2018        };
2019        let offset = br
2020            .offset
2021            .unwrap_or_else(|| *self.byte_range_cursor.get(url).unwrap_or(&0));
2022        let next_cursor =
2023            offset
2024                .checked_add(br.length)
2025                .ok_or_else(|| Error::ByteRangeOverflow {
2026                    url: url.to_string(),
2027                    offset,
2028                    length: br.length,
2029                })?;
2030        self.byte_range_cursor.insert(url.to_string(), next_cursor);
2031        Ok(Some((offset, br.length)))
2032    }
2033
2034    /// Same overflow contract as [`Self::resolve_byte_range`] — see
2035    /// [`Error::ByteRangeOverflow`].
2036    fn resolve_hint_byte_range(
2037        &mut self,
2038        url: &str,
2039        ll: &broadcast_hls::LowLatencyConfig,
2040    ) -> Result<Option<(u64, u64)>> {
2041        let Some(length) = ll.preload_hint_byte_range_length else {
2042            return Ok(None);
2043        };
2044        let br = ByteRange {
2045            length,
2046            offset: ll.preload_hint_byte_range_start,
2047        };
2048        self.resolve_byte_range(url, &Some(br))
2049    }
2050
2051    fn demux_and_emit(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
2052        let slot = self.init_of(&id).filter(|s| self.init_ready(Some(*s)));
2053        let Some(slot) = slot else {
2054            return Err(Error::InitNotYetAvailable { id });
2055        };
2056        self.announce_init(slot);
2057        let init = self
2058            .inits
2059            .get(&slot)
2060            .and_then(|s| s.bytes.as_ref())
2061            .ok_or(Error::InitNotYetAvailable { id })?;
2062        if segment::progressive_movie(bytes) {
2063            return Err(Error::NotFragmented { id });
2064        }
2065        let mut combined = Vec::with_capacity(init.len() + bytes.len());
2066        combined.extend_from_slice(init);
2067        combined.extend_from_slice(bytes);
2068        let mut demux = Fmp4Demux::new();
2069        let media = demux
2070            .unpackage(combined.as_slice())
2071            .map_err(|source| Error::Demux { id, source })?;
2072        let damaged = media
2073            .degraded
2074            .iter()
2075            .filter(|d| matches!(d, InputDegradation::MalformedUnit { .. }))
2076            .count();
2077        if damaged > 0 {
2078            self.pending_outputs.push_back(Output::FragmentDamaged {
2079                id,
2080                skipped: damaged,
2081            });
2082        }
2083        for skipped in &media.skipped {
2084            let key = (skipped.track_id.map(TrackId::value), skipped.fourcc.clone());
2085            let first = self
2086                .inits
2087                .get_mut(&slot)
2088                .is_some_and(|s| s.skipped_reported.insert(key));
2089            if first {
2090                self.pending_outputs.push_back(Output::TrackSkipped {
2091                    track_id: skipped.track_id,
2092                    fourcc: skipped.fourcc.clone(),
2093                    reason: skipped.reason.clone(),
2094                });
2095            }
2096        }
2097        let specs: Vec<TrackSpec> = media.tracks.iter().map(|t| t.spec.clone()).collect();
2098        let specs_changed = self
2099            .last_fmp4_specs
2100            .as_ref()
2101            .map(|prev| format!("{:?}", prev) != format!("{:?}", specs))
2102            .unwrap_or(true);
2103        if specs_changed {
2104            self.pending_outputs
2105                .push_back(Output::Tracks(specs.clone()));
2106            self.last_fmp4_specs = Some(specs);
2107        }
2108        for track in media.tracks {
2109            if !track.samples.is_empty() {
2110                self.pending_outputs.push_back(Output::Samples {
2111                    track_id: track.spec.track_id,
2112                    samples: track.samples,
2113                });
2114            }
2115        }
2116        Ok(())
2117    }
2118
2119    /// Emit the pending `EXT-X-DISCONTINUITY` for `msn` and, when one fires,
2120    /// restart the TS demuxer's timeline: the timestamps after a
2121    /// discontinuity are a new timeline (RFC 8216 §4.3.2.3), so the previous
2122    /// 33-bit unwrap anchor must not carry across it. The old timeline's
2123    /// held-back final access units are flushed *before* the marker. The
2124    /// track set is kept ([`StreamingTsDemux::restart_timeline`]): a fresh
2125    /// demuxer would have to re-probe every PID from the new timeline alone
2126    /// and loses any PID whose parameter sets are not in it (a segment that
2127    /// opens mid-GOP).
2128    fn begin_ts_resource(&mut self, id: ResourceId, msn: u64) -> Result<()> {
2129        if self.discontinuous_msns.contains(&msn) && !self.discontinuity_emitted.contains(&msn) {
2130            self.restart_ts_demux(id)?;
2131            self.emit_discontinuity_if_needed(msn);
2132        }
2133        Ok(())
2134    }
2135
2136    fn restart_ts_demux(&mut self, id: ResourceId) -> Result<()> {
2137        let Some(TsDemuxState(demux)) = self.ts_demux.as_mut() else {
2138            return Ok(());
2139        };
2140        demux.restart_timeline();
2141        self.drain_ts_demux(id)
2142    }
2143
2144    /// The classic-TS-HLS counterpart to [`Self::demux_and_emit`]: demux a
2145    /// self-contained MPEG-TS Part/Segment resource through the one
2146    /// [`StreamingTsDemux`] this client keeps across segments (no init bytes
2147    /// to concatenate — each `.ts` segment carries its own PAT/PMT/PES), so
2148    /// the 33-bit PTS/DTS wrap (ISO/IEC 13818-1 §2.4.3.7, every ~26.5 h) is
2149    /// unrolled across segment boundaries instead of restarting per segment.
2150    ///
2151    /// The demuxer is **not** finished per segment: it resolves each video
2152    /// sample's duration from the next access unit's timestamp, so a
2153    /// segment's final access unit per stream is held back and emitted once
2154    /// the next segment supplies its real duration (or, at
2155    /// `EXT-X-ENDLIST`/`EXT-X-DISCONTINUITY`, flushed with the stream's last
2156    /// known duration). Samples therefore stay in decode order with true
2157    /// durations, one access unit per stream later than a per-segment flush.
2158    ///
2159    /// The [`Output::Init`] the crate's output contract requires is
2160    /// synthesized from the demuxer's track set via
2161    /// [`transmux::build_init_segment`] — a real `ftyp`+fragmented-`moov`,
2162    /// byte-for-byte demuxable by `transmux::Fmp4Demux` like any other init
2163    /// segment, so callers built against the fMP4 path (e.g. `multimux`'s
2164    /// `HlsPull`) need no TS-specific handling. It is emitted before the
2165    /// first samples and again whenever the track set (a new PID, a codec
2166    /// change) makes the re-synthesized init differ from the last one.
2167    fn demux_and_emit_ts(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
2168        let TsDemuxState(demux) = self
2169            .ts_demux
2170            .get_or_insert_with(|| TsDemuxState(StreamingTsDemux::new()));
2171        demux.feed(bytes);
2172        self.drain_ts_demux(id)
2173    }
2174
2175    /// A whole media segment has been fed: a PID its PMT declares that sent
2176    /// nothing in it is absent, so it must not hold the rest of its program
2177    /// back until `EXT-X-ENDLIST` (a live playlist has none).
2178    fn release_ts_segment_end(&mut self, id: ResourceId) -> Result<()> {
2179        let Some(TsDemuxState(demux)) = self.ts_demux.as_mut() else {
2180            return Ok(());
2181        };
2182        demux.release_silent_heads();
2183        self.drain_ts_demux(id)
2184    }
2185
2186    /// Flush the TS demuxer's held-back access units (no more input on this
2187    /// timeline) and emit them.
2188    fn flush_ts_demux(&mut self, id: ResourceId) -> Result<()> {
2189        let Some(TsDemuxState(demux)) = self.ts_demux.as_mut() else {
2190            return Ok(());
2191        };
2192        demux.finish();
2193        self.drain_ts_demux(id)
2194    }
2195
2196    /// Turn the TS demuxer's pending events into [`Output`]s: the track set
2197    /// (re-synthesizing the init when it changed), then the samples per track.
2198    fn drain_ts_demux(&mut self, id: ResourceId) -> Result<()> {
2199        let Some(TsDemuxState(demux)) = self.ts_demux.as_mut() else {
2200            return Ok(());
2201        };
2202        let mut per_track: BTreeMap<TrackId, Vec<Sample>> = BTreeMap::new();
2203        let mut events: Vec<DemuxEvent> = Vec::new();
2204        while let Some(event) = demux.poll_event() {
2205            match event {
2206                DemuxEvent::TrackAdded(spec) | DemuxEvent::TrackUpdated(spec) => {
2207                    if spec.config.is_muxable_in_bmff() {
2208                        self.ts_specs.insert(spec.track_id, spec);
2209                    } else {
2210                        self.ts_specs.remove(&spec.track_id);
2211                        let stream_type = match &spec.config {
2212                            transmux::CodecConfig::Data { stream_type, .. } => Some(*stream_type),
2213                            _ => None,
2214                        };
2215                        self.pending_outputs.push_back(Output::TrackDropped {
2216                            track_id: spec.track_id,
2217                            stream_type,
2218                        });
2219                    }
2220                }
2221                DemuxEvent::TrackRemoved { track_id, .. } => {
2222                    self.ts_specs.remove(&track_id);
2223                }
2224                DemuxEvent::Sample { track_id, .. }
2225                | DemuxEvent::DisplayChanged { track_id, .. }
2226                    if self.ts_specs.contains_key(&track_id) =>
2227                {
2228                    events.push(event);
2229                }
2230                _ => {}
2231            }
2232        }
2233        if !self.ts_specs.is_empty() {
2234            let specs: Vec<TrackSpec> = self.ts_specs.values().cloned().collect();
2235            let init_bytes = transmux::build_init_segment(&specs, TS_MOVIE_TIMESCALE)
2236                .map_err(|source| Error::Demux { id, source })?;
2237            if self.last_ts_init.as_deref() != Some(init_bytes.as_slice()) {
2238                self.pending_outputs
2239                    .push_back(Output::Tracks(specs.clone()));
2240                self.pending_outputs
2241                    .push_back(Output::Init(init_bytes.clone()));
2242                self.last_ts_init = Some(init_bytes);
2243            }
2244        }
2245        for event in events {
2246            match event {
2247                DemuxEvent::Sample {
2248                    track_id, sample, ..
2249                } => {
2250                    per_track.entry(track_id).or_default().push(sample);
2251                }
2252                DemuxEvent::DisplayChanged {
2253                    track_id,
2254                    display,
2255                    pts,
2256                    ..
2257                } => {
2258                    if let Some(samples) = per_track.remove(&track_id)
2259                        && !samples.is_empty()
2260                    {
2261                        self.pending_outputs
2262                            .push_back(Output::Samples { track_id, samples });
2263                    }
2264                    self.pending_outputs.push_back(Output::DisplayChanged {
2265                        track_id,
2266                        pts,
2267                        display,
2268                    });
2269                }
2270                _ => {}
2271            }
2272        }
2273        for (track_id, samples) in per_track {
2274            if !samples.is_empty() {
2275                self.pending_outputs
2276                    .push_back(Output::Samples { track_id, samples });
2277            }
2278        }
2279        Ok(())
2280    }
2281
2282    /// Emit [`Output::Discontinuity`] once for a discontinuous `msn`;
2283    /// `true` when it was emitted by this call.
2284    fn emit_discontinuity_if_needed(&mut self, msn: u64) -> bool {
2285        if self.discontinuous_msns.contains(&msn) && !self.discontinuity_emitted.contains(&msn) {
2286            self.pending_outputs.push_back(Output::Discontinuity);
2287            self.discontinuity_emitted.insert(msn);
2288            return true;
2289        }
2290        false
2291    }
2292
2293    /// Choose the first segment to play on the first playlist load and mark
2294    /// every closed segment before it as already delivered, so the join does
2295    /// not replay (or, for a long EVENT/DVR window, download) the history.
2296    ///
2297    /// - A live playlist (no `EXT-X-ENDLIST`) is not joined closer to its end
2298    ///   than the server's hold-back (RFC 8216 §6.3.3, RFC 8216bis §6.3.3):
2299    ///   `PART-HOLD-BACK` when the client plays in Low-Latency Mode
2300    ///   (`CAN-BLOCK-RELOAD=YES`), else the larger of `HOLD-BACK` and three
2301    ///   Target Durations (§4.4.3.8). The parts of the open segment count
2302    ///   towards the distance from the end.
2303    /// - `EXT-X-START` (§4.4.2.2) is honoured at segment granularity — a
2304    ///   positive `TIME-OFFSET` counts from the start, a negative one from the
2305    ///   end of the last segment; a live playlist never starts later than the
2306    ///   hold-back allows. `PRECISE` does not apply: segments are delivered
2307    ///   whole.
2308    /// - A VOD playlist without `EXT-X-START` is played from its start.
2309    ///
2310    /// A segment whose `EXTINF` is zero counts as one Target Duration, so a
2311    /// degenerate window still joins near its end. The byte-range cursor is
2312    /// advanced over every skipped segment, so a later omitted-offset
2313    /// `EXT-X-BYTERANGE` on the same resource continues from the right offset
2314    /// (RFC 8216 §4.3.2.2).
2315    ///
2316    /// # Errors
2317    /// [`Error::ByteRangeOverflow`] from the skipped segments' ranges.
2318    fn join_start(&mut self, playlist: &MediaPlaylist) -> Result<()> {
2319        let segments = &playlist.segments;
2320        let target = f64::from(playlist.target_duration.max(1));
2321        let duration_of = |seg: &MediaSegment| {
2322            let d = seg.duration.get();
2323            if d > 0.0 { d } else { target }
2324        };
2325
2326        let from_start_tag = playlist.start.as_ref().map(|start| {
2327            let total: f64 = segments.iter().map(duration_of).sum();
2328            let offset = start.time_offset.get();
2329            let position = if offset >= 0.0 {
2330                offset
2331            } else {
2332                total + offset
2333            };
2334            let position = position.clamp(0.0, total);
2335            let mut elapsed = 0.0_f64;
2336            for (i, seg) in segments.iter().enumerate() {
2337                elapsed += duration_of(seg);
2338                if elapsed > position {
2339                    return i;
2340                }
2341            }
2342            // At (or past) the end: the last segment.
2343            segments.len().saturating_sub(1)
2344        });
2345
2346        let from_hold_back = (!playlist.endlist).then(|| {
2347            let ll = playlist.low_latency.as_ref();
2348            let wanted = match ll.filter(|ll| ll.can_block_reload) {
2349                Some(ll) if ll.part_hold_back.is_some() => {
2350                    ll.part_hold_back.map_or(0.0, |p| p.get())
2351                }
2352                _ => {
2353                    let hold_back = ll.and_then(|ll| ll.hold_back).map_or(0.0, |h| h.get());
2354                    (LIVE_EDGE_TARGET_DURATIONS * target).max(hold_back)
2355                }
2356            };
2357            let mut behind: f64 = playlist
2358                .open_segment
2359                .as_ref()
2360                .map_or(0.0, |o| o.parts.iter().map(|p| p.duration.get()).sum());
2361            if behind >= wanted {
2362                return segments.len();
2363            }
2364            for (i, seg) in segments.iter().enumerate().rev() {
2365                behind += duration_of(seg);
2366                if behind >= wanted {
2367                    return i;
2368                }
2369            }
2370            0
2371        });
2372
2373        let start = match (from_start_tag, from_hold_back) {
2374            (Some(s), Some(h)) => s.min(h),
2375            (Some(s), None) => s,
2376            (None, Some(h)) => h,
2377            (None, None) => 0,
2378        };
2379        for (i, seg) in segments.iter().enumerate().take(start) {
2380            let msn = playlist.media_sequence.saturating_add(index_u64(i));
2381            self.skip_segment(msn, seg)?;
2382        }
2383        Ok(())
2384    }
2385
2386    /// Mark a segment skipped at join time as delivered, advancing the
2387    /// byte-range cursor exactly as fetching it would have (a segment with
2388    /// parts is addressed by its parts' ranges, a plain one by its own).
2389    fn skip_segment(&mut self, msn: u64, seg: &MediaSegment) -> Result<()> {
2390        if self.delivered_segments.contains(&msn) {
2391            return Ok(());
2392        }
2393        if seg.parts.is_empty() {
2394            let url = url::resolve(self.base(), &seg.uri);
2395            self.resolve_byte_range(&url, &seg.byte_range)?;
2396        } else {
2397            for part in seg.parts.iter().filter(|p| !p.gap) {
2398                let url = url::resolve(self.base(), &part.uri);
2399                self.resolve_byte_range(&url, &part.byte_range)?;
2400            }
2401        }
2402        self.delivered_segments.insert(msn);
2403        Ok(())
2404    }
2405
2406    /// Drop every MSN-keyed record below the playlist's first segment (it
2407    /// can never be referenced again, and a long-lived pull would otherwise
2408    /// grow these sets without bound), and — for a full playlist — every
2409    /// byte-range cursor whose URL the playlist no longer references.
2410    /// Resources still in flight (requested, not yet fulfilled) are kept so
2411    /// their delivery is still accepted.
2412    fn prune(&mut self, playlist: &MediaPlaylist) {
2413        let first = playlist.media_sequence;
2414        let below = |id: &ResourceId| resource_msn(id).is_some_and(|m| m < first);
2415        let fulfilled = &self.fulfilled;
2416        self.requested
2417            .retain(|id| !(below(id) && fulfilled.contains(id)));
2418        self.resource_requests.retain(|id, _| !below(id));
2419        self.fulfilled.retain(|id| !below(id));
2420        self.pending_keyed.retain(|(id, _)| !below(id));
2421        self.segment_crypto.retain(|&m, _| m >= first);
2422        self.delivered_parts.retain(|&(m, _)| m >= first);
2423        self.delivered_segments.retain(|&m| m >= first);
2424        self.discontinuous_msns.retain(|&m| m >= first);
2425        self.discontinuity_emitted.retain(|&m| m >= first);
2426        self.msn_init.retain(|&m, _| m >= first);
2427        self.prune_inits();
2428
2429        if playlist.skip.is_none() {
2430            let base = self.base();
2431            let mut live: BTreeSet<String> = BTreeSet::new();
2432            for seg in &playlist.segments {
2433                live.insert(url::resolve(base, &seg.uri));
2434                for part in &seg.parts {
2435                    live.insert(url::resolve(base, &part.uri));
2436                }
2437                if let Some(map) = &seg.map {
2438                    live.insert(url::resolve(base, &map.uri));
2439                }
2440            }
2441            if let Some(open) = &playlist.open_segment {
2442                for part in &open.parts {
2443                    live.insert(url::resolve(base, &part.uri));
2444                }
2445                if let Some(map) = &open.map {
2446                    live.insert(url::resolve(base, &map.uri));
2447                }
2448            }
2449            if let Some(hint) = playlist
2450                .low_latency
2451                .as_ref()
2452                .and_then(|ll| ll.preload_hint_part.as_ref())
2453            {
2454                live.insert(url::resolve(base, hint));
2455            }
2456            self.byte_range_cursor.retain(|u, _| live.contains(u));
2457        }
2458    }
2459
2460    /// Drop every held init no segment still being fetched refers to, keeping
2461    /// the newest.
2462    fn prune_inits(&mut self) {
2463        let newest = self.inits.keys().next_back().copied();
2464        let bound: BTreeSet<InitId> = self.msn_init.values().copied().collect();
2465        self.inits.retain(|id, slot| {
2466            Some(*id) == newest || bound.contains(id) || slot.fetch != InitFetch::Ready
2467        });
2468    }
2469
2470    /// Decide whether `new` continues the stream the previous full playlist
2471    /// described, is merely an *older* copy of it (a CDN edge a poll — or a
2472    /// whole window — behind another), or is a different stream (an origin
2473    /// restart; audit r09-C3, issue #1031).
2474    ///
2475    /// Signals, strongest first:
2476    ///
2477    /// 1. **Segment identity at a shared number** — a media sequence number
2478    ///    listed by both windows must name the same segment (URI, duration,
2479    ///    byte range) and, when both carry `EXT-X-PROGRAM-DATE-TIME` for it,
2480    ///    the same wall-clock time. A mismatch is a restart *whatever the
2481    ///    direction of the numbers* — an origin that restarts to an equal or
2482    ///    higher number is caught here. The URI's query string is ignored only
2483    ///    when it differs on *every* shared segment (a CDN re-signing the whole
2484    ///    window); a window where some shared segments keep their query and
2485    ///    others change it is a restart.
2486    /// 2. **`EXT-X-DISCONTINUITY-SEQUENCE`** may never decrease while the
2487    ///    media sequence number does not (RFC 8216 §6.2.2): a decrease at an
2488    ///    equal or higher number is a restart.
2489    /// 3. When the media sequence number *went backwards* with **no** shared
2490    ///    number (the new window is wholly older, or a restart):
2491    ///    - a URI that the previous window listed under a *different* number
2492    ///      is a restart (names reused under new numbering);
2493    ///    - else, with `PROGRAM-DATE-TIME` on both sides, a new window that
2494    ///      ends before the previous one began is a wholly older copy
2495    ///      ([`Continuity::OlderWindow`], skipped), anything else a restart;
2496    ///    - else nothing distinguishes them and it is treated as a restart.
2497    ///
2498    /// **Residual, undetectable cases** (documented, not guessed at): a CDN
2499    /// edge lagging by a whole window behind an origin that carries *no*
2500    /// `PROGRAM-DATE-TIME` and whose segment names are all new reads as a
2501    /// restart (one spurious `Discontinuity` and a re-join); and an origin
2502    /// restarting to a *higher*, non-overlapping number with consistent
2503    /// discontinuity sequence and no `PROGRAM-DATE-TIME` reads as the client
2504    /// having fallen behind.
2505    fn continuity(&self, new: &MediaPlaylist) -> Continuity {
2506        let Some(prev) = &self.last_full_playlist else {
2507            return if self
2508                .last_media_sequence
2509                .is_some_and(|p| new.media_sequence < p)
2510            {
2511                Continuity::Restart
2512            } else {
2513                Continuity::Continues
2514            };
2515        };
2516        let (p0, n0) = (prev.media_sequence, new.media_sequence);
2517        let regressed = n0 < self.last_media_sequence.unwrap_or(p0).max(p0);
2518
2519        // 1. Identity at every shared number. A query that differs is a re-signing
2520        //    only when it differs on every shared segment; on some of them it is a
2521        //    restart that reuses names.
2522        let mut overlap = false;
2523        let mut shared = 0usize;
2524        let mut requeried = 0usize;
2525        for (i, seg) in new.segments.iter().enumerate() {
2526            let Some(msn) = n0.checked_add(index_u64(i)) else {
2527                return Continuity::Restart;
2528            };
2529            let Some(old) = msn
2530                .checked_sub(p0)
2531                .and_then(|d| usize::try_from(d).ok())
2532                .and_then(|d| prev.segments.get(d))
2533            else {
2534                continue;
2535            };
2536            if without_query(&old.uri) != without_query(&seg.uri)
2537                || old.duration != seg.duration
2538                || old.byte_range != seg.byte_range
2539            {
2540                return Continuity::Restart;
2541            }
2542            if let (Some(a), Some(b)) = (program_date_time(old), program_date_time(seg))
2543                && a != b
2544            {
2545                return Continuity::Restart;
2546            }
2547            overlap = true;
2548            shared = shared.saturating_add(1);
2549            if old.uri != seg.uri {
2550                requeried = requeried.saturating_add(1);
2551            }
2552        }
2553        if requeried != 0 && requeried != shared {
2554            return Continuity::Restart;
2555        }
2556
2557        // 2. The discontinuity sequence never decreases while the number does
2558        //    not.
2559        if !regressed && new.discontinuity_sequence < prev.discontinuity_sequence {
2560            return Continuity::Restart;
2561        }
2562        if !regressed {
2563            return Continuity::Continues;
2564        }
2565
2566        // 3. Regressed.
2567        if overlap {
2568            return Continuity::Lagging;
2569        }
2570        let old_uris: BTreeSet<&str> = prev.segments.iter().map(|s| s.uri.as_str()).collect();
2571        if new
2572            .segments
2573            .iter()
2574            .any(|s| old_uris.contains(s.uri.as_str()))
2575        {
2576            // A URI the previous window lists under another number.
2577            return Continuity::Restart;
2578        }
2579        let new_last = new.segments.iter().rev().find_map(program_date_time_ms);
2580        let prev_first = prev.segments.iter().find_map(program_date_time_ms);
2581        match (new_last, prev_first) {
2582            (Some(end), Some(start)) if end < start => Continuity::OlderWindow,
2583            _ => Continuity::Restart,
2584        }
2585    }
2586
2587    /// The Media Sequence Number went backwards (RFC 8216 §6.2.2 forbids it;
2588    /// restarting origins do it anyway — ffmpeg without `-start_number`,
2589    /// nginx-rtmp, a replaced origin; audit r09-C3, issue #1031). Every
2590    /// number the new playlist uses may name different media from the same
2591    /// number already delivered, so all MSN-keyed state is dropped (a new
2592    /// segment must not be skipped as "already delivered"), not-yet-polled
2593    /// fetches of the old numbering are withdrawn, the live-edge join is
2594    /// redone, the init segment is re-fetched (the restart may carry a new
2595    /// codec configuration), and [`Output::Discontinuity`] is emitted. A
2596    /// response still in flight for an old id is rejected as
2597    /// [`Error::UnrequestedResource`].
2598    fn restart_after_regression(&mut self) -> Result<()> {
2599        // Held-back TS access units belong to the old timeline: emit them
2600        // before the discontinuity, then start a fresh demuxer.
2601        self.flush_ts_demux(ResourceId::Init)?;
2602        self.ts_demux = None;
2603        self.ts_specs.clear();
2604        self.last_ts_init = None;
2605        self.pending_actions
2606            .retain(|a| !matches!(a, Action::FetchResource { .. }));
2607        self.pending_demux.clear();
2608        self.pending_actions
2609            .retain(|a| !matches!(a, Action::FetchKey { .. }));
2610        self.keys_in_flight.clear();
2611        self.segment_crypto.clear();
2612        self.pending_keyed.clear();
2613        self.requested.clear();
2614        self.fulfilled.clear();
2615        self.delivered_parts.clear();
2616        self.delivered_segments.clear();
2617        self.discontinuous_msns.clear();
2618        self.discontinuity_emitted.clear();
2619        self.byte_range_cursor.clear();
2620        self.resource_requests.clear();
2621        self.last_media_sequence = None;
2622        self.outstanding_fetches = 0;
2623        self.last_full_playlist = None;
2624        self.inits.clear();
2625        self.init_target = None;
2626        self.announced_init = None;
2627        self.msn_init.clear();
2628        self.last_fmp4_specs = None;
2629        self.full_reload_requested = false;
2630        self.joined = false;
2631        self.pending_outputs.push_back(Output::Discontinuity);
2632        Ok(())
2633    }
2634
2635    fn all_playlist_segments_delivered(&self) -> bool {
2636        let Some(playlist) = &self.last_full_playlist else {
2637            return true;
2638        };
2639        (0..playlist.segments.len() as u64).all(|i| {
2640            let msn = playlist.media_sequence.saturating_add(i);
2641            self.delivered_segments.contains(&msn)
2642                || self.fulfilled.contains(&ResourceId::Segment { msn })
2643                || self.covered_by_parts(msn)
2644        })
2645    }
2646
2647    fn maybe_emit_end_of_stream(&mut self) -> Result<()> {
2648        if self.saw_endlist
2649            && !self.end_emitted
2650            && self.outstanding_fetches == 0
2651            && self.pending_keyed.is_empty()
2652            && self.inits.values().all(|slot| slot.bytes.is_some())
2653            && self.all_playlist_segments_delivered()
2654        {
2655            // The TS demuxer holds back each stream's last access unit until
2656            // the next one shows its real duration; nothing follows now.
2657            self.flush_ts_demux(ResourceId::Init)?;
2658            self.pending_outputs.push_back(Output::EndOfStream);
2659            self.end_emitted = true;
2660        }
2661        Ok(())
2662    }
2663}
2664
2665#[cfg(test)]
2666mod tests {
2667    use super::*;
2668    use alloc::vec;
2669    use transmux::sample_aes::aes128_encrypt_segment;
2670
2671    const EMPTY_FRAGMENT: &[u8] = &[
2672        0, 0, 0, 8, b'm', b'o', b'o', b'f', 0, 0, 0, 8, b'm', b'd', b'a', b't',
2673    ];
2674
2675    #[test]
2676    fn decrypt_segment_strips_pkcs7_padding_to_the_exact_plaintext() {
2677        let key = [0x5Au8; KEY_LEN];
2678        let iv = [0xC3u8; BLOCK_LEN];
2679        let id = ResourceId::Segment { msn: 9 };
2680        let crypto = SegmentCrypto {
2681            key_id: KeyId(0),
2682            iv,
2683        };
2684        for len in [1usize, 15, 16, 17, 37, 188] {
2685            let plain: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
2686            let cipher = aes128_encrypt_segment(&key, &iv, &plain);
2687            assert_eq!(decrypt_segment(id, crypto, &key, &cipher).unwrap(), plain);
2688        }
2689    }
2690
2691    #[test]
2692    fn media_sequence_iv_is_big_endian_left_padded() {
2693        let iv = media_sequence_iv(0x0102_0304_0506_0708);
2694        assert_eq!(iv[..8], [0u8; 8]);
2695        assert_eq!(iv[8..], [1, 2, 3, 4, 5, 6, 7, 8]);
2696        assert_eq!(media_sequence_iv(0)[..], [0u8; 16]);
2697        assert_eq!(media_sequence_iv(0x1FF)[14..], [1, 0xFF]);
2698    }
2699
2700    // Regression: `on_resource` documents (see `Error::UnrequestedResource`)
2701    // that it rejects a `ResourceId` the client never requested, but
2702    // previously never actually checked — any bytes for any id (a
2703    // caller/driver bug, or a stale/duplicate delivery) were silently
2704    // accepted. Must FAIL if that check is ever removed.
2705    #[test]
2706    fn on_resource_rejects_a_never_requested_id() {
2707        let mut client = HlsClient::new(HlsClientConfig::new("http://example.com/playlist.m3u8"));
2708        let id = ResourceId::Segment { msn: 0 };
2709
2710        let err = client
2711            .on_resource(id, b"some bytes")
2712            .expect_err("an id the client never requested must be rejected");
2713        assert!(
2714            matches!(err, Error::UnrequestedResource { id: got } if got == id),
2715            "wrong error variant: {err:?}"
2716        );
2717
2718        // Init is checked too (tracked via `init_uri` rather than
2719        // `requested`, since it's never inserted into that set).
2720        let err = client
2721            .on_resource(ResourceId::Init, b"init bytes")
2722            .expect_err("an unrequested Init must be rejected");
2723        assert!(
2724            matches!(
2725                err,
2726                Error::UnrequestedResource {
2727                    id: ResourceId::Init
2728                }
2729            ),
2730            "wrong error variant: {err:?}"
2731        );
2732    }
2733
2734    // The flip side of the regression above: a `ResourceId` the client
2735    // actually asked for (via its own internal `request_resource`
2736    // bookkeeping, mirroring what a real `poll()`-driven fetch populates)
2737    // must still be accepted, not spuriously rejected.
2738    #[test]
2739    fn on_resource_accepts_a_previously_requested_id() {
2740        let mut client = HlsClient::new(HlsClientConfig::new("http://example.com/playlist.m3u8"));
2741        let id = ResourceId::Segment { msn: 0 };
2742        client.request_resource(id, "http://example.com/seg0.m4s".to_string(), None);
2743
2744        // No init segment cached yet, so this is buffered rather than
2745        // demuxed — the point here is only that it is *not* rejected as
2746        // unrequested.
2747        let result = client.on_resource(id, EMPTY_FRAGMENT);
2748        assert!(
2749            result.is_ok(),
2750            "a requested id must be accepted: {result:?}"
2751        );
2752        assert!(
2753            client.pending_demux.iter().any(|(bid, _)| *bid == id),
2754            "expected the resource to be buffered pending the init segment"
2755        );
2756    }
2757
2758    // Issue #760: classic MPEG-TS-segment HLS routing. `is_ts_segment` must
2759    // say yes to a genuine TS resource (sync byte, no map ever seen)...
2760    #[test]
2761    fn is_ts_segment_true_when_no_map_seen_and_sync_byte_present() {
2762        let client = HlsClient::new(HlsClientConfig::new("http://example.com/playlist.m3u8"));
2763        assert!(client.is_ts_segment(&[TS_SYNC_BYTE, 0x40, 0x11, 0x00]));
2764    }
2765
2766    // ...but say no to an ISOBMFF (fMP4/CMAF) resource even when no map has
2767    // been seen yet — the content itself is never TS, so it must fall
2768    // through to the ordinary init-buffering path rather than being
2769    // misrouted into `TsDemux` (which would reject it as malformed TS).
2770    #[test]
2771    fn is_ts_segment_false_for_an_isobmff_resource_with_no_map_seen() {
2772        let client = HlsClient::new(HlsClientConfig::new("http://example.com/playlist.m3u8"));
2773        let ftyp_box = b"\x00\x00\x00\x18ftypiso5\x00\x00\x02\x00iso5iso6mp41";
2774        assert!(!client.is_ts_segment(ftyp_box));
2775    }
2776
2777    // The playlist signal takes precedence over content-sniffing: once this
2778    // playlist is known to advertise an `EXT-X-MAP` (an init fetch has been
2779    // requested/cached), even a resource whose first byte happens to be
2780    // `0x47` must NOT be misrouted through `TsDemux` -- it is that
2781    // playlist's own fMP4/CMAF init + part/segment concatenation the
2782    // fetched bytes belong with.
2783    #[test]
2784    fn is_ts_segment_false_once_a_map_has_been_requested() {
2785        let mut client = HlsClient::new(HlsClientConfig::new("http://example.com/playlist.m3u8"));
2786        client
2787            .ensure_init_requested(&MapTag {
2788                uri: "init.mp4".to_string(),
2789                byte_range: None,
2790                extra_attrs: Vec::new(),
2791            })
2792            .expect("ensure_init_requested succeeds");
2793        assert!(!client.is_ts_segment(&[TS_SYNC_BYTE, 0x40, 0x11, 0x00]));
2794    }
2795
2796    // Biting test for the u64-overflow defect: a remote origin's
2797    // `#EXT-X-BYTERANGE` (or preload-hint byte range) is untrusted text —
2798    // `broadcast_hls::MediaPlaylist::parse` places no upper bound on either
2799    // the offset or the length (confirmed: bare `str::parse::<u64>()`). A
2800    // playlist advertising `BYTERANGE:18446744073709551615@1` must be
2801    // rejected with `Error::ByteRangeOverflow`, not panic (debug) or
2802    // silently wrap `offset + length` into a bogus cursor (release).
2803    //
2804    // MUTATION VERIFIED: reverting `resolve_byte_range`'s
2805    // `offset.checked_add(br.length).ok_or_else(...)` back to the original
2806    // `offset + br.length` makes this test fail — the debug build panics
2807    // with "attempt to add with overflow" before `expect_err` ever runs
2808    // (confirmed by running it), rather than the release build's silent
2809    // wraparound the issue actually reports. Recompiled and re-ran to
2810    // observe that exact panic, then restored the checked_add.
2811    #[test]
2812    fn resolve_byte_range_rejects_an_offset_plus_length_that_overflows_u64() {
2813        let mut client = HlsClient::new(HlsClientConfig::new("http://example.com/playlist.m3u8"));
2814        let br = Some(ByteRange {
2815            length: u64::MAX,
2816            offset: Some(1),
2817        });
2818        let err = client
2819            .resolve_byte_range("http://example.com/seg0.m4s", &br)
2820            .expect_err("offset 1 + length u64::MAX must overflow and be rejected");
2821        assert!(
2822            matches!(
2823                err,
2824                Error::ByteRangeOverflow {
2825                    offset: 1,
2826                    length: u64::MAX,
2827                    ..
2828                }
2829            ),
2830            "wrong error variant/fields: {err:?}"
2831        );
2832    }
2833
2834    // Same defect, the other trigger named in the issue: repeated
2835    // omitted-offset ranges on the *same* resource URL accumulate via
2836    // `byte_range_cursor` (RFC 8216bis §4.4.4.9's "omitted offset continues
2837    // the previous sub-range" rule) — a long-lived pull can walk that
2838    // cursor arbitrarily close to `u64::MAX` before a single request's
2839    // `offset + length` itself overflows.
2840    #[test]
2841    fn resolve_byte_range_rejects_a_cursor_accumulation_that_overflows_u64() {
2842        let mut client = HlsClient::new(HlsClientConfig::new("http://example.com/playlist.m3u8"));
2843        let url = "http://example.com/seg0.m4s";
2844        // Seed the cursor near the top of the u64 range with an explicit
2845        // offset, then the next omitted-offset range pushes it over.
2846        let seed = Some(ByteRange {
2847            length: u64::MAX - 10,
2848            offset: Some(5),
2849        });
2850        let (offset, length) = client
2851            .resolve_byte_range(url, &seed)
2852            .expect("seed range does not itself overflow")
2853            .expect("Some for a Some(ByteRange)");
2854        assert_eq!((offset, length), (5, u64::MAX - 10));
2855
2856        let next = Some(ByteRange {
2857            length: 100,
2858            offset: None, // continues the cursor left at 5 + (u64::MAX - 10)
2859        });
2860        let err = client
2861            .resolve_byte_range(url, &next)
2862            .expect_err("cursor + next length must overflow and be rejected");
2863        assert!(
2864            matches!(err, Error::ByteRangeOverflow { length: 100, .. }),
2865            "wrong error variant/fields: {err:?}"
2866        );
2867    }
2868
2869    // Biting test for the `merge_delta` defect: an `#EXT-X-SKIP` delta's
2870    // `SKIPPED-SEGMENTS` is untrusted `u64` text with no upper bound
2871    // enforced by `broadcast_hls::MediaPlaylist::parse`. A malicious/corrupt
2872    // origin claiming a `SKIPPED-SEGMENTS` value that overflows
2873    // `prefix_start + skipped_segments` (here: `prefix_start == 10` from a
2874    // 10-segment `media_sequence` advance, plus `skipped_segments ==
2875    // u64::MAX - 5`) must fall through to the existing "can't merge, return
2876    // the delta as-is" fallback, not panic. `prefix_start` is deliberately
2877    // nonzero: `0 + u64::MAX` does not overflow, so a `prefix_start == 0`
2878    // case would pass even with the guard removed, for the wrong reason
2879    // (the subsequent `.get()` bounds check catching it) rather than the
2880    // one this test targets (the addition itself).
2881    //
2882    // MUTATION VERIFIED: reverting the guard back to the original
2883    // `prefix_start + skip.skipped_segments as usize` makes this test fail:
2884    // the debug build panics with "attempt to add with overflow" inside
2885    // `merge_delta` before the `let merged = ...` assertions ever run
2886    // (confirmed by running it), rather than returning the delta unmerged.
2887    // Recompiled and re-ran to observe that exact panic, then restored the
2888    // checked/try_from guard.
2889    #[test]
2890    fn merge_delta_does_not_panic_on_an_overflowing_skipped_segments() {
2891        let mut client = HlsClient::new(HlsClientConfig::new("http://example.com/playlist.m3u8"));
2892        let prev = MediaPlaylist {
2893            media_sequence: 0,
2894            segments: vec![MediaSegment::default(); 2],
2895            ..Default::default()
2896        };
2897        // `last_full_playlist` is set directly (private field, same module)
2898        // rather than driven through a full `on_playlist` round-trip — the
2899        // point under test is purely `merge_delta`'s own arithmetic guard.
2900        client.last_full_playlist = Some(prev);
2901
2902        let delta = MediaPlaylist {
2903            media_sequence: 10, // prefix_start == 10 - 0 == 10
2904            skip: Some(broadcast_hls::SkipInfo {
2905                skipped_segments: u64::MAX - 5,
2906                ..Default::default()
2907            }),
2908            segments: Vec::new(),
2909            ..Default::default()
2910        };
2911
2912        let merged = client.merge_delta(delta.clone());
2913        assert_eq!(
2914            merged, delta,
2915            "an unmergeable skip count must fall back to the delta as-is, not panic"
2916        );
2917    }
2918
2919    fn byte_range_playlist(first_msn: u64, file: &str) -> String {
2920        // Two sub-ranges of one resource; the second omits its offset
2921        // (RFC 8216 §4.3.2.2), so the per-URL cursor matters.
2922        format!(
2923            "#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:2\n\
2924             #EXT-X-MEDIA-SEQUENCE:{first_msn}\n\
2925             #EXTINF:2.0,\n#EXT-X-BYTERANGE:100@0\n{file}\n\
2926             #EXTINF:2.0,\n#EXT-X-BYTERANGE:50\n{file}\n"
2927        )
2928    }
2929
2930    // r09-W12: every set keyed by MSN (and the per-URL byte-range cursor)
2931    // must shrink back to what the current window references, not grow for
2932    // the life of the pull. Expected contents are literals.
2933    #[test]
2934    fn state_is_pruned_to_the_current_window() {
2935        let mut client = HlsClient::new(HlsClientConfig::new("http://example.com/p.m3u8"));
2936        client
2937            .on_playlist(byte_range_playlist(0, "a.ts").as_bytes())
2938            .unwrap();
2939        assert_eq!(
2940            client.byte_range_cursor.iter().collect::<Vec<_>>(),
2941            vec![(&"http://example.com/a.ts".to_string(), &150u64)]
2942        );
2943        // Deliver msn 0 (buffered: no init), leave msn 1 in flight.
2944        client
2945            .on_resource(ResourceId::Segment { msn: 0 }, EMPTY_FRAGMENT)
2946            .unwrap();
2947
2948        client
2949            .on_playlist(byte_range_playlist(1000, "b.ts").as_bytes())
2950            .unwrap();
2951
2952        let requested: Vec<ResourceId> = client.requested.iter().copied().collect();
2953        assert_eq!(
2954            requested,
2955            vec![
2956                // in flight when the window moved: kept
2957                ResourceId::Segment { msn: 1 },
2958                ResourceId::Segment { msn: 1000 },
2959                ResourceId::Segment { msn: 1001 },
2960            ]
2961        );
2962        assert!(client.fulfilled.is_empty(), "{:?}", client.fulfilled);
2963        assert_eq!(
2964            client.byte_range_cursor.iter().collect::<Vec<_>>(),
2965            vec![(&"http://example.com/b.ts".to_string(), &150u64)],
2966            "the cursor of the unreferenced a.ts must be dropped"
2967        );
2968    }
2969
2970    // r09-W12/W13 on a discontinuity-bearing window: MSN-keyed discontinuity
2971    // bookkeeping below the window is dropped too.
2972    #[test]
2973    fn discontinuity_bookkeeping_below_the_window_is_dropped() {
2974        let mut client = HlsClient::new(HlsClientConfig::new("http://example.com/p.m3u8"));
2975        let pl = |first: u64| {
2976            format!(
2977                "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n\
2978                 #EXT-X-MEDIA-SEQUENCE:{first}\n#EXT-X-DISCONTINUITY\n#EXTINF:2.0,\ns.ts\n"
2979            )
2980        };
2981        client.on_playlist(pl(5).as_bytes()).unwrap();
2982        client
2983            .on_resource(ResourceId::Segment { msn: 5 }, EMPTY_FRAGMENT)
2984            .unwrap();
2985        assert_eq!(
2986            client
2987                .discontinuous_msns
2988                .iter()
2989                .copied()
2990                .collect::<Vec<_>>(),
2991            vec![5]
2992        );
2993        client.on_playlist(pl(9).as_bytes()).unwrap();
2994        assert_eq!(
2995            client
2996                .discontinuous_msns
2997                .iter()
2998                .copied()
2999                .collect::<Vec<_>>(),
3000            vec![9]
3001        );
3002        assert!(client.discontinuity_emitted.is_empty());
3003        assert!(client.delivered_segments.is_empty());
3004    }
3005
3006    fn keyed_playlist(first_msn: u64, count: u64, key_uri: &str) -> String {
3007        let mut text = format!(
3008            "#EXTM3U\n#EXT-X-VERSION:5\n#EXT-X-TARGETDURATION:2\n\
3009             #EXT-X-MEDIA-SEQUENCE:{first_msn}\n\
3010             #EXT-X-KEY:METHOD=AES-128,URI=\"{key_uri}\"\n"
3011        );
3012        for msn in first_msn..first_msn + count {
3013            text.push_str(&format!("#EXTINF:2.0,\nseg{msn}.ts\n"));
3014        }
3015        text
3016    }
3017
3018    fn pending_keyed_bytes(client: &HlsClient) -> usize {
3019        client.pending_keyed.iter().map(|(_, b)| b.len()).sum()
3020    }
3021
3022    #[test]
3023    fn pending_keyed_is_pruned_with_the_window() {
3024        let mut client = HlsClient::new(HlsClientConfig::new("http://example.com/p.m3u8"));
3025        client
3026            .on_playlist(keyed_playlist(0, 1, "key.bin").as_bytes())
3027            .unwrap();
3028        client
3029            .on_resource(ResourceId::Segment { msn: 0 }, &[0u8; 32])
3030            .unwrap();
3031        assert_eq!(client.pending_keyed.len(), 1);
3032        client
3033            .on_playlist(keyed_playlist(5, 1, "key.bin").as_bytes())
3034            .unwrap();
3035        assert!(
3036            client.pending_keyed.is_empty(),
3037            "ciphertext below the window can never be played: {:?}",
3038            client
3039                .pending_keyed
3040                .iter()
3041                .map(|(id, _)| id)
3042                .collect::<Vec<_>>()
3043        );
3044    }
3045
3046    #[test]
3047    fn pending_keyed_is_capped_and_drops_the_oldest_with_a_typed_error() {
3048        let mut client = HlsClient::new(HlsClientConfig::new("http://example.com/p.m3u8"));
3049        client
3050            .on_playlist(keyed_playlist(0, 2, "key.bin").as_bytes())
3051            .unwrap();
3052        let big = vec![0u8; 40 * 1024 * 1024];
3053        client
3054            .on_resource(ResourceId::Segment { msn: 0 }, &big)
3055            .unwrap();
3056        let err = client
3057            .on_resource(ResourceId::Segment { msn: 1 }, &big)
3058            .expect_err("the second 40 MiB segment exceeds the 64 MiB cap");
3059        assert!(
3060            matches!(err, Error::PendingKeyedOverflow { dropped } if dropped == ResourceId::Segment { msn: 0 }),
3061            "{err:?}"
3062        );
3063        assert!(
3064            pending_keyed_bytes(&client) <= MAX_PENDING_KEYED_BYTES,
3065            "{} bytes buffered",
3066            pending_keyed_bytes(&client)
3067        );
3068        assert_eq!(
3069            client
3070                .pending_keyed
3071                .iter()
3072                .map(|(id, _)| *id)
3073                .collect::<Vec<_>>(),
3074            vec![ResourceId::Segment { msn: 1 }]
3075        );
3076    }
3077
3078    #[test]
3079    fn key_maps_do_not_grow_with_per_reload_tokenised_key_urls() {
3080        let mut client = HlsClient::new(HlsClientConfig::new("http://example.com/p.m3u8"));
3081        for n in 0..8u64 {
3082            client
3083                .on_playlist(keyed_playlist(n, 2, &format!("key.bin?t={n}")).as_bytes())
3084                .unwrap();
3085        }
3086        assert!(
3087            client.key_ids.len() <= 3 && client.key_urls.len() <= 3,
3088            "{:?} {:?}",
3089            client.key_ids,
3090            client.key_urls
3091        );
3092    }
3093
3094    #[test]
3095    #[cfg(feature = "std")]
3096    fn next_wait_reports_the_queued_wait_hint_without_consuming_it() {
3097        use core::time::Duration;
3098        let mut c = HlsClient::new(HlsClientConfig::new("http://h/p.m3u8"));
3099        assert_eq!(c.next_wait(), None, "the first queued action is a fetch");
3100        let _ = c.poll();
3101        c.on_playlist(
3102            b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:2.0,\nseg0.ts\n",
3103        )
3104        .unwrap();
3105        while c.next_wait().is_none() {
3106            c.poll()
3107                .expect("a WaitMs must be queued after a non-blocking live playlist");
3108        }
3109        assert_eq!(
3110            c.next_wait(),
3111            Some(Duration::from_millis(1000)),
3112            "half the 2 s target duration"
3113        );
3114        assert_eq!(
3115            c.next_wait(),
3116            Some(Duration::from_millis(1000)),
3117            "peeking does not consume"
3118        );
3119        // The deadline is ABSOLUTE: re-queries at later `now`s (unrelated
3120        // wake-ups) return the same instant instead of re-arming the wait.
3121        let t0 = std::time::Instant::now();
3122        let first = c.poll_timeout(t0).expect("a wait is at the front");
3123        assert_eq!(first, t0 + Duration::from_millis(1000));
3124        for later in [250u64, 600, 999, 5000] {
3125            assert_eq!(
3126                c.poll_timeout(t0 + Duration::from_millis(later)),
3127                Some(first),
3128                "re-query at +{later} ms must not slide the deadline"
3129            );
3130        }
3131        // Draining the wait supersedes it: the next wait is anchored afresh.
3132        assert!(matches!(c.poll(), Some(Action::WaitMs(1000))));
3133        assert_eq!(c.poll_timeout(t0 + Duration::from_secs(9)), None);
3134    }
3135
3136    /// What the client reads from `EXT-X-PROGRAM-DATE-TIME`: the production parser, in Unix
3137    /// milliseconds.
3138    fn program_date_ms(text: &str) -> Option<i64> {
3139        broadcast_hls::ProgramDateTime::parse(text)
3140            .ok()
3141            .map(|pdt| pdt.timestamp().as_millisecond())
3142    }
3143
3144    /// The spellings the playlist parser accepts beyond strict RFC 3339: a zone-less value is UTC
3145    /// (RFC 8216bis) and surrounding whitespace is ignored.
3146    #[test]
3147    fn program_date_time_accepts_a_zone_less_value_as_utc_and_ignores_whitespace() {
3148        assert_eq!(
3149            program_date_ms("2026-10-02T10:00:00"),
3150            Some(1_790_935_200_000)
3151        );
3152        assert_eq!(
3153            program_date_ms(" 2026-10-02T10:00:00Z "),
3154            Some(1_790_935_200_000)
3155        );
3156        assert_eq!(program_date_ms("not a date"), None);
3157    }
3158
3159    #[test]
3160    fn program_date_time_accepts_the_common_spellings() {
3161        assert_eq!(
3162            program_date_ms("2026-10-02 10:00:00Z"),
3163            Some(1_790_935_200_000),
3164            "space separator"
3165        );
3166        assert_eq!(
3167            program_date_ms("2026-10-02t10:00:00z"),
3168            Some(1_790_935_200_000),
3169            "lower-case t and z"
3170        );
3171        assert_eq!(
3172            program_date_ms("2026-10-02T10:00:00.123456789Z"),
3173            Some(1_790_935_200_123),
3174            "extra digits truncated"
3175        );
3176        assert_eq!(
3177            program_date_ms("1969-12-31T23:59:59Z"),
3178            Some(-1_000),
3179            "before the epoch"
3180        );
3181    }
3182
3183    #[test]
3184    fn rfc3339_offset_spellings_all_name_the_same_instant() {
3185        let want = Some(1_790_935_200_000);
3186        for text in [
3187            "2026-10-02T12:00:00+02:00",
3188            "2026-10-02T12:00:00+0200",
3189            "2026-10-02T05:00:00-05:00",
3190            "2026-10-02T05:00:00-0500",
3191            "2026-10-02T10:00:00+00:00",
3192            "2026-10-02T10:00:00-00:00",
3193        ] {
3194            assert_eq!(program_date_ms(text), want, "{text}");
3195        }
3196        // An hour-only offset: whatever `jiff` decides, it must never be a guess.
3197        let hour_only = program_date_ms("2026-10-02T12:00:00+02");
3198        assert!(hour_only == want || hour_only.is_none(), "{hour_only:?}");
3199    }
3200
3201    /// A leap second is clamped to :59 (jiff), where the old parser added 60 s to the minute.
3202    #[test]
3203    fn a_leap_second_is_clamped_to_the_last_second_of_the_minute() {
3204        assert_eq!(
3205            program_date_ms("2016-12-31T23:59:60Z"),
3206            Some(1_483_228_799_000)
3207        );
3208    }
3209
3210    #[test]
3211    fn rfc3339_parses_to_literal_epoch_milliseconds() {
3212        // 2026-10-02T10:00:00Z = 1_790_935_200 s (independently: `date -u -d`).
3213        assert_eq!(
3214            program_date_ms("2026-10-02T10:00:00Z"),
3215            Some(1_790_935_200_000)
3216        );
3217        assert_eq!(
3218            program_date_ms("2026-10-02T10:00:00.250Z"),
3219            Some(1_790_935_200_250)
3220        );
3221        assert_eq!(
3222            program_date_ms("2026-10-02T10:00:00.5Z"),
3223            Some(1_790_935_200_500)
3224        );
3225        assert_eq!(program_date_ms("1970-01-01T00:00:00Z"), Some(0));
3226        assert_eq!(
3227            program_date_ms("2000-02-29T23:59:59Z"),
3228            Some(951_868_799_000)
3229        );
3230        // The same instant with an offset, both spellings.
3231        assert_eq!(
3232            program_date_ms("2026-10-02T12:00:00+02:00"),
3233            Some(1_790_935_200_000)
3234        );
3235        assert_eq!(
3236            program_date_ms("2026-10-02T05:00:00-0500"),
3237            Some(1_790_935_200_000)
3238        );
3239        // A zone-less date-time is UTC (RFC 8216bis); a bare date is not a date-time
3240        // (RFC 8216 §4.3.2.6) and everything else that is not RFC 3339 is `None`, never a guess.
3241        assert_eq!(program_date_ms("2026-10-02"), None, "a bare date");
3242        for bad in [
3243            "",
3244            "2026-13-02T10:00:00Z",
3245            "2026-10-02T25:00:00Z",
3246            "2026-10-02T10:00:00Zjunk",
3247            "2026-10-02T10:00:00.Z",
3248            "2026-10-02T10:00:00+2:00",
3249            "xxxx-10-02T10:00:00Z",
3250        ] {
3251            assert_eq!(program_date_ms(bad), None, "{bad:?}");
3252        }
3253    }
3254}