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