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