Skip to main content

broadcast_hls/
lib.rs

1//! HLS playlist generation — RFC 8216.
2//!
3//! Produces `#EXTM3U`-formatted media and master playlists from structured
4//! data, suitable for VOD and live CMAF workflows.
5//!
6//! # Trick-play (I-frame-only) signalling
7//!
8//! HLS supports two complementary tags for trick-play (timeline scrubbing /
9//! thumbnail extraction) renditions; both are strictly opt-in so existing
10//! playlists are byte-for-byte unchanged:
11//!
12//! - **`#EXT-X-I-FRAME-STREAM-INF`** (RFC 8216 §4.3.4.2) — a master-playlist
13//!   tag declaring an I-frame-only rendition.  Unlike `#EXT-X-STREAM-INF` the
14//!   URI is an *attribute* on the tag line itself, not a following line.  Add
15//!   one [`IFrameVariant`] per rendition to [`MasterPlaylist::iframe_variants`];
16//!   `to_m3u8` renders each as
17//!   `#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=<n>[,CODECS="<c>"][,RESOLUTION=<w>x<h>],URI="<uri>"`.
18//!
19//! - **`#EXT-X-I-FRAMES-ONLY`** (RFC 8216 §4.3.3.6) — a media-playlist tag
20//!   declaring that every segment carries a single I-frame.  Set
21//!   [`MediaPlaylist::iframes_only`] to `true`; `to_m3u8` emits the tag in
22//!   the header block (after the version line).  RFC 8216 §4.3.3.6 requires
23//!   protocol version ≥ 4 when this tag is present; the renderer computes
24//!   this as one input to the general `#EXT-X-VERSION` derivation (see
25//!   "Protocol version derivation" below), not as a special case.
26//!
27//! # Discontinuity support
28//!
29//! The playlist model supports RFC 8216 discontinuity signalling:
30//!
31//! - **`#EXT-X-DISCONTINUITY`** (RFC 8216 §4.3.4.3) — a marker emitted
32//!   immediately before the `#EXTINF` of a discontinuous [`MediaSegment`]
33//!   (one whose [`MediaSegment::discontinuous`] flag is `true`). It signals
34//!   a break in the media timeline between the preceding segment and the one
35//!   that follows it (change in encoding, timestamps, tracks, or format).
36//!
37//! - **`#EXT-X-DISCONTINUITY-SEQUENCE`** (RFC 8216 §4.3.3.3) — a header
38//!   tag equal to the count of discontinuities that have already rolled off
39//!   the front of a live/sliding-window playlist. Emitted as
40//!   `#EXT-X-DISCONTINUITY-SEQUENCE:<n>` when `n > 0`; absent (defaulting
41//!   to 0) otherwise.
42//!
43//! A caller assembling segments (e.g. the `transmux` crate's `Segmenter`,
44//! via its `mark_discontinuity` method) marks the next cut as discontinuous;
45//! a segmenter also typically auto-detects init-segment changes and marks
46//! those cuts automatically (see [`mark_init_discontinuities`] below).
47//!
48//! # Low-Latency HLS (RFC 8216bis)
49//!
50//! Low-Latency HLS (LL-HLS — the HLS 2nd edition draft, *RFC 8216bis*) drives
51//! end-to-end latency below one segment duration by publishing each segment's
52//! **partial segments** ("parts", RFC 8216bis §4.4.4.9) as they are produced,
53//! before the parent segment is complete. This model adds four opt-in playlist
54//! directives, all rendered only when [`MediaPlaylist::low_latency`] is set (so a
55//! plain playlist is byte-for-byte unchanged):
56//!
57//! - **`#EXT-X-SERVER-CONTROL`** (RFC 8216bis §4.4.3.8) — the header carries
58//!   `CAN-BLOCK-RELOAD=YES` (the server supports blocking playlist reload) and
59//!   `PART-HOLD-BACK=<sec>` (how far from the live edge a client may play parts).
60//!   Per the spec, `PART-HOLD-BACK` MUST be at least **three times** the
61//!   part-target duration.
62//! - **`#EXT-X-PART-INF:PART-TARGET=<sec>`** (RFC 8216bis §4.4.3.7) — the header
63//!   declaring the part-target duration.
64//! - **`#EXT-X-PART:DURATION=<sec>,URI="<uri>"[,INDEPENDENT=YES]`**
65//!   (RFC 8216bis §4.4.4.9) — one line per part, emitted before the parent
66//!   segment's `#EXTINF`. `INDEPENDENT=YES` marks a part that begins with an
67//!   independently decodable frame (a sync sample).
68//! - **`#EXT-X-PRELOAD-HINT:TYPE=PART,URI="<next-part-uri>"`**
69//!   (RFC 8216bis §4.4.5.3) — hints the URI of the next, not-yet-available part
70//!   so a client can request it ahead of time.
71//!
72//! A live origin's trailing segment is often still *open* — being filled in by
73//! new parts as they are produced, not yet closed with a duration and URI.
74//! [`MediaPlaylist::open_segment`] carries that in-progress
75//! [`OpenSegment`]'s known parts; `to_m3u8` renders them as trailing
76//! `#EXT-X-PART` lines with **no** `#EXTINF`/URI (RFC 8216bis §4.4.4.9), same
77//! opt-in gating as the closed segments' parts above.
78//!
79//! # Protocol version derivation (RFC 8216bis §8, issue #871)
80//!
81//! `#EXT-X-VERSION` is never chosen ahead of time — it is *computed* as the
82//! `max()` of the minimums the playlist's actual content triggers, per the
83//! feature → minimum-version table transcribed at
84//! `docs/version-compatibility.md` (twice-verified against
85//! draft-pantos-hls-rfc8216bis-22 §8). [`MediaPlaylist::computed_version`]
86//! and [`MasterPlaylist::computed_version`] expose this directly; `to_m3u8`
87//! uses it internally. A Playlist that triggers nothing (fully compatible
88//! with version 1) carries **no** `#EXT-X-VERSION` tag at all, per §8's
89//! opening rule.
90//!
91//! [`MediaPlaylist::version`]/[`MasterPlaylist::version`] stay a *settable*
92//! floor rather than becoming computed-only: `0` (the field's `Default`)
93//! means "no explicit floor" (render exactly the computed value, or nothing);
94//! a nonzero value is raised — never lowered — to the computed minimum, so a
95//! caller can still deliberately over-declare (e.g. the backward-compatible
96//! `EXT-X-MEDIA`/`AUDIO`/`VIDEO`/`SUBTITLES` MAY-rule in §8) but can never
97//! silently under-declare an invalid playlist.
98//!
99//! This replaces a real bug (issue #871): an LL-HLS origin previously baked
100//! in a hardcoded `EXT-X-VERSION:9` unconditionally, even though none of the
101//! low-latency tags it emits (`EXT-X-PART`/`EXT-X-PART-INF`/
102//! `EXT-X-PRELOAD-HINT`/`EXT-X-SERVER-CONTROL`) carry any version
103//! requirement at all — only `EXT-X-SKIP` does. RFC 8216 §7: "A client MUST
104//! NOT attempt playback if it does not support the protocol version
105//! specified by the EXT-X-VERSION tag" — so over-declaring silently locks
106//! out every client that supports the playlist's true (lower) requirement.
107//!
108//! # CENC/CBCS DRM signalling (ISO/IEC 23001-7, issue #564)
109//!
110//! [`cenc_ext_x_key`] renders the `#EXT-X-KEY` tag line for a `cbcs`
111//! (AES-128 pattern CBC)-protected CMAF track — the CMAF-HLS case Apple's
112//! HLS authoring guidance carries as `METHOD=SAMPLE-AES`. Push the returned
113//! line into [`MediaPlaylist::extra_tags`] (before the segments it
114//! protects). `cenc` (AES-128 full-block CTR) has **no** valid HLS `METHOD`
115//! — CTR is not one of HLS's two encryption methods (`SAMPLE-AES`/
116//! `AES-128`, both CBC) — so `cenc`-protected CMAF is signalling-only on the
117//! DASH side (the `transmux` crate's `dash` module); `cenc_ext_x_key` returns
118//! `None` rather than emit an invalid tag.
119//!
120//! # Parsing (RFC 8216bis, issue #717 slice 1)
121//!
122//! [`MediaPlaylist::parse`] and [`MasterPlaylist::parse`] are the symmetric
123//! *inverse* of `to_m3u8()`: they parse an m3u8 string back into the same
124//! structs the renderer consumes, so an LL-HLS **client** (issue #717) can
125//! reuse the origin's wire model rather than growing a second one. Recognized
126//! tags are the ones listed above plus the client-relevant LL-HLS tags —
127//! `#EXT-X-BYTERANGE`, `#EXT-X-MAP`, `#EXT-X-SKIP`, `#EXT-X-RENDITION-REPORT`
128//! and the `BYTERANGE`/`GAP`/`CAN-SKIP-UNTIL`/preload-hint-byte-range
129//! attributes. Unrecognized tags are preserved verbatim into
130//! [`MediaPlaylist::extra_tags`] (never an error — forward-compat); a
131//! malformed *known* tag (missing required attribute, unparsable value)
132//! returns [`crate::Error::HlsParse`].
133//!
134//! Known, documented gaps (data the current struct shape cannot yet carry,
135//! called out per the project's round-trip-fidelity discipline rather than
136//! silently dropped):
137//! - `#EXT-X-MEDIA` (Multivariant Playlist alternate audio/subtitle
138//!   renditions) is not modeled with typed fields — but `MasterPlaylist`
139//!   now has its own `extra_tags` (mirroring [`MediaPlaylist::extra_tags`]):
140//!   `MasterPlaylist::parse` preserves an unrecognized `#EXT-...` tag like
141//!   this one verbatim, and `to_m3u8` re-renders it, so it round-trips (just
142//!   without structured field access) rather than being silently dropped.
143//! - `#EXT-X-MAP` is carried on [`MediaSegment::map`] with carry-forward
144//!   parse semantics (a map applies to every following segment until the
145//!   next `EXT-X-MAP`, per spec) and dedup-render semantics (re-emitted only
146//!   when it changes from the previous segment). A hand-built
147//!   [`MediaPlaylist`] whose segments' `map` fields are *not* a valid
148//!   carry-forward sequence (e.g. reverting to `None` after a `Some`) cannot
149//!   round-trip, since the wire format has no way to say "stop applying the
150//!   map" short of `#EXT-X-DISCONTINUITY` + a new `#EXT-X-MAP`.
151//! - A per-segment tag outside the recognized set above (e.g.
152//!   `#EXT-X-PROGRAM-DATE-TIME`, a segment-scoped `#EXT-X-KEY`) is captured
153//!   into the flat, playlist-level [`MediaPlaylist::extra_tags`] — the data
154//!   is preserved, not dropped, but re-rendering loses its original
155//!   interleaved position (extra tags always render as one block before all
156//!   segments, matching `to_m3u8()`'s existing placement).
157//! - [`MediaSegment::bitrate`] (`#EXT-X-BITRATE`, RFC 8216bis §4.4.4.8) uses
158//!   the same carry-forward + dedup-render rule as `map` above; the spec's
159//!   producer-side constraint that the tag "does not apply" to a segment
160//!   carrying its own `#EXT-X-BYTERANGE` is not enforced here (the value is
161//!   still carried and rendered on such a segment if present).
162//!
163//! # Issue #872: the remaining 9 of RFC 8216bis §4.4's 32 tags
164//!
165//! `#EXT-X-INDEPENDENT-SEGMENTS` (§4.4.2.1), `#EXT-X-START` (§4.4.2.2,
166//! [`StartPoint`]), `#EXT-X-DEFINE` (§4.4.2.3, [`Define`]) are valid in
167//! either playlist type, so [`MediaPlaylist`] and [`MasterPlaylist`] each
168//! carry their own copies of these fields. `#EXT-X-PLAYLIST-TYPE` (§4.4.3.5,
169//! [`PlaylistType`]), `#EXT-X-GAP` (§4.4.4.7, [`MediaSegment::gap`]) and
170//! `#EXT-X-BITRATE` (§4.4.4.8, [`MediaSegment::bitrate`]) are
171//! [`MediaPlaylist`]-only. `#EXT-X-SESSION-DATA` (§4.4.6.4, [`SessionData`]),
172//! `#EXT-X-SESSION-KEY` (§4.4.6.5, [`SessionKey`]) and
173//! `#EXT-X-CONTENT-STEERING` (§4.4.6.6, [`ContentSteering`]) are
174//! [`MasterPlaylist`]-only. Together with the tags documented above, all 32
175//! §4.4 tags now parse; see `tests/hls_tag_completeness.rs` for the
176//! drift-guard enumerating all 32 by name.
177//!
178//! This crate does not enforce every spec MUST-constraint that requires
179//! cross-tag or cross-file context it cannot see at single-playlist parse
180//! time (e.g. `EXT-X-DEFINE`'s IMPORT/QUERYPARAM resolution against a parent
181//! Multivariant Playlist or a request URI, or any "MUST NOT appear more than
182//! once" rule) — it parses the tag's own attribute grammar and leaves such
183//! semantic validation to a higher-level tool (e.g. `media-doctor`).
184//!
185//! Depends only on `broadcast-common`. `#![no_std]` (+ `alloc`) when the
186//! `std` feature is disabled.
187#![cfg_attr(not(feature = "std"), no_std)]
188#![cfg_attr(docsrs, feature(doc_cfg))]
189#![warn(missing_docs)]
190// Runnable examples, embedded so they render on docs.rs and stay in sync with
191// the actual `examples/*.rs` files (shown, not compiled).
192#![doc = "\n## Runnable examples\n"]
193#![doc = "Run with `cargo run -p broadcast-hls --example <name>`.\n"]
194#![doc = "\n### `build_playlist`\n\n```rust,ignore"]
195#![doc = include_str!("../examples/build_playlist.rs")]
196#![doc = "```\n\n### `parse_playlist`\n\n```rust,ignore"]
197#![doc = include_str!("../examples/parse_playlist.rs")]
198#![doc = "```"]
199
200extern crate alloc;
201
202mod error;
203
204pub use error::{Error, Result};
205
206use alloc::collections::BTreeMap;
207use alloc::format;
208use alloc::string::{String, ToString};
209use alloc::vec::Vec;
210
211use broadcast_common::hex::hex_encode;
212
213/// A CENC protection scheme (`schm.scheme_type`) — ISO/IEC 23001-7 §4.
214///
215/// Re-exported from [`broadcast_common::cenc`], which holds the single
216/// definition. CENC is *Common* Encryption — a container-independent scheme
217/// identity — so it sits below both this crate and `transmux` (which owns the
218/// ISOBMFF `schm`/`tenc`/`senc` boxes carrying it), rather than being defined
219/// twice and converted at the boundary (issues #564, #878).
220pub use broadcast_common::CencScheme;
221
222// ---------------------------------------------------------------------------
223// CENC/CBCS DRM signalling — ISO/IEC 23001-7 `cbcs` over CMAF-HLS (issue #564).
224// ---------------------------------------------------------------------------
225
226/// `KEYFORMAT` for the generic CENC identification (mirrors DASH's
227/// `ContentProtection@schemeIdUri` for the "common" scheme —
228/// ISO/IEC 23001-7 / `urn:mpeg:dash:mp4protection:2011`).
229pub const CENC_KEYFORMAT: &str = "urn:mpeg:dash:mp4protection:2011";
230
231/// `KEYFORMATVERSIONS` for [`CENC_KEYFORMAT`] (there is only version `"1"`).
232pub const CENC_KEYFORMATVERSIONS: &str = "1";
233
234/// Build the `#EXT-X-KEY` tag line for a `cbcs`-protected CMAF track
235/// (RFC 8216 §4.3.2.4 `METHOD=SAMPLE-AES`, `KEYFORMAT`/`KEYFORMATVERSIONS`
236/// per [`CENC_KEYFORMAT`]/[`CENC_KEYFORMATVERSIONS`], plus the `KEYID`
237/// attribute Apple's HLS CMAF/fMP4 authoring guidance uses to identify the
238/// CENC key ID).
239///
240/// Returns `None` for [`CencScheme::Cenc`] (AES-128 full-block CTR): CTR is
241/// not a valid HLS `METHOD` (HLS only speaks `SAMPLE-AES`/`AES-128`, both
242/// CBC), so `cenc`-protected CMAF has no HLS key tag — it is DASH-only (see
243/// the module docs).
244///
245/// `key_uri` is caller-supplied (a key-server URL, `skd://`, or `data:`
246/// URI — no DRM logic lives here) and `kid` is the track's Track Encryption
247/// Box default KID (`tenc.default_kid`, ISO/IEC 14496-12 §8.12.1 — the
248/// `transmux` crate's `cenc::TrackEncryptionBox::default_kid` /
249/// `media::TrackEncryption::tenc::default_kid`).
250pub fn cenc_ext_x_key(scheme: CencScheme, kid: &[u8; 16], key_uri: &str) -> Option<String> {
251    if scheme != CencScheme::Cbcs {
252        return None;
253    }
254    Some(format!(
255        "#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"{key_uri}\",KEYFORMAT=\"{CENC_KEYFORMAT}\",\
256         KEYFORMATVERSIONS=\"{CENC_KEYFORMATVERSIONS}\",KEYID=0x{}",
257        hex_encode(kid)
258    ))
259}
260
261/// A byte sub-range into a resource.
262///
263/// Shared by three tags that all use the same underlying notation:
264/// - `#EXT-X-BYTERANGE:<n>[@<o>]` (RFC 8216bis §4.4.4.2) — [`MediaSegment::byte_range`].
265/// - `#EXT-X-PART`'s `BYTERANGE="<n>[@<o>]"` attribute (RFC 8216bis
266///   §4.4.4.9, "same format as the EXT-X-BYTERANGE tag") — [`PartSpec::byte_range`].
267/// - `#EXT-X-MAP`'s `BYTERANGE="<n>@<o>"` attribute (RFC 8216bis §4.4.4.5) —
268///   [`MapTag::byte_range`]. Unlike the other two, the spec says the offset
269///   `o` is **REQUIRED** here (there is no "previous sub-range" to continue
270///   from for an Initialization Section).
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub struct ByteRange {
273    /// `n` — length of the sub-range in bytes.
274    pub length: u64,
275    /// `o` — byte offset of the sub-range from the start of the resource.
276    /// `None` means "immediately following the previous Media/Partial
277    /// Segment's sub-range of the same resource" (only meaningful for
278    /// `EXT-X-BYTERANGE`/`EXT-X-PART`'s `BYTERANGE`; `EXT-X-MAP`'s
279    /// `BYTERANGE` always carries `Some`).
280    pub offset: Option<u64>,
281}
282
283impl ByteRange {
284    /// Render the `<n>[@<o>]` wire notation (used inside a quoted attribute
285    /// value for `PART`/`MAP`, or as the whole `#EXT-X-BYTERANGE` tag value).
286    fn render(&self) -> String {
287        match self.offset {
288            Some(o) => format!("{}@{o}", self.length),
289            None => format!("{}", self.length),
290        }
291    }
292
293    /// Parse the `<n>[@<o>]` wire notation.
294    fn parse(s: &str, line_no: usize, line: &str) -> Result<Self> {
295        let mut split = s.splitn(2, '@');
296        let n = split.next().unwrap_or("");
297        let length = parse_decimal::<u64>(n, line_no, line, "BYTERANGE length")?;
298        let offset = match split.next() {
299            Some(o) => Some(parse_decimal::<u64>(o, line_no, line, "BYTERANGE offset")?),
300            None => None,
301        };
302        if let Some(o) = offset.filter(|&o| o.checked_add(length).is_none()) {
303            return Err(Error::HlsParse {
304                line_no,
305                line: line.to_string(),
306                reason: format!("BYTERANGE offset ({o}) + length ({length}) overflows u64"),
307            });
308        }
309        Ok(ByteRange { length, offset })
310    }
311}
312
313/// The Media Initialization Section reference of `#EXT-X-MAP` (RFC 8216bis
314/// §4.4.4.5) — see [`MediaSegment::map`] for carry-forward/dedup semantics.
315#[derive(Debug, Clone, PartialEq, Eq, Default)]
316pub struct MapTag {
317    /// `URI` — the resource containing the Media Initialization Section
318    /// (REQUIRED).
319    pub uri: String,
320    /// `BYTERANGE` — a sub-range of `uri` containing just the
321    /// Initialization Section. `None` means the entire resource. The
322    /// offset is always present when this is `Some` (spec requires it here,
323    /// unlike [`MediaSegment::byte_range`]/[`PartSpec::byte_range`]).
324    pub byte_range: Option<ByteRange>,
325    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
326    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
327    pub extra_attrs: Vec<(String, String)>,
328}
329
330/// `TYPE` attribute of `#EXT-X-PRELOAD-HINT` (RFC 8216bis §4.4.5.3).
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
332#[non_exhaustive]
333pub enum PreloadHintType {
334    /// `PART` — the hinted resource is a Partial Segment.
335    #[default]
336    Part,
337    /// `MAP` — the hinted resource is a Media Initialization Section.
338    Map,
339}
340
341impl PreloadHintType {
342    /// The spec token (`"PART"` / `"MAP"`).
343    pub fn name(&self) -> &'static str {
344        match self {
345            PreloadHintType::Part => "PART",
346            PreloadHintType::Map => "MAP",
347        }
348    }
349}
350
351broadcast_common::impl_spec_display!(PreloadHintType);
352
353/// `#EXT-X-RENDITION-REPORT` (RFC 8216bis §4.4.5.4) — a pointer to the
354/// current state of an associated Rendition's own Media Playlist, so an
355/// LL-HLS client following one Rendition can discover how far another has
356/// progressed without polling it.
357#[derive(Debug, Clone, PartialEq, Eq, Default)]
358pub struct RenditionReport {
359    /// `URI` of the Rendition's Media Playlist, relative to the playlist
360    /// carrying this tag (REQUIRED).
361    pub uri: String,
362    /// `LAST-MSN` — Media Sequence Number of the last segment (or partial
363    /// segment, if any) currently in that Rendition (REQUIRED).
364    pub last_msn: u64,
365    /// `LAST-PART` — Part Index of the last partial segment at `last_msn`,
366    /// if that Rendition has partial segments.
367    pub last_part: Option<u64>,
368    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
369    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
370    pub extra_attrs: Vec<(String, String)>,
371}
372
373/// `#EXT-X-SKIP` (RFC 8216bis §4.4.5.2) — present on a Playlist Delta Update
374/// response in place of the segments/tags before the Skip Boundary.
375#[derive(Debug, Clone, PartialEq, Eq, Default)]
376pub struct SkipInfo {
377    /// `SKIPPED-SEGMENTS` — count of Media Segments elided (REQUIRED).
378    pub skipped_segments: u64,
379    /// `RECENTLY-REMOVED-DATERANGES` — `EXT-X-DATERANGE` `ID`s removed from
380    /// the playlist recently (tab-delimited on the wire). Empty when the
381    /// attribute is absent.
382    pub recently_removed_daterange_ids: Vec<String>,
383    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
384    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
385    pub extra_attrs: Vec<(String, String)>,
386}
387
388/// A single partial segment ("part") of a [`MediaSegment`] — RFC 8216bis
389/// §4.4.4.9 (`#EXT-X-PART`).
390///
391/// A part is an independently addressable CMAF chunk (a `moof`+`mdat` fragment)
392/// covering a sub-duration of its parent segment; a client can fetch and play it
393/// before the parent segment is complete. Parts are emitted as `#EXT-X-PART`
394/// lines immediately before the parent segment's `#EXTINF`.
395#[derive(Debug, Clone, PartialEq, Default)]
396pub struct PartSpec {
397    /// The part URI (e.g. `"seg0.1.m4s"`).
398    pub uri: String,
399    /// The part duration in seconds (e.g. `0.334`).
400    pub duration: f64,
401    /// If `true`, render `,INDEPENDENT=YES` — the part begins with an
402    /// independently decodable frame (a sync sample). RFC 8216bis §4.4.4.9.
403    pub independent: bool,
404    /// `BYTERANGE` attribute (RFC 8216bis §4.4.4.9) — the part is a
405    /// sub-range of the resource named by [`Self::uri`], same `<n>[@<o>]`
406    /// format as [`MediaSegment::byte_range`]. `None` when the part is the
407    /// entire resource.
408    pub byte_range: Option<ByteRange>,
409    /// `GAP` attribute (RFC 8216bis §4.4.4.9) — `true` if this partial
410    /// segment is not actually available (a hole in the part list).
411    pub gap: bool,
412    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
413    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
414    pub extra_attrs: Vec<(String, String)>,
415}
416
417/// An in-progress (open) LL-HLS segment: its parts are known and being served,
418/// but the segment is not yet complete, so it carries no `#EXTINF`/URI
419/// (RFC 8216bis §4.4.4.9 — an open segment is represented by its trailing
420/// `#EXT-X-PART` lines only, until it closes).
421#[derive(Debug, Clone, PartialEq)]
422#[non_exhaustive]
423pub struct OpenSegment {
424    /// The parts of the in-progress segment, in order.
425    pub parts: Vec<PartSpec>,
426    /// The Media Initialization Section in effect for this segment (RFC
427    /// 8216bis §4.4.4.5) — `#EXT-X-MAP` applies "until the next `EXT-X-MAP`
428    /// tag or the end of the Playlist", so this carries forward from the
429    /// last `#EXT-X-MAP` seen, whether or not any segment has *closed* yet.
430    /// `None` if no `#EXT-X-MAP` has appeared at all (rare in practice — a
431    /// live LL-HLS playlist's very first segment normally needs one).
432    pub map: Option<MapTag>,
433}
434
435impl OpenSegment {
436    /// Build an open segment from its in-progress parts, with no map (see
437    /// [`Self::with_map`] to attach one).
438    pub fn new(parts: Vec<PartSpec>) -> Self {
439        Self { parts, map: None }
440    }
441
442    /// Attach the Media Initialization Section in effect for this segment.
443    pub fn with_map(mut self, map: MapTag) -> Self {
444        self.map = Some(map);
445        self
446    }
447}
448
449// ---------------------------------------------------------------------------
450// Media or Multivariant Playlist Tags — RFC 8216bis §4.4.2. Valid in either
451// a `MediaPlaylist` or a `MasterPlaylist` (issue #872).
452// ---------------------------------------------------------------------------
453
454/// `#EXT-X-START` (RFC 8216bis §4.4.2.2) — a preferred playback start point.
455/// Valid in either a [`MediaPlaylist`] or a [`MasterPlaylist`].
456#[derive(Debug, Clone, PartialEq)]
457#[non_exhaustive]
458pub struct StartPoint {
459    /// `TIME-OFFSET` — signed seconds from the start of the Playlist
460    /// (positive) or from the end of the last Media Segment (negative).
461    /// REQUIRED.
462    pub time_offset: f64,
463    /// `PRECISE` — if `true`, a client should not render samples before
464    /// `time_offset` within the segment it lands in. Absence on the wire
465    /// means `false` (RFC 8216bis §4.4.2.2).
466    pub precise: bool,
467    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
468    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
469    pub extra_attrs: Vec<(String, String)>,
470}
471
472/// A single `#EXT-X-DEFINE` variable declaration (RFC 8216bis §4.4.2.3).
473/// Unlike every other §4.4.2 tag, `EXT-X-DEFINE` MAY appear more than once
474/// per Playlist, so it is carried as a `Vec` on both [`MediaPlaylist`] and
475/// [`MasterPlaylist`] rather than a single field.
476#[derive(Debug, Clone, PartialEq, Eq)]
477#[non_exhaustive]
478pub enum Define {
479    /// `NAME`/`VALUE` form — declares a Variable with a literal value.
480    Name {
481        /// The Variable Name (`[a-zA-Z0-9_-]` per spec).
482        name: String,
483        /// The Variable Value (MAY be empty).
484        value: String,
485        /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
486        /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
487        extra_attrs: Vec<(String, String)>,
488    },
489    /// `IMPORT` form — imports a Variable of the same name from the parent
490    /// Multivariant Playlist. The spec says this **MUST NOT** occur in a
491    /// [`MasterPlaylist`] (Multivariant Playlist) — only in a
492    /// [`MediaPlaylist`] loaded from one — but that is a cross-file
493    /// constraint this single-playlist parser cannot check, so it is not
494    /// enforced here (see the module docs on this crate's general
495    /// MUST-constraint leniency).
496    Import {
497        /// The imported Variable Name.
498        name: String,
499        /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
500        /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
501        extra_attrs: Vec<(String, String)>,
502    },
503    /// `QUERYPARAM` form — imports a Variable from the query parameter of
504    /// the same name in the Playlist's own URI.
505    QueryParam {
506        /// The Variable Name / query parameter name.
507        name: String,
508        /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
509        /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
510        extra_attrs: Vec<(String, String)>,
511    },
512}
513
514// ---------------------------------------------------------------------------
515// Media Playlist Tags — RFC 8216bis §4.4.3.5.
516// ---------------------------------------------------------------------------
517
518/// `#EXT-X-PLAYLIST-TYPE` (RFC 8216bis §4.4.3.5) mutability declaration —
519/// [`MediaPlaylist`]-only.
520#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521#[non_exhaustive]
522pub enum PlaylistType {
523    /// `EVENT` — segments can only be appended, never removed.
524    Event,
525    /// `VOD` (Video On Demand) — the Playlist can never change.
526    Vod,
527}
528
529impl PlaylistType {
530    /// The spec token (`"EVENT"` / `"VOD"`).
531    pub fn name(&self) -> &'static str {
532        match self {
533            PlaylistType::Event => "EVENT",
534            PlaylistType::Vod => "VOD",
535        }
536    }
537}
538
539broadcast_common::impl_spec_display!(PlaylistType);
540
541// ---------------------------------------------------------------------------
542// Multivariant Playlist Tags — RFC 8216bis §4.4.6.4 / §4.4.6.5 / §4.4.6.6.
543// All three are [`MasterPlaylist`]-only.
544// ---------------------------------------------------------------------------
545
546/// `FORMAT` attribute of `#EXT-X-SESSION-DATA` (RFC 8216bis §4.4.6.4).
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
548#[non_exhaustive]
549pub enum SessionDataFormat {
550    /// `JSON` — the default when the attribute (or the whole `URI`
551    /// attribute it qualifies) is absent.
552    #[default]
553    Json,
554    /// `RAW` — the URI names a binary file.
555    Raw,
556}
557
558impl SessionDataFormat {
559    /// The spec token (`"JSON"` / `"RAW"`).
560    pub fn name(&self) -> &'static str {
561        match self {
562            SessionDataFormat::Json => "JSON",
563            SessionDataFormat::Raw => "RAW",
564        }
565    }
566}
567
568broadcast_common::impl_spec_display!(SessionDataFormat);
569
570/// The mutually-exclusive `VALUE`/`URI` content of `#EXT-X-SESSION-DATA`
571/// (RFC 8216bis §4.4.6.4: "Each ... tag MUST contain either a VALUE or URI
572/// attribute, but not both").
573#[derive(Debug, Clone, PartialEq, Eq)]
574#[non_exhaustive]
575pub enum SessionDataContent {
576    /// `VALUE` — a literal data string.
577    Value(String),
578    /// `URI` (+ `FORMAT`) — a reference to an external resource.
579    Uri {
580        /// The `URI` attribute.
581        uri: String,
582        /// The `FORMAT` attribute — only meaningful here (ignored by the
583        /// spec when `URI` is absent, i.e. for [`SessionDataContent::Value`]).
584        format: SessionDataFormat,
585    },
586}
587
588/// `#EXT-X-SESSION-DATA` (RFC 8216bis §4.4.6.4) — arbitrary session data
589/// carried in a [`MasterPlaylist`] (Multivariant Playlist only). A Playlist
590/// MAY carry multiple entries, including repeats of the same `DATA-ID`
591/// distinguished by `LANGUAGE`.
592#[derive(Debug, Clone, PartialEq, Eq)]
593#[non_exhaustive]
594pub struct SessionData {
595    /// `DATA-ID` — identifies this data value (REQUIRED).
596    pub data_id: String,
597    /// The mutually-exclusive `VALUE`/`URI` payload.
598    pub content: SessionDataContent,
599    /// `LANGUAGE` — an RFC 5646 language tag, typically qualifying a
600    /// [`SessionDataContent::Value`].
601    pub language: Option<String>,
602    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
603    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
604    pub extra_attrs: Vec<(String, String)>,
605}
606
607/// `METHOD` attribute shared by `#EXT-X-KEY`/`#EXT-X-SESSION-KEY`
608/// (RFC 8216bis §4.4.4.4 / §4.4.6.5).
609#[derive(Debug, Clone, Copy, PartialEq, Eq)]
610#[non_exhaustive]
611pub enum EncryptionMethod {
612    /// `NONE` — not encrypted. The spec disallows this value on
613    /// `EXT-X-SESSION-KEY` specifically (not enforced at parse time here).
614    None,
615    /// `AES-128` — whole-segment AES-128-CBC.
616    Aes128,
617    /// `SAMPLE-AES` — per-sample AES (`cbcs` scheme for fMP4).
618    SampleAes,
619    /// `SAMPLE-AES-CTR` — per-sample AES-CTR (`cenc` scheme for fMP4).
620    SampleAesCtr,
621    /// `AES-256-GCM` — whole-segment AES-256-GCM.
622    Aes256Gcm,
623}
624
625impl EncryptionMethod {
626    /// The spec token.
627    pub fn name(&self) -> &'static str {
628        match self {
629            EncryptionMethod::None => "NONE",
630            EncryptionMethod::Aes128 => "AES-128",
631            EncryptionMethod::SampleAes => "SAMPLE-AES",
632            EncryptionMethod::SampleAesCtr => "SAMPLE-AES-CTR",
633            EncryptionMethod::Aes256Gcm => "AES-256-GCM",
634        }
635    }
636}
637
638broadcast_common::impl_spec_display!(EncryptionMethod);
639
640/// `#EXT-X-SESSION-KEY` (RFC 8216bis §4.4.6.5) — preloadable decryption key
641/// info for a [`MasterPlaylist`] (Multivariant Playlist only), carrying the
642/// same attributes as `#EXT-X-KEY` (§4.4.4.4) except that the spec requires
643/// `METHOD` not be `NONE` (enforced at parse time).
644#[derive(Debug, Clone, PartialEq, Eq)]
645#[non_exhaustive]
646pub struct SessionKey {
647    /// `METHOD` (REQUIRED).
648    pub method: EncryptionMethod,
649    /// `URI` — REQUIRED unless `method` is [`EncryptionMethod::None`].
650    pub uri: Option<String>,
651    /// `IV` — 128-bit Initialization Vector.
652    pub iv: Option<[u8; 16]>,
653    /// `KEYFORMAT` — absence on the wire implies `"identity"`.
654    pub keyformat: Option<String>,
655    /// `KEYFORMATVERSIONS` — absence on the wire implies `"1"`.
656    pub keyformatversions: Option<String>,
657    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
658    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
659    pub extra_attrs: Vec<(String, String)>,
660}
661
662/// `#EXT-X-CONTENT-STEERING` (RFC 8216bis §4.4.6.6) — a pointer to a Content
663/// Steering Manifest. [`MasterPlaylist`]-only; at most one per Playlist.
664#[derive(Debug, Clone, PartialEq, Eq)]
665#[non_exhaustive]
666pub struct ContentSteering {
667    /// `SERVER-URI` — the Steering Manifest URI (REQUIRED).
668    pub server_uri: String,
669    /// `PATHWAY-ID` — the Pathway to apply before the first Steering
670    /// Manifest has been obtained.
671    pub pathway_id: Option<String>,
672    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
673    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
674    pub extra_attrs: Vec<(String, String)>,
675}
676
677/// A single media segment in a media playlist.
678#[derive(Debug, Clone, PartialEq, Default)]
679pub struct MediaSegment {
680    /// The segment URI (e.g. `"seg0.m4s"`).
681    pub uri: String,
682    /// The segment duration in seconds (e.g. `9.009`).
683    pub duration: f64,
684    /// If `true`, emit `#EXT-X-DISCONTINUITY` immediately before this
685    /// segment's `#EXTINF` line — RFC 8216 §4.3.4.3.
686    pub discontinuous: bool,
687    /// Low-Latency HLS partial segments of this segment (RFC 8216bis §4.4.4.9).
688    /// Rendered as `#EXT-X-PART` lines *before* this segment's `#EXTINF`, but
689    /// only when the playlist is low-latency (see [`MediaPlaylist::low_latency`]).
690    /// Empty for a non-low-latency playlist or a segment whose parts have already
691    /// been coalesced into the full `#EXTINF`.
692    pub parts: Vec<PartSpec>,
693    /// `#EXT-X-BYTERANGE` (RFC 8216bis §4.4.4.2) — this segment is a
694    /// sub-range of the resource named by [`Self::uri`]. Rendered
695    /// immediately after this segment's `#EXTINF` line, before the URI.
696    /// `None` (the default) means the segment is the entire resource.
697    pub byte_range: Option<ByteRange>,
698    /// `#EXT-X-MAP` (RFC 8216bis §4.4.4.5) applying to this segment. Per
699    /// spec a map applies to every segment following it until the next
700    /// `#EXT-X-MAP`; `to_m3u8` renders the tag only when it differs from the
701    /// previous segment's map (dedup), and [`MediaPlaylist::parse`] carries
702    /// the value forward onto every segment it applies to — so this field
703    /// is `Some` on every segment covered by a given `#EXT-X-MAP`, not just
704    /// the one it was written before.
705    pub map: Option<MapTag>,
706    /// `#EXT-X-GAP` (RFC 8216bis §4.4.4.7) — `true` if this segment's URI
707    /// does not contain media data and should not be loaded by clients.
708    /// Applies to exactly the one segment it is rendered against (no
709    /// carry-forward, unlike [`Self::map`]/[`Self::bitrate`]).
710    pub gap: bool,
711    /// `#EXT-X-BITRATE` (RFC 8216bis §4.4.4.8) in kilobits per second —
712    /// carried forward (same rule as [`Self::map`]) onto every segment
713    /// following the tag until the next `#EXT-X-BITRATE` or the end of the
714    /// Playlist. `to_m3u8` re-emits the tag only when it changes from the
715    /// previous segment (same dedup rule as `#EXT-X-MAP`). The spec says the
716    /// tag does not apply to a segment carrying its own `#EXT-X-BYTERANGE`;
717    /// this crate does not enforce that producer-side constraint (documented
718    /// modeling gap, see the module docs).
719    pub bitrate: Option<u64>,
720}
721
722/// A media playlist (`#EXTM3U` / `#EXTINF` / ...).
723#[derive(Debug, Clone, PartialEq, Default)]
724pub struct MediaPlaylist {
725    /// `#EXT-X-VERSION` — an explicit *floor*, not the rendered value. `0`
726    /// (this type's `Default`) means "no explicit floor": [`Self::to_m3u8`]
727    /// renders exactly [`Self::computed_version`], or no tag at all when
728    /// nothing triggers one. A nonzero value is raised — never lowered — to
729    /// the computed minimum. See the module's "Protocol version derivation"
730    /// docs (issue #871).
731    pub version: u8,
732    /// `#EXT-X-TARGETDURATION` — must be >= the max rounded segment duration.
733    pub target_duration: u32,
734    /// `#EXT-X-MEDIA-SEQUENCE`
735    pub media_sequence: u64,
736    /// `#EXT-X-DISCONTINUITY-SEQUENCE` (RFC 8216 §4.3.3.3) — the count of
737    /// discontinuities that have already rolled off the front of a live
738    /// sliding-window playlist and are no longer represented by any in-window
739    /// `#EXT-X-DISCONTINUITY` tag. Emitted as
740    /// `#EXT-X-DISCONTINUITY-SEQUENCE:<n>` when `n > 0`; omitted when `0`
741    /// (which is the implicit default per the spec).
742    pub discontinuity_sequence: u64,
743    /// Ordered list of segments.
744    pub segments: Vec<MediaSegment>,
745    /// The in-progress (open) segment, if any — rendered as trailing
746    /// `#EXT-X-PART` lines with no `#EXTINF` (LL-HLS live edge).
747    pub open_segment: Option<OpenSegment>,
748    /// If `true`, append `#EXT-X-ENDLIST`.
749    pub endlist: bool,
750    /// Extra tag lines emitted verbatim before segment entries
751    /// (e.g. `#EXT-X-DATERANGE:...`).
752    pub extra_tags: Vec<String>,
753    /// Low-Latency HLS configuration (RFC 8216bis). When `Some`, `to_m3u8`
754    /// renders the LL-HLS directives — `#EXT-X-SERVER-CONTROL`,
755    /// `#EXT-X-PART-INF`, each segment's `#EXT-X-PART` lines, and (if set) the
756    /// `#EXT-X-PRELOAD-HINT`. When `None` (the default), none of these appear —
757    /// LL-HLS is strictly opt-in and a plain playlist is unchanged.
758    pub low_latency: Option<LowLatencyConfig>,
759    /// If `true`, emit `#EXT-X-I-FRAMES-ONLY` (RFC 8216 §4.3.3.6) in the
760    /// header block, declaring that every segment in this playlist carries a
761    /// single I-frame (a trick-play / thumbnail rendition).  When `true` the
762    /// rendered version is at least 4 (RFC 8216 §4.3.3.6 requirement).
763    pub iframes_only: bool,
764    /// `#EXT-X-RENDITION-REPORT` entries (RFC 8216bis §4.4.5.4) — one per
765    /// associated Rendition, pointing an LL-HLS client at that Rendition's
766    /// current playlist state. Rendered after the segment list (and any
767    /// preload hint), in order.
768    pub rendition_reports: Vec<RenditionReport>,
769    /// `#EXT-X-SKIP` (RFC 8216bis §4.4.5.2) — present on a Playlist Delta
770    /// Update response, replacing the segments/tags before the Skip
771    /// Boundary. `None` (the default) means this is a full playlist, not a
772    /// delta update.
773    pub skip: Option<SkipInfo>,
774    /// `#EXT-X-INDEPENDENT-SEGMENTS` (RFC 8216bis §4.4.2.1) — every Media
775    /// Segment in this Playlist can be decoded without information from any
776    /// other segment.
777    pub independent_segments: bool,
778    /// `#EXT-X-START` (RFC 8216bis §4.4.2.2) — a preferred playback start
779    /// point.
780    pub start: Option<StartPoint>,
781    /// `#EXT-X-DEFINE` entries (RFC 8216bis §4.4.2.3) — variable
782    /// declarations/imports, in the order they appeared on the wire.
783    pub defines: Vec<Define>,
784    /// `#EXT-X-PLAYLIST-TYPE` (RFC 8216bis §4.4.3.5) — mutability
785    /// declaration. `None` means the tag was absent (no additional
786    /// restriction beyond Section 6.2.1's defaults).
787    pub playlist_type: Option<PlaylistType>,
788}
789
790/// Low-Latency HLS playlist configuration — RFC 8216bis.
791///
792/// Presence of this config on a [`MediaPlaylist`] switches on the LL-HLS
793/// directives (`#EXT-X-SERVER-CONTROL`, `#EXT-X-PART-INF`, `#EXT-X-PART`,
794/// `#EXT-X-PRELOAD-HINT`); see the module docs for each tag's spec section.
795#[derive(Debug, Clone, PartialEq)]
796pub struct LowLatencyConfig {
797    /// Part-target duration in seconds — the `PART-TARGET` of `#EXT-X-PART-INF`
798    /// (RFC 8216bis §4.4.3.7). Typically 0.2–0.5 s.
799    pub part_target: f64,
800    /// `PART-HOLD-BACK` in seconds — the `#EXT-X-SERVER-CONTROL` attribute
801    /// (RFC 8216bis §4.4.3.8). MUST be at least `3 × part_target`; the renderer
802    /// raises it to that floor if a smaller value is supplied.
803    pub part_hold_back: f64,
804    /// URI of the next, not-yet-available part or map — rendered as
805    /// `#EXT-X-PRELOAD-HINT:TYPE=<...>,URI="<uri>"` (RFC 8216bis §4.4.5.3). When
806    /// `None`, no preload hint is emitted (e.g. an ended playlist).
807    pub preload_hint_part: Option<String>,
808    /// `TYPE` of [`Self::preload_hint_part`]'s hinted resource (RFC 8216bis
809    /// §4.4.5.3): `PART` (a Partial Segment) or `MAP` (a Media
810    /// Initialization Section). Only meaningful when `preload_hint_part` is
811    /// `Some`; defaults to [`PreloadHintType::Part`] (the overwhelmingly
812    /// common case).
813    pub preload_hint_type: PreloadHintType,
814    /// `BYTERANGE-START` of the `#EXT-X-PRELOAD-HINT` tag (RFC 8216bis
815    /// §4.4.5.3) — byte offset of the hinted resource. `None` implies 0.
816    pub preload_hint_byte_range_start: Option<u64>,
817    /// `BYTERANGE-LENGTH` of the `#EXT-X-PRELOAD-HINT` tag (RFC 8216bis
818    /// §4.4.5.3) — length in bytes. `None` means "to the end of the
819    /// resource".
820    pub preload_hint_byte_range_length: Option<u64>,
821    /// `CAN-SKIP-UNTIL` attribute of `#EXT-X-SERVER-CONTROL` (RFC 8216bis
822    /// §4.4.3.8) — the Skip Boundary in seconds, advertising support for
823    /// Playlist Delta Updates (`#EXT-X-SKIP`). `None` omits the attribute.
824    pub can_skip_until: Option<f64>,
825    /// `CAN-BLOCK-RELOAD` attribute of `#EXT-X-SERVER-CONTROL` (RFC 8216bis
826    /// §4.4.3.8) — whether the server supports Blocking Playlist Reload
827    /// (RFC 8216bis §6.2.5.2). `to_m3u8` renders the actual value
828    /// (`YES`/`NO`) rather than assuming `YES`; `MediaPlaylist::parse`
829    /// derives it from the attribute's real wire value, defaulting to
830    /// `false` (per RFC 8216bis) when the attribute — or the whole
831    /// `#EXT-X-SERVER-CONTROL` tag — is absent. A client MUST NOT infer
832    /// blocking-reload support merely from [`MediaPlaylist::low_latency`]
833    /// being `Some`; it must check this field (issue #717 slice 1 gap).
834    pub can_block_reload: bool,
835    /// Unmodeled attributes from `#EXT-X-SERVER-CONTROL`,
836    /// `#EXT-X-PART-INF`, and `#EXT-X-PRELOAD-HINT`, retained so `REQ-`
837    /// prefixed names can fire RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
838    pub extra_attrs: Vec<(String, String)>,
839    /// Unmodeled attributes from `#EXT-X-SERVER-CONTROL` only.
840    pub sc_extra_attrs: Vec<(String, String)>,
841    /// Unmodeled attributes from `#EXT-X-PART-INF` only.
842    pub pi_extra_attrs: Vec<(String, String)>,
843    /// Unmodeled attributes from `#EXT-X-PRELOAD-HINT` only.
844    pub ph_extra_attrs: Vec<(String, String)>,
845    /// `HOLD-BACK` attribute of `#EXT-X-SERVER-CONTROL` (RFC 8216bis
846    /// §4.4.3.8) — the server-recommended minimum distance from the live
847    /// edge for clients NOT playing in Low-Latency Mode. `None` means the
848    /// attribute is absent (the spec default: three times the Target
849    /// Duration). When `Some`, the value MUST be at least three times the
850    /// Target Duration per the spec. Only meaningful when
851    /// [`LowLatencyConfig`] is present; for a non-LL-HLS playlist with a
852    /// custom hold-back, set this on a playlist that also carries the
853    /// `#EXT-X-PART-INF` and `#EXT-X-SERVER-CONTROL` tags.
854    pub hold_back: Option<f64>,
855    /// `CAN-SKIP-DATERANGES` attribute of `#EXT-X-SERVER-CONTROL`
856    /// (RFC 8216bis §4.4.3.8) — enumerated-string `YES` if the server can
857    /// produce Playlist Delta Updates (§6.2.5.1) that skip older
858    /// `#EXT-X-DATERANGE` tags in addition to Media Segments. REQUIRES the
859    /// presence of [`Self::can_skip_until`]; the renderer suppresses this
860    /// attribute when that field is `None` regardless of this value.
861    pub can_skip_dateranges: bool,
862}
863
864impl LowLatencyConfig {
865    /// The `PART-HOLD-BACK` value actually rendered: at least `3 × part_target`
866    /// per RFC 8216bis §4.4.3.8, even if [`Self::part_hold_back`] is smaller.
867    pub fn effective_part_hold_back(&self) -> f64 {
868        let floor = 3.0 * self.part_target;
869        if self.part_hold_back < floor {
870            floor
871        } else {
872            self.part_hold_back
873        }
874    }
875}
876
877impl Default for LowLatencyConfig {
878    /// Defaults `can_block_reload` to `true` — this crate's own LL-HLS
879    /// origin (and every in-repo test/fixture that builds a
880    /// [`LowLatencyConfig`] from scratch via `..Default::default()`) always
881    /// supports blocking reload, matching `to_m3u8()`'s historical
882    /// `CAN-BLOCK-RELOAD=YES` output. This default is never consulted for a
883    /// *parsed* playlist: [`MediaPlaylist::parse`] always derives
884    /// `can_block_reload` from the wire attribute (RFC 8216bis default:
885    /// `false`/absent-means-NO), independent of this `Default` impl.
886    fn default() -> Self {
887        Self {
888            part_target: 0.0,
889            part_hold_back: 0.0,
890            preload_hint_part: None,
891            preload_hint_type: PreloadHintType::default(),
892            preload_hint_byte_range_start: None,
893            preload_hint_byte_range_length: None,
894            can_skip_until: None,
895            can_block_reload: true,
896            extra_attrs: Vec::new(),
897            sc_extra_attrs: Vec::new(),
898            pi_extra_attrs: Vec::new(),
899            ph_extra_attrs: Vec::new(),
900            hold_back: None,
901            can_skip_dateranges: false,
902        }
903    }
904}
905
906// ---------------------------------------------------------------------------
907// Protocol Version Compatibility — RFC 8216bis §8 (issue #871).
908//
909// Source of truth: `docs/version-compatibility.md`, a twice-verified
910// transcription of §8 from draft-pantos-hls-rfc8216bis-22. One named
911// constant per row of that table, cited by row number. `#EXT-X-VERSION` is
912// always `max()` over the minimums the playlist's actual content triggers —
913// never a value picked ahead of time and baked in (the bug this issue
914// fixes: a low-latency origin unconditionally declaring 9 when nothing it
915// emits requires more than 6, forcing every client on 6/7/8 to refuse a
916// stream it could have played).
917// ---------------------------------------------------------------------------
918
919/// §8 row 2 (Media Playlist): the `IV` attribute of `EXT-X-KEY`.
920const VERSION_KEY_IV: u8 = 2;
921/// §8 row 3 (Media Playlist): a floating-point `EXTINF` duration.
922const VERSION_FLOAT_EXTINF: u8 = 3;
923/// §8 row 4 (Media Playlist): `EXT-X-BYTERANGE`, or `EXT-X-I-FRAMES-ONLY`.
924const VERSION_BYTERANGE_OR_IFRAMES_ONLY: u8 = 4;
925/// §8 row 5 (Media Playlist): `EXT-X-KEY` with `METHOD=SAMPLE-AES`, or its
926/// `KEYFORMAT`/`KEYFORMATVERSIONS` attributes, or `EXT-X-MAP` **together
927/// with** `EXT-X-I-FRAMES-ONLY`. (`EXT-X-MAP` alone, without
928/// `EXT-X-I-FRAMES-ONLY`, needs [`VERSION_MAP_WITHOUT_IFRAMES_ONLY`] = 6
929/// instead — the row-6 note in `docs/version-compatibility.md`.)
930const VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY: u8 = 5;
931/// §8 row 6 (Media Playlist): `EXT-X-MAP` in a playlist that does *not* also
932/// carry `EXT-X-I-FRAMES-ONLY`.
933const VERSION_MAP_WITHOUT_IFRAMES_ONLY: u8 = 6;
934/// §8 row 7 (Multivariant Playlist): a `"SERVICE"` value for the
935/// `INSTREAM-ID` attribute of `EXT-X-MEDIA`.
936const VERSION_MEDIA_SERVICE_INSTREAM_ID: u8 = 7;
937/// §8 row 8 (any Playlist): variable substitution.
938const VERSION_VARIABLE_SUBSTITUTION: u8 = 8;
939/// §8 row 9 (any Playlist): `EXT-X-SKIP`.
940const VERSION_SKIP: u8 = 9;
941/// §8 row 10 (any Playlist): an `EXT-X-SKIP` that replaces
942/// `EXT-X-DATERANGE` tags in a Playlist Delta Update — its
943/// `RECENTLY-REMOVED-DATERANGES` attribute is non-empty.
944const VERSION_SKIP_REPLACES_DATERANGE: u8 = 10;
945/// §8 row 11 (any Playlist): `EXT-X-DEFINE` with a `QUERYPARAM` attribute.
946const VERSION_DEFINE_QUERYPARAM: u8 = 11;
947/// §8 row 12 (any Playlist): an attribute whose name starts with `"REQ-"`.
948const VERSION_REQ_ATTRIBUTE: u8 = 12;
949/// §8 row 13 (Multivariant Playlist): `EXT-X-MEDIA` with an `INSTREAM-ID`
950/// attribute for a non-`CLOSED-CAPTIONS` `TYPE`.
951const VERSION_MEDIA_INSTREAM_ID_NON_CC: u8 = 13;
952
953/// RFC 8216bis §4.2.2's variable-substitution marker (`{$name}`, inside a
954/// quoted-string attribute value or a URI) — §8 row 8's trigger. This is
955/// *not* part of the version-compatibility transcription (that section
956/// states only the rule, not the substitution grammar, which lives in a
957/// different part of the spec) — it is a textual heuristic over the opaque
958/// strings this crate already carries (`extra_tags`, segment/variant URIs),
959/// not a modeled `EXT-X-DEFINE` parser.
960const VARIABLE_SUBSTITUTION_MARKER: &str = "{$";
961
962/// Fold `n` into the running maximum `v` — §8's rule is that the highest
963/// version required by any single triggered feature governs the whole
964/// Playlist.
965fn bump_version(v: &mut Option<u8>, n: u8) {
966    *v = Some(match *v {
967        Some(m) => m.max(n),
968        None => n,
969    });
970}
971
972/// `true` if this duration **renders** as a floating-point `EXTINF` value
973/// (§8 row 3).
974///
975/// Deliberately defined as "does [`format_extinf`] emit a decimal point",
976/// not as a numeric property of `duration`, because §8 row 3 constrains what
977/// the *playlist contains* — "A Media Playlist MUST indicate an
978/// EXT-X-VERSION of 3 or higher if it contains: Floating-point EXTINF
979/// duration values" — not what the in-memory type happens to be. Deriving
980/// the predicate from the renderer makes the two impossible to diverge.
981///
982/// They previously did, in both directions, and both were spec violations:
983///
984/// - a whole `4.0` rendered as `#EXTINF:4.000,` — a floating-point value —
985///   while this predicate (integer-millisecond) called it integral, so no
986///   `EXT-X-VERSION` was emitted at all and a v1/v2 client was told the
987///   playlist was compatible with it before meeting a float it cannot parse;
988/// - a sub-millisecond `4.0004` rendered at full precision as
989///   `#EXTINF:4.0004,` (correctly, since issue #872) while the same
990///   millisecond-granular rounding still called it integral.
991///
992/// Note the modeling boundary this implies: a playlist *parsed* from text
993/// that literally said `4.000` reports no row-3 requirement here, because
994/// [`MediaPlaylist`] stores the numeric duration, not its original lexical
995/// form — and this crate would re-render it as `4`. The claim is about the
996/// playlist this crate emits, which is the one a client will actually read.
997fn is_fractional_duration(duration: f64) -> bool {
998    format_extinf(duration).contains('.')
999}
1000
1001/// `true` if `s` carries a variable-substitution reference (§8 row 8).
1002fn contains_variable_substitution(s: &str) -> bool {
1003    s.contains(VARIABLE_SUBSTITUTION_MARKER)
1004}
1005
1006/// Scan a set of opaque, verbatim tag lines (a playlist's `extra_tags`) for
1007/// the §8 triggers that are attributes of tags this crate does not model as
1008/// struct fields: `EXT-X-KEY`'s `IV`/`METHOD`/`KEYFORMAT*` attributes (rows
1009/// 2 and 5), `EXT-X-MEDIA`'s `INSTREAM-ID` (rows 7 and 13 —
1010/// Multivariant-Playlist-only; harmless to scan on a Media Playlist's
1011/// `extra_tags` since that tag never legitimately appears there), and any
1012/// attribute name starting `REQ-` (row 12). Returns the max triggered
1013/// version, or `None`.
1014///
1015/// **Row 11 is handled here only for the raw-line path.** `EXT-X-DEFINE`
1016/// gained a typed representation in issue #872, so a *parsed* playlist's
1017/// `EXT-X-DEFINE` tags land in `defines` and never reach `extra_tags`;
1018/// relying on this scan alone would make row 11 silently stop firing for
1019/// every parsed or programmatically-built playlist. Both `computed_version`
1020/// impls therefore check the typed field, and the arm below is retained
1021/// purely for a caller who hand-pushes a verbatim tag line. `bump_version`
1022/// is a max, so the two paths cannot double-count or disagree.
1023///
1024///
1025/// **Row 12 now fires on typed tags too.** Unknown attributes on modeled tags
1026/// are retained since issue #884 (`extra_attrs` on every attribute-list-bearing
1027/// struct); the typed check in `any_typed_req_attr` and
1028/// `any_media_typed_req_attr` scans those fields so `REQ-` attributes reach
1029/// the version derivation whether they appear on a modeled or an unmodeled
1030/// tag.
1031fn scan_tag_lines_for_version(tags: &[String]) -> Option<u8> {
1032    let mut v: Option<u8> = None;
1033    for tag in tags {
1034        if let Some(rest) = tag.strip_prefix("#EXT-X-KEY:") {
1035            let attrs = parse_attr_list(rest);
1036            if attrs.contains_key("IV") {
1037                bump_version(&mut v, VERSION_KEY_IV);
1038            }
1039            if attrs.get("METHOD").map(String::as_str) == Some("SAMPLE-AES")
1040                || attrs.contains_key("KEYFORMAT")
1041                || attrs.contains_key("KEYFORMATVERSIONS")
1042            {
1043                bump_version(
1044                    &mut v,
1045                    VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY,
1046                );
1047            }
1048        } else if let Some(rest) = tag.strip_prefix("#EXT-X-DEFINE:") {
1049            // Row 11 via the *raw-line* path only. Since issue #872 a parsed
1050            // `EXT-X-DEFINE` lands in the typed `defines` field and never
1051            // reaches here, so this arm no longer covers the common case —
1052            // both `computed_version` impls check `defines` directly. It is
1053            // kept because a caller may still hand-push a verbatim tag line
1054            // into `extra_tags`, and `bump_version` is a max, so the two
1055            // paths cannot double-count or disagree.
1056            if parse_attr_list(rest).contains_key("QUERYPARAM") {
1057                bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
1058            }
1059        } else if let Some(rest) = tag.strip_prefix("#EXT-X-MEDIA:") {
1060            let attrs = parse_attr_list(rest);
1061            if let Some(instream_id) = attrs.get("INSTREAM-ID") {
1062                if instream_id.starts_with("SERVICE") {
1063                    bump_version(&mut v, VERSION_MEDIA_SERVICE_INSTREAM_ID);
1064                }
1065                let is_closed_captions =
1066                    attrs.get("TYPE").map(String::as_str) == Some("CLOSED-CAPTIONS");
1067                if !is_closed_captions {
1068                    bump_version(&mut v, VERSION_MEDIA_INSTREAM_ID_NON_CC);
1069                }
1070            }
1071        }
1072        // Row 12 applies to ANY attribute-bearing tag, not just the three
1073        // handled above — scan every tag's attribute keys uniformly.
1074        if let Some(colon) = tag.find(':')
1075            && parse_attr_list(&tag[colon + 1..])
1076                .keys()
1077                .any(|k| k.starts_with("REQ-"))
1078        {
1079            bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
1080        }
1081    }
1082    v
1083}
1084
1085/// Check whether any extra_attrs on any typed struct carry a `REQ-` prefix
1086/// (RFC 8216bis §8 row 12). `start` is Option<StartPoint>, `defines` is
1087/// `&[Define]`, `sd` is `&[SessionData]`, `sk` is `&[SessionKey]`,
1088/// `cs` is `Option<&ContentSteering>`, `variants` is `&[Variant]`,
1089/// `iframes` is `&[IFrameVariant]`.
1090///
1091/// AttributeName contains only uppercase letters per §4.2, so the `REQ-`
1092/// prefix match is case-sensitive.
1093fn any_typed_req_attr(
1094    start: Option<&StartPoint>,
1095    defines: &[Define],
1096    sd: &[SessionData],
1097    sk: &[SessionKey],
1098    cs: Option<&ContentSteering>,
1099    variants: &[Variant],
1100    iframes: &[IFrameVariant],
1101) -> bool {
1102    let extra_attr_is_req =
1103        |attrs: &[(String, String)]| -> bool { attrs.iter().any(|(k, _)| k.starts_with("REQ-")) };
1104    if start.is_some_and(|s| extra_attr_is_req(&s.extra_attrs)) {
1105        return true;
1106    }
1107    if defines.iter().any(|d| {
1108        let attrs = match d {
1109            Define::Name { extra_attrs, .. } => extra_attrs,
1110            Define::Import { extra_attrs, .. } => extra_attrs,
1111            Define::QueryParam { extra_attrs, .. } => extra_attrs,
1112        };
1113        extra_attr_is_req(attrs)
1114    }) {
1115        return true;
1116    }
1117    if sd.iter().any(|s| extra_attr_is_req(&s.extra_attrs)) {
1118        return true;
1119    }
1120    if sk.iter().any(|s| extra_attr_is_req(&s.extra_attrs)) {
1121        return true;
1122    }
1123    if cs.is_some_and(|c| extra_attr_is_req(&c.extra_attrs)) {
1124        return true;
1125    }
1126    if variants.iter().any(|v| extra_attr_is_req(&v.extra_attrs)) {
1127        return true;
1128    }
1129    if iframes.iter().any(|i| extra_attr_is_req(&i.extra_attrs)) {
1130        return true;
1131    }
1132    false
1133}
1134
1135/// Check whether any extra_attrs on media-playlist-specific typed structs
1136/// carry a `REQ-` prefix: `MapTag` (in segments/open), `PartSpec`,
1137/// `RenditionReport`, `SkipInfo`, `LowLatencyConfig`.
1138fn any_media_typed_req_attr(
1139    segments: &[MediaSegment],
1140    open_segment: Option<&OpenSegment>,
1141    rendition_reports: &[RenditionReport],
1142    skip: Option<&SkipInfo>,
1143    low_latency: Option<&LowLatencyConfig>,
1144) -> bool {
1145    let extra_attr_is_req =
1146        |attrs: &[(String, String)]| -> bool { attrs.iter().any(|(k, _)| k.starts_with("REQ-")) };
1147    if segments.iter().any(|s| {
1148        s.map
1149            .as_ref()
1150            .is_some_and(|m| extra_attr_is_req(&m.extra_attrs))
1151            || s.parts.iter().any(|p| extra_attr_is_req(&p.extra_attrs))
1152    }) {
1153        return true;
1154    }
1155    if let Some(open) = open_segment
1156        && (open
1157            .map
1158            .as_ref()
1159            .is_some_and(|m| extra_attr_is_req(&m.extra_attrs))
1160            || open.parts.iter().any(|p| extra_attr_is_req(&p.extra_attrs)))
1161    {
1162        return true;
1163    }
1164    if rendition_reports
1165        .iter()
1166        .any(|r| extra_attr_is_req(&r.extra_attrs))
1167    {
1168        return true;
1169    }
1170    if skip.is_some_and(|s| extra_attr_is_req(&s.extra_attrs)) {
1171        return true;
1172    }
1173    if low_latency.is_some_and(|ll| extra_attr_is_req(&ll.extra_attrs)) {
1174        return true;
1175    }
1176    false
1177}
1178
1179/// Shared floor/clamp logic behind `MediaPlaylist`'s and `MasterPlaylist`'s
1180/// private `effective_version` methods: `explicit` (the public `version`
1181/// field) acts as a floor over `computed` (the derived minimum), raised —
1182/// never lowered — to it. `0` means "no explicit floor".
1183fn effective_version(explicit: u8, computed: Option<u8>) -> Option<u8> {
1184    match (explicit, computed) {
1185        (0, None) => None,
1186        (0, Some(m)) => Some(m),
1187        (e, None) => Some(e),
1188        (e, Some(m)) => Some(e.max(m)),
1189    }
1190}
1191
1192impl MediaPlaylist {
1193    /// Compute the minimum `#EXT-X-VERSION` this playlist's actual content
1194    /// requires, per RFC 8216bis §8 (`docs/version-compatibility.md`).
1195    /// `None` means the playlist is fully compatible with version 1, which
1196    /// per §8's opening rule need not carry the tag at all.
1197    ///
1198    /// This is `max()` over every triggered rule — see the `VERSION_*`
1199    /// constants above for the rule → row mapping. [`Self::to_m3u8`] uses
1200    /// this (via the private `effective_version`) rather than blindly
1201    /// trusting [`Self::version`]; see that field's docs for how an
1202    /// explicit value interacts with this computed minimum.
1203    pub fn computed_version(&self) -> Option<u8> {
1204        let mut v: Option<u8> = None;
1205
1206        // Rows 5/6: EXT-X-MAP, present either as a structured
1207        // MediaSegment/OpenSegment map, or (a caller that hasn't adopted
1208        // that field, e.g. a raw #EXT-X-MAP: line) in `extra_tags`.
1209        let has_map = self.segments.iter().any(|s| s.map.is_some())
1210            || self.open_segment.as_ref().is_some_and(|o| o.map.is_some())
1211            || self.extra_tags.iter().any(|t| t.starts_with("#EXT-X-MAP:"));
1212        if has_map {
1213            bump_version(
1214                &mut v,
1215                if self.iframes_only {
1216                    VERSION_SAMPLE_AES_OR_KEYFORMAT_OR_MAP_WITH_IFRAMES_ONLY
1217                } else {
1218                    VERSION_MAP_WITHOUT_IFRAMES_ONLY
1219                },
1220            );
1221        }
1222
1223        // Row 4: EXT-X-BYTERANGE, or EXT-X-I-FRAMES-ONLY.
1224        if self.iframes_only || self.segments.iter().any(|s| s.byte_range.is_some()) {
1225            bump_version(&mut v, VERSION_BYTERANGE_OR_IFRAMES_ONLY);
1226        }
1227
1228        // Row 3: floating-point EXTINF duration.
1229        if self
1230            .segments
1231            .iter()
1232            .any(|s| is_fractional_duration(s.duration))
1233        {
1234            bump_version(&mut v, VERSION_FLOAT_EXTINF);
1235        }
1236
1237        // Rows 9/10: EXT-X-SKIP, optionally replacing EXT-X-DATERANGE.
1238        if let Some(skip) = &self.skip {
1239            bump_version(&mut v, VERSION_SKIP);
1240            if !skip.recently_removed_daterange_ids.is_empty() {
1241                bump_version(&mut v, VERSION_SKIP_REPLACES_DATERANGE);
1242            }
1243        }
1244
1245        // Row 11: EXT-X-DEFINE with a QUERYPARAM attribute. Read from the
1246        // typed `defines` (issue #872) — before that, `EXT-X-DEFINE` was
1247        // unmodeled and this row could only be reached by string-scanning
1248        // `extra_tags`, which now never sees the tag at all.
1249        if self
1250            .defines
1251            .iter()
1252            .any(|d| matches!(d, Define::QueryParam { .. }))
1253        {
1254            bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
1255        }
1256
1257        // Row 8: variable substitution, wherever this crate carries a URI
1258        // or tag verbatim. Every string field that can legitimately hold a
1259        // `{$var}` reference is scanned — a URI or attribute value the
1260        // caller supplied, whether it reached us as a modeled field or as
1261        // an opaque `extra_tags` line.
1262        if self
1263            .extra_tags
1264            .iter()
1265            .any(|t| contains_variable_substitution(t))
1266            || self
1267                .segments
1268                .iter()
1269                .any(|s| contains_variable_substitution(&s.uri))
1270            || self.media_playlist_typed_strings_use_substitution()
1271        {
1272            bump_version(&mut v, VERSION_VARIABLE_SUBSTITUTION);
1273        }
1274
1275        // Rows 2/5/7/12/13: EXT-X-KEY's IV/METHOD/KEYFORMAT*, EXT-X-MEDIA's
1276        // INSTREAM-ID, and any REQ- attribute on *unmodeled* tags — none of
1277        // these tags has a modeled struct field in this crate, so `extra_tags`
1278        // remains the substrate for the first three. Row 12 on modeled tags
1279        // is now checked below via the typed extra_attrs scan (issue #884).
1280        // (Row 11 moved to the typed check above.)
1281        if let Some(m) = scan_tag_lines_for_version(&self.extra_tags) {
1282            bump_version(&mut v, m);
1283        }
1284
1285        // Row 12: REQ- attribute on any typed struct — modeled tags that
1286        // carry extra_attrs fields since issue #884.
1287        if any_media_typed_req_attr(
1288            &self.segments,
1289            self.open_segment.as_ref(),
1290            &self.rendition_reports,
1291            self.skip.as_ref(),
1292            self.low_latency.as_ref(),
1293        ) || any_typed_req_attr(self.start.as_ref(), &self.defines, &[], &[], None, &[], &[])
1294        {
1295            bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
1296        }
1297
1298        v
1299    }
1300
1301    /// Row 8 helper: does any *typed* string field of this Media Playlist
1302    /// carry a `{$var}` reference?
1303    ///
1304    /// Segment URIs are checked by the caller; this covers the rest of the
1305    /// places a URI or attribute value lives once it is modeled rather than
1306    /// left in `extra_tags` — `EXT-X-MAP`/`EXT-X-PART` URIs (typed since
1307    /// before #872) and the `EXT-X-DEFINE` values / preload-hint /
1308    /// rendition-report URIs. Missing one of these would under-declare the
1309    /// version for a playlist that genuinely uses substitution.
1310    fn media_playlist_typed_strings_use_substitution(&self) -> bool {
1311        let seg_strings = self.segments.iter().flat_map(|s| {
1312            s.map
1313                .iter()
1314                .map(|m| &m.uri)
1315                .chain(s.parts.iter().map(|p| &p.uri))
1316        });
1317        let open_strings = self.open_segment.iter().flat_map(|o| {
1318            o.map
1319                .iter()
1320                .map(|m| &m.uri)
1321                .chain(o.parts.iter().map(|p| &p.uri))
1322        });
1323        let define_values = self.defines.iter().filter_map(|d| match d {
1324            Define::Name { value, .. } => Some(value),
1325            _ => None,
1326        });
1327        let report_uris = self.rendition_reports.iter().map(|r| &r.uri);
1328        let preload = self
1329            .low_latency
1330            .iter()
1331            .filter_map(|ll| ll.preload_hint_part.as_ref());
1332
1333        seg_strings
1334            .chain(open_strings)
1335            .chain(define_values)
1336            .chain(report_uris)
1337            .chain(preload)
1338            .any(|s| contains_variable_substitution(s))
1339    }
1340
1341    /// The `#EXT-X-VERSION` value actually rendered by [`Self::to_m3u8`]:
1342    /// [`Self::computed_version`], raised — never lowered — to
1343    /// [`Self::version`] when that field carries a nonzero explicit floor.
1344    /// See [`Self::version`]'s docs for the full rule.
1345    fn effective_version(&self) -> Option<u8> {
1346        effective_version(self.version, self.computed_version())
1347    }
1348}
1349
1350impl MediaPlaylist {
1351    /// Render this media playlist as an RFC 8216 `#EXTM3U` string.
1352    ///
1353    /// Emits `#EXT-X-DISCONTINUITY-SEQUENCE:<n>` after the media-sequence
1354    /// header when `discontinuity_sequence > 0` (RFC 8216 §4.3.3.3), and
1355    /// `#EXT-X-DISCONTINUITY` immediately before the `#EXTINF` of every
1356    /// segment whose [`MediaSegment::discontinuous`] flag is `true`
1357    /// (RFC 8216 §4.3.4.3).
1358    ///
1359    /// When [`Self::iframes_only`] is `true`, emits `#EXT-X-I-FRAMES-ONLY`
1360    /// (RFC 8216 §4.3.3.6) in the header block; the version this (and every
1361    /// other triggering feature) requires is derived by
1362    /// [`Self::computed_version`] (see the module's "Protocol version
1363    /// derivation" docs, issue #871) — omitted entirely when nothing in the
1364    /// playlist triggers a version requirement.
1365    pub fn to_m3u8(&self) -> String {
1366        let mut s = String::new();
1367        s.push_str("#EXTM3U\n");
1368        if let Some(version) = self.effective_version() {
1369            s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
1370        }
1371        if self.iframes_only {
1372            s.push_str("#EXT-X-I-FRAMES-ONLY\n");
1373        }
1374        // §4.4.2 tags (valid in either playlist type) — RFC 8216bis
1375        // §4.4.2.1/.2/.3 (issue #872).
1376        if self.independent_segments {
1377            s.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
1378        }
1379        for def in &self.defines {
1380            push_define_line(&mut s, def);
1381        }
1382        if let Some(start) = &self.start {
1383            push_start_line(&mut s, start);
1384        }
1385        s.push_str(&format!("#EXT-X-TARGETDURATION:{}\n", self.target_duration));
1386        s.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", self.media_sequence));
1387        if self.discontinuity_sequence > 0 {
1388            s.push_str(&format!(
1389                "#EXT-X-DISCONTINUITY-SEQUENCE:{}\n",
1390                self.discontinuity_sequence
1391            ));
1392        }
1393        // §4.4.3.5 (issue #872).
1394        if let Some(pt) = self.playlist_type {
1395            s.push_str(&format!("#EXT-X-PLAYLIST-TYPE:{}\n", pt.name()));
1396        }
1397
1398        // Low-Latency HLS header directives (RFC 8216bis §4.4.3.7/§4.4.3.8),
1399        // opt-in via `low_latency`.
1400        if let Some(ll) = &self.low_latency {
1401            // #EXT-X-SERVER-CONTROL — CAN-BLOCK-RELOAD (the actual value,
1402            // not always YES) + PART-HOLD-BACK (>= 3x part-target, enforced
1403            // by effective_part_hold_back) + optional HOLD-BACK +
1404            // optional CAN-SKIP-UNTIL + optional CAN-SKIP-DATERANGES
1405            // (RFC 8216bis §4.4.3.8).
1406            s.push_str(&format!(
1407                "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD={},PART-HOLD-BACK={}",
1408                if ll.can_block_reload { "YES" } else { "NO" },
1409                format_secs(ll.effective_part_hold_back()),
1410            ));
1411            if let Some(hb) = ll.hold_back {
1412                s.push_str(&format!(",HOLD-BACK={}", format_secs(hb)));
1413            }
1414            if let Some(csu) = ll.can_skip_until {
1415                s.push_str(&format!(",CAN-SKIP-UNTIL={}", format_secs(csu)));
1416                if ll.can_skip_dateranges {
1417                    s.push_str(",CAN-SKIP-DATERANGES=YES");
1418                }
1419            }
1420            push_extra_attrs(&mut s, &ll.sc_extra_attrs);
1421            s.push('\n');
1422            // #EXT-X-PART-INF — the part-target duration.
1423            s.push_str(&format!(
1424                "#EXT-X-PART-INF:PART-TARGET={}",
1425                format_secs(ll.part_target),
1426            ));
1427            push_extra_attrs(&mut s, &ll.pi_extra_attrs);
1428            s.push('\n');
1429        }
1430
1431        // #EXT-X-SKIP (RFC 8216bis §4.4.5.2) — a Playlist Delta Update marker
1432        // standing in for the segments/tags before the Skip Boundary.
1433        if let Some(skip) = &self.skip {
1434            s.push_str(&format!(
1435                "#EXT-X-SKIP:SKIPPED-SEGMENTS={}",
1436                skip.skipped_segments
1437            ));
1438            if !skip.recently_removed_daterange_ids.is_empty() {
1439                s.push_str(&format!(
1440                    ",RECENTLY-REMOVED-DATERANGES=\"{}\"",
1441                    skip.recently_removed_daterange_ids.join("\t")
1442                ));
1443            }
1444            push_extra_attrs(&mut s, &skip.extra_attrs);
1445            s.push('\n');
1446        }
1447
1448        for tag in &self.extra_tags {
1449            s.push_str(tag);
1450            s.push('\n');
1451        }
1452
1453        for (i, seg) in self.segments.iter().enumerate() {
1454            if seg.discontinuous {
1455                s.push_str("#EXT-X-DISCONTINUITY\n");
1456            }
1457            // #EXT-X-MAP (RFC 8216bis §4.4.4.5) — emitted only when it
1458            // changes from the previous segment's map, since the tag
1459            // applies "until the next EXT-X-MAP or the end of the Playlist".
1460            let prev_map = if i == 0 {
1461                None
1462            } else {
1463                self.segments[i - 1].map.as_ref()
1464            };
1465            if seg.map.as_ref() != prev_map
1466                && let Some(map) = &seg.map
1467            {
1468                push_map_line(&mut s, map);
1469            }
1470            // #EXT-X-BITRATE (RFC 8216bis §4.4.4.8, issue #872) — same
1471            // carry-forward + dedup-render rule as #EXT-X-MAP above.
1472            let prev_bitrate = if i == 0 {
1473                None
1474            } else {
1475                self.segments[i - 1].bitrate
1476            };
1477            if seg.bitrate != prev_bitrate
1478                && let Some(kbps) = seg.bitrate
1479            {
1480                s.push_str(&format!("#EXT-X-BITRATE:{kbps}\n"));
1481            }
1482            // LL-HLS partial segments precede the parent's #EXTINF
1483            // (RFC 8216bis §4.4.4.9), rendered only for a low-latency playlist.
1484            if self.low_latency.is_some() {
1485                for part in &seg.parts {
1486                    push_part_line(&mut s, part);
1487                }
1488            }
1489            // #EXT-X-GAP (RFC 8216bis §4.4.4.7, issue #872) — applies to
1490            // exactly this segment; rendered immediately before its #EXTINF.
1491            if seg.gap {
1492                s.push_str("#EXT-X-GAP\n");
1493            }
1494            // Format with exactly 3 decimal places per RFC 8216 examples.
1495            s.push_str(&format!("#EXTINF:{},\n", format_extinf(seg.duration)));
1496            // #EXT-X-BYTERANGE (RFC 8216bis §4.4.4.2) — after EXTINF, before
1497            // the URI it applies to.
1498            if let Some(br) = &seg.byte_range {
1499                s.push_str(&format!("#EXT-X-BYTERANGE:{}\n", br.render()));
1500            }
1501            s.push_str(&seg.uri);
1502            s.push('\n');
1503        }
1504
1505        // The in-progress (open) segment at the live edge — its parts are
1506        // known but it has not yet closed, so it carries no #EXTINF/URI
1507        // (RFC 8216bis §4.4.4.9). Rendered only for a low-latency playlist,
1508        // same opt-in gating as the closed segments' parts above.
1509        if self.low_latency.is_some()
1510            && let Some(open) = &self.open_segment
1511        {
1512            // Same dedup-vs-previous rule as the closed segments' loop
1513            // above: `#EXT-X-MAP` applies until a new one is seen, so
1514            // only emit it here if it differs from the last *closed*
1515            // segment's map (or there were no closed segments at all).
1516            let prev_map = self.segments.last().and_then(|s| s.map.as_ref());
1517            if open.map.as_ref() != prev_map
1518                && let Some(map) = &open.map
1519            {
1520                push_map_line(&mut s, map);
1521            }
1522            for part in &open.parts {
1523                push_part_line(&mut s, part);
1524            }
1525        }
1526
1527        // LL-HLS preload hint for the next not-yet-available part or map
1528        // (RFC 8216bis §4.4.5.3) — after the segment list, before ENDLIST.
1529        if let Some(ll) = &self.low_latency
1530            && let Some(uri) = &ll.preload_hint_part
1531        {
1532            s.push_str(&format!(
1533                "#EXT-X-PRELOAD-HINT:TYPE={},URI=\"{uri}\"",
1534                ll.preload_hint_type.name(),
1535            ));
1536            if let Some(start) = ll.preload_hint_byte_range_start {
1537                s.push_str(&format!(",BYTERANGE-START={start}"));
1538            }
1539            if let Some(len) = ll.preload_hint_byte_range_length {
1540                s.push_str(&format!(",BYTERANGE-LENGTH={len}"));
1541            }
1542            push_extra_attrs(&mut s, &ll.ph_extra_attrs);
1543            s.push('\n');
1544        }
1545
1546        // #EXT-X-RENDITION-REPORT entries (RFC 8216bis §4.4.5.4).
1547        for rr in &self.rendition_reports {
1548            s.push_str(&format!(
1549                "#EXT-X-RENDITION-REPORT:URI=\"{}\",LAST-MSN={}",
1550                rr.uri, rr.last_msn
1551            ));
1552            if let Some(lp) = rr.last_part {
1553                s.push_str(&format!(",LAST-PART={lp}"));
1554            }
1555            push_extra_attrs(&mut s, &rr.extra_attrs);
1556            s.push('\n');
1557        }
1558
1559        if self.endlist {
1560            s.push_str("#EXT-X-ENDLIST\n");
1561        }
1562
1563        s
1564    }
1565
1566    /// Parse an RFC 8216bis `#EXTM3U` Media Playlist — the symmetric inverse
1567    /// of [`Self::to_m3u8`]. See the module docs for the recognized-tag list
1568    /// and the documented modeling gaps.
1569    ///
1570    /// Unrecognized `#EXT-...` tags are preserved verbatim into
1571    /// [`Self::extra_tags`] rather than erroring (forward-compat); a
1572    /// non-`#EXT` comment line (RFC 8216 §4.1) is silently ignored. A known
1573    /// tag with a missing required attribute or an unparsable value returns
1574    /// [`crate::Error::HlsParse`].
1575    pub fn parse(input: &str) -> Result<Self> {
1576        // `0` (not `1`): distinguishes "no #EXT-X-VERSION line was present
1577        // on the wire at all" from "the wire explicitly said version 1",
1578        // so a fully-untagged input round-trips back to no tag rather than
1579        // gaining one (see `effective_version`/issue #871).
1580        let mut version: u8 = 0;
1581        let mut target_duration: Option<u32> = None;
1582        let mut media_sequence: u64 = 0;
1583        let mut discontinuity_sequence: u64 = 0;
1584        let mut iframes_only = false;
1585        let mut endlist = false;
1586        let mut extra_tags: Vec<String> = Vec::new();
1587        let mut segments: Vec<MediaSegment> = Vec::new();
1588        let mut rendition_reports: Vec<RenditionReport> = Vec::new();
1589        let mut skip: Option<SkipInfo> = None;
1590        let mut saw_extm3u = false;
1591        // §4.4.2/§4.4.3.5 accumulators (issue #872).
1592        let mut independent_segments = false;
1593        let mut start: Option<StartPoint> = None;
1594        let mut defines: Vec<Define> = Vec::new();
1595        let mut playlist_type: Option<PlaylistType> = None;
1596
1597        // Low-Latency HLS accumulators.
1598        let mut part_target: Option<f64> = None;
1599        let mut part_hold_back: Option<f64> = None;
1600        let mut can_skip_until: Option<f64> = None;
1601        let mut can_skip_dateranges = false;
1602        let mut hold_back: Option<f64> = None;
1603        // RFC 8216bis §4.4.3.8: absent CAN-BLOCK-RELOAD (or an absent
1604        // #EXT-X-SERVER-CONTROL tag entirely) means the server does NOT
1605        // support Blocking Playlist Reload — default false, not the
1606        // `LowLatencyConfig::default()` convenience value of true.
1607        let mut can_block_reload = false;
1608        let mut preload_hint_part: Option<String> = None;
1609        let mut preload_hint_type = PreloadHintType::Part;
1610        let mut preload_hint_byte_range_start: Option<u64> = None;
1611        let mut preload_hint_byte_range_length: Option<u64> = None;
1612        let mut saw_ll_tag = false;
1613        let mut sc_extra_attrs: Vec<(String, String)> = Vec::new();
1614        let mut pi_extra_attrs: Vec<(String, String)> = Vec::new();
1615        let mut ph_extra_attrs: Vec<(String, String)> = Vec::new();
1616
1617        // Per-segment pending state, reset each time a bare URI line closes
1618        // a segment.
1619        let mut current_map: Option<MapTag> = None;
1620        let mut pending_discontinuous = false;
1621        let mut pending_byte_range: Option<ByteRange> = None;
1622        let mut pending_parts: Vec<PartSpec> = Vec::new();
1623        let mut pending_duration: Option<f64> = None;
1624        // §4.4.4.7/§4.4.4.8 per-segment state (issue #872): GAP applies only
1625        // to the next segment; BITRATE carries forward like MAP.
1626        let mut pending_gap = false;
1627        let mut current_bitrate: Option<u64> = None;
1628
1629        for (idx, raw_line) in input.lines().enumerate() {
1630            let line_no = idx + 1;
1631            let mut line = raw_line.trim_end_matches('\r');
1632            if line_no == 1 {
1633                line = line.strip_prefix('\u{feff}').unwrap_or(line);
1634            }
1635            let line = line.trim();
1636            if line.is_empty() {
1637                continue;
1638            }
1639
1640            if line == "#EXTM3U" {
1641                saw_extm3u = true;
1642            } else if let Some(rest) = line.strip_prefix("#EXT-X-VERSION:") {
1643                version = parse_decimal(rest, line_no, line, "EXT-X-VERSION")?;
1644            } else if let Some(rest) = line.strip_prefix("#EXT-X-TARGETDURATION:") {
1645                target_duration = Some(parse_decimal(rest, line_no, line, "EXT-X-TARGETDURATION")?);
1646            } else if let Some(rest) = line.strip_prefix("#EXT-X-MEDIA-SEQUENCE:") {
1647                media_sequence = parse_decimal(rest, line_no, line, "EXT-X-MEDIA-SEQUENCE")?;
1648            } else if let Some(rest) = line.strip_prefix("#EXT-X-DISCONTINUITY-SEQUENCE:") {
1649                discontinuity_sequence =
1650                    parse_decimal(rest, line_no, line, "EXT-X-DISCONTINUITY-SEQUENCE")?;
1651            } else if line == "#EXT-X-I-FRAMES-ONLY" {
1652                iframes_only = true;
1653            } else if line == "#EXT-X-INDEPENDENT-SEGMENTS" {
1654                independent_segments = true;
1655            } else if let Some(rest) = line.strip_prefix("#EXT-X-START:") {
1656                start = Some(parse_start(rest, line_no, line)?);
1657            } else if let Some(rest) = line.strip_prefix("#EXT-X-DEFINE:") {
1658                defines.push(parse_define(rest, line_no, line)?);
1659            } else if let Some(rest) = line.strip_prefix("#EXT-X-PLAYLIST-TYPE:") {
1660                playlist_type = Some(match rest.trim() {
1661                    "EVENT" => PlaylistType::Event,
1662                    "VOD" => PlaylistType::Vod,
1663                    other => {
1664                        return Err(Error::HlsParse {
1665                            line_no,
1666                            line: line.to_string(),
1667                            reason: format!(
1668                                "EXT-X-PLAYLIST-TYPE value {other:?} is neither EVENT nor VOD"
1669                            ),
1670                        });
1671                    }
1672                });
1673            } else if line == "#EXT-X-ENDLIST" {
1674                endlist = true;
1675            } else if line == "#EXT-X-DISCONTINUITY" {
1676                pending_discontinuous = true;
1677            } else if line == "#EXT-X-GAP" {
1678                pending_gap = true;
1679            } else if let Some(rest) = line.strip_prefix("#EXT-X-BITRATE:") {
1680                current_bitrate = Some(parse_decimal(rest, line_no, line, "EXT-X-BITRATE")?);
1681            } else if let Some(rest) = line.strip_prefix("#EXT-X-BYTERANGE:") {
1682                pending_byte_range = Some(ByteRange::parse(rest, line_no, line)?);
1683            } else if let Some(rest) = line.strip_prefix("#EXT-X-MAP:") {
1684                let attrs = parse_attr_list(rest);
1685                let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-MAP")?;
1686                let byte_range = match attrs.get("BYTERANGE") {
1687                    Some(v) => Some(ByteRange::parse(v, line_no, line)?),
1688                    None => None,
1689                };
1690                let extra_attrs = filter_extra_attrs(&attrs, &["URI", "BYTERANGE"]);
1691                current_map = Some(MapTag {
1692                    uri,
1693                    byte_range,
1694                    extra_attrs,
1695                });
1696            } else if let Some(rest) = line.strip_prefix("#EXTINF:") {
1697                let dur_str = rest.split(',').next().unwrap_or(rest);
1698                pending_duration = Some(parse_decimal(dur_str, line_no, line, "EXTINF duration")?);
1699            } else if let Some(rest) = line.strip_prefix("#EXT-X-PART-INF:") {
1700                let attrs = parse_attr_list(rest);
1701                if let Some(v) = attrs.get("PART-TARGET") {
1702                    part_target = Some(parse_decimal(v, line_no, line, "PART-TARGET")?);
1703                }
1704                pi_extra_attrs.extend(filter_extra_attrs(&attrs, &["PART-TARGET"]));
1705                saw_ll_tag = true;
1706            } else if let Some(rest) = line.strip_prefix("#EXT-X-SERVER-CONTROL:") {
1707                let attrs = parse_attr_list(rest);
1708                if let Some(v) = attrs.get("PART-HOLD-BACK") {
1709                    part_hold_back = Some(parse_decimal(v, line_no, line, "PART-HOLD-BACK")?);
1710                }
1711                if let Some(v) = attrs.get("CAN-SKIP-UNTIL") {
1712                    can_skip_until = Some(parse_decimal(v, line_no, line, "CAN-SKIP-UNTIL")?);
1713                }
1714                can_skip_dateranges =
1715                    attrs.get("CAN-SKIP-DATERANGES").map(String::as_str) == Some("YES");
1716                can_block_reload = attrs.get("CAN-BLOCK-RELOAD").map(String::as_str) == Some("YES");
1717                if let Some(v) = attrs.get("HOLD-BACK") {
1718                    hold_back = Some(parse_decimal(v, line_no, line, "HOLD-BACK")?);
1719                }
1720                sc_extra_attrs.extend(filter_extra_attrs(
1721                    &attrs,
1722                    &[
1723                        "PART-HOLD-BACK",
1724                        "CAN-SKIP-UNTIL",
1725                        "CAN-BLOCK-RELOAD",
1726                        "HOLD-BACK",
1727                        "CAN-SKIP-DATERANGES",
1728                    ],
1729                ));
1730                saw_ll_tag = true;
1731            } else if let Some(rest) = line.strip_prefix("#EXT-X-PART:") {
1732                let attrs = parse_attr_list(rest);
1733                let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-PART")?;
1734                let duration_str = attrs.get("DURATION").ok_or_else(|| Error::HlsParse {
1735                    line_no,
1736                    line: line.to_string(),
1737                    reason: "EXT-X-PART missing required DURATION attribute".to_string(),
1738                })?;
1739                let duration = parse_decimal(duration_str, line_no, line, "EXT-X-PART DURATION")?;
1740                let independent = attrs.get("INDEPENDENT").map(String::as_str) == Some("YES");
1741                let gap = attrs.get("GAP").map(String::as_str) == Some("YES");
1742                let byte_range = match attrs.get("BYTERANGE") {
1743                    Some(v) => Some(ByteRange::parse(v, line_no, line)?),
1744                    None => None,
1745                };
1746                let extra_attrs = filter_extra_attrs(
1747                    &attrs,
1748                    &["URI", "DURATION", "INDEPENDENT", "GAP", "BYTERANGE"],
1749                );
1750                pending_parts.push(PartSpec {
1751                    uri,
1752                    duration,
1753                    independent,
1754                    byte_range,
1755                    gap,
1756                    extra_attrs,
1757                });
1758                saw_ll_tag = true;
1759            } else if let Some(rest) = line.strip_prefix("#EXT-X-PRELOAD-HINT:") {
1760                let attrs = parse_attr_list(rest);
1761                preload_hint_type = match attrs.get("TYPE").map(String::as_str) {
1762                    Some("MAP") => PreloadHintType::Map,
1763                    _ => PreloadHintType::Part,
1764                };
1765                preload_hint_part = Some(require_attr(
1766                    &attrs,
1767                    "URI",
1768                    line_no,
1769                    line,
1770                    "EXT-X-PRELOAD-HINT",
1771                )?);
1772                if let Some(v) = attrs.get("BYTERANGE-START") {
1773                    preload_hint_byte_range_start =
1774                        Some(parse_decimal(v, line_no, line, "BYTERANGE-START")?);
1775                }
1776                if let Some(v) = attrs.get("BYTERANGE-LENGTH") {
1777                    preload_hint_byte_range_length =
1778                        Some(parse_decimal(v, line_no, line, "BYTERANGE-LENGTH")?);
1779                }
1780                ph_extra_attrs.extend(filter_extra_attrs(
1781                    &attrs,
1782                    &["TYPE", "URI", "BYTERANGE-START", "BYTERANGE-LENGTH"],
1783                ));
1784                saw_ll_tag = true;
1785            } else if let Some(rest) = line.strip_prefix("#EXT-X-RENDITION-REPORT:") {
1786                let attrs = parse_attr_list(rest);
1787                let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-RENDITION-REPORT")?;
1788                let last_msn = match attrs.get("LAST-MSN") {
1789                    Some(v) => parse_decimal(v, line_no, line, "LAST-MSN")?,
1790                    None => 0,
1791                };
1792                let last_part = match attrs.get("LAST-PART") {
1793                    Some(v) => Some(parse_decimal(v, line_no, line, "LAST-PART")?),
1794                    None => None,
1795                };
1796                let extra_attrs = filter_extra_attrs(&attrs, &["URI", "LAST-MSN", "LAST-PART"]);
1797                rendition_reports.push(RenditionReport {
1798                    uri,
1799                    last_msn,
1800                    last_part,
1801                    extra_attrs,
1802                });
1803            } else if let Some(rest) = line.strip_prefix("#EXT-X-SKIP:") {
1804                let attrs = parse_attr_list(rest);
1805                let skipped_segments_str =
1806                    require_attr(&attrs, "SKIPPED-SEGMENTS", line_no, line, "EXT-X-SKIP")?;
1807                let skipped_segments =
1808                    parse_decimal(&skipped_segments_str, line_no, line, "SKIPPED-SEGMENTS")?;
1809                let recently_removed_daterange_ids = attrs
1810                    .get("RECENTLY-REMOVED-DATERANGES")
1811                    .map(|v| {
1812                        v.split('\t')
1813                            .filter(|s| !s.is_empty())
1814                            .map(ToString::to_string)
1815                            .collect()
1816                    })
1817                    .unwrap_or_default();
1818                let extra_attrs = filter_extra_attrs(
1819                    &attrs,
1820                    &["SKIPPED-SEGMENTS", "RECENTLY-REMOVED-DATERANGES"],
1821                );
1822                skip = Some(SkipInfo {
1823                    skipped_segments,
1824                    recently_removed_daterange_ids,
1825                    extra_attrs,
1826                });
1827            } else if let Some(rest) = line.strip_prefix("#EXT") {
1828                let _ = rest;
1829                // A well-formed but unrecognized tag: preserve verbatim
1830                // (forward-compat) rather than error or drop.
1831                extra_tags.push(line.to_string());
1832            } else if line.starts_with('#') {
1833                // RFC 8216 §4.1: a non-"#EXT" '#' line is a comment — ignore.
1834            } else {
1835                // A bare (non-'#') line is always a Media Segment URI; parts
1836                // have no URI line of their own (their URI is an attribute).
1837                let duration = pending_duration.take().ok_or_else(|| Error::HlsParse {
1838                    line_no,
1839                    line: line.to_string(),
1840                    reason: "media segment URI with no preceding #EXTINF".to_string(),
1841                })?;
1842                segments.push(MediaSegment {
1843                    uri: line.to_string(),
1844                    duration,
1845                    discontinuous: core::mem::take(&mut pending_discontinuous),
1846                    parts: core::mem::take(&mut pending_parts),
1847                    byte_range: pending_byte_range.take(),
1848                    map: current_map.clone(),
1849                    gap: core::mem::take(&mut pending_gap),
1850                    bitrate: current_bitrate,
1851                });
1852            }
1853        }
1854
1855        if !saw_extm3u {
1856            return Err(Error::HlsParse {
1857                line_no: 1,
1858                line: String::new(),
1859                reason: "missing #EXTM3U header".to_string(),
1860            });
1861        }
1862        let target_duration = target_duration.ok_or_else(|| Error::HlsParse {
1863            line_no: 0,
1864            line: String::new(),
1865            reason: "missing required #EXT-X-TARGETDURATION".to_string(),
1866        })?;
1867
1868        // Any parts accumulated but never closed by a following #EXTINF/URI
1869        // are the in-progress (open) segment at the live edge
1870        // (RFC 8216bis §4.4.4.9).
1871        let open_segment = if pending_parts.is_empty() {
1872            None
1873        } else {
1874            let open = OpenSegment::new(pending_parts);
1875            Some(match &current_map {
1876                Some(map) => open.with_map(map.clone()),
1877                None => open,
1878            })
1879        };
1880
1881        if let (Some(start), Some(len)) = (
1882            preload_hint_byte_range_start,
1883            preload_hint_byte_range_length,
1884        ) && start.checked_add(len).is_none()
1885        {
1886            return Err(Error::HlsParse {
1887                line_no: 0,
1888                line: String::new(),
1889                reason: format!(
1890                    "PRELOAD-HINT BYTERANGE-START ({start}) + BYTERANGE-LENGTH ({len}) overflows u64"
1891                ),
1892            });
1893        }
1894
1895        let low_latency = if saw_ll_tag {
1896            let mut all_extra: Vec<(String, String)> = Vec::new();
1897            all_extra.extend(sc_extra_attrs.iter().cloned());
1898            all_extra.extend(pi_extra_attrs.iter().cloned());
1899            all_extra.extend(ph_extra_attrs.iter().cloned());
1900            Some(LowLatencyConfig {
1901                part_target: part_target.unwrap_or(0.0),
1902                part_hold_back: part_hold_back.unwrap_or(0.0),
1903                preload_hint_part,
1904                preload_hint_type,
1905                preload_hint_byte_range_start,
1906                preload_hint_byte_range_length,
1907                can_skip_until,
1908                can_block_reload,
1909                extra_attrs: all_extra,
1910                sc_extra_attrs,
1911                pi_extra_attrs,
1912                ph_extra_attrs,
1913                hold_back,
1914                can_skip_dateranges,
1915            })
1916        } else {
1917            None
1918        };
1919
1920        Ok(MediaPlaylist {
1921            version,
1922            target_duration,
1923            media_sequence,
1924            discontinuity_sequence,
1925            segments,
1926            open_segment,
1927            endlist,
1928            extra_tags,
1929            low_latency,
1930            iframes_only,
1931            rendition_reports,
1932            skip,
1933            independent_segments,
1934            start,
1935            defines,
1936            playlist_type,
1937        })
1938    }
1939}
1940
1941/// Render one `#EXT-X-PART:DURATION=<sec>,URI="<uri>"[,BYTERANGE="<n>[@<o>]"]
1942/// [,INDEPENDENT=YES][,GAP=YES]` line (RFC 8216bis §4.4.4.9) into `s`, shared
1943/// by both a closed segment's parts and an open (in-progress) segment's parts
1944/// so the two can never drift in format.
1945fn push_part_line(s: &mut String, part: &PartSpec) {
1946    s.push_str(&format!(
1947        "#EXT-X-PART:DURATION={},URI=\"{}\"",
1948        format_secs(part.duration),
1949        part.uri,
1950    ));
1951    if let Some(br) = &part.byte_range {
1952        s.push_str(&format!(",BYTERANGE=\"{}\"", br.render()));
1953    }
1954    if part.independent {
1955        s.push_str(",INDEPENDENT=YES");
1956    }
1957    if part.gap {
1958        s.push_str(",GAP=YES");
1959    }
1960    push_extra_attrs(s, &part.extra_attrs);
1961    s.push('\n');
1962}
1963
1964/// Render one `#EXT-X-MAP:URI="<uri>"[,BYTERANGE="<n>@<o>"]` line
1965/// (RFC 8216bis §4.4.4.5).
1966fn push_map_line(s: &mut String, map: &MapTag) {
1967    s.push_str(&format!("#EXT-X-MAP:URI=\"{}\"", map.uri));
1968    if let Some(br) = &map.byte_range {
1969        s.push_str(&format!(",BYTERANGE=\"{}\"", br.render()));
1970    }
1971    push_extra_attrs(s, &map.extra_attrs);
1972    s.push('\n');
1973}
1974
1975/// Render one `#EXT-X-DEFINE:...` line (RFC 8216bis §4.4.2.3, issue #872).
1976fn push_define_line(s: &mut String, def: &Define) {
1977    match def {
1978        Define::Name {
1979            name,
1980            value,
1981            extra_attrs,
1982        } => {
1983            s.push_str(&format!("#EXT-X-DEFINE:NAME=\"{name}\",VALUE=\"{value}\""));
1984            push_extra_attrs(s, extra_attrs);
1985            s.push('\n');
1986        }
1987        Define::Import { name, extra_attrs } => {
1988            s.push_str(&format!("#EXT-X-DEFINE:IMPORT=\"{name}\""));
1989            push_extra_attrs(s, extra_attrs);
1990            s.push('\n');
1991        }
1992        Define::QueryParam { name, extra_attrs } => {
1993            s.push_str(&format!("#EXT-X-DEFINE:QUERYPARAM=\"{name}\""));
1994            push_extra_attrs(s, extra_attrs);
1995            s.push('\n');
1996        }
1997    }
1998}
1999
2000/// Parse an `#EXT-X-DEFINE:<attribute-list>` value (RFC 8216bis §4.4.2.3):
2001/// exactly one of `NAME` (+ required `VALUE`), `IMPORT`, `QUERYPARAM`.
2002fn parse_define(rest: &str, line_no: usize, line: &str) -> Result<Define> {
2003    let attrs = parse_attr_list(rest);
2004    let present = [
2005        attrs.contains_key("NAME"),
2006        attrs.contains_key("IMPORT"),
2007        attrs.contains_key("QUERYPARAM"),
2008    ]
2009    .iter()
2010    .filter(|&&b| b)
2011    .count();
2012    if present != 1 {
2013        return Err(Error::HlsParse {
2014            line_no,
2015            line: line.to_string(),
2016            reason: "EXT-X-DEFINE must contain exactly one of NAME, IMPORT, QUERYPARAM".to_string(),
2017        });
2018    }
2019    if let Some(name) = attrs.get("NAME") {
2020        let value = require_attr(&attrs, "VALUE", line_no, line, "EXT-X-DEFINE")?;
2021        let extra_attrs = filter_extra_attrs(&attrs, &["NAME", "VALUE"]);
2022        Ok(Define::Name {
2023            name: name.clone(),
2024            value,
2025            extra_attrs,
2026        })
2027    } else if let Some(name) = attrs.get("IMPORT") {
2028        let extra_attrs = filter_extra_attrs(&attrs, &["IMPORT"]);
2029        Ok(Define::Import {
2030            name: name.clone(),
2031            extra_attrs,
2032        })
2033    } else {
2034        let name = attrs
2035            .get("QUERYPARAM")
2036            .expect("exactly one of the three checked above")
2037            .clone();
2038        let extra_attrs = filter_extra_attrs(&attrs, &["QUERYPARAM"]);
2039        Ok(Define::QueryParam { name, extra_attrs })
2040    }
2041}
2042
2043/// Append `,NAME=VALUE` (or `,NAME="VALUE"` for values containing special
2044/// characters) for each extra attribute. Sorted by name (already sorted on
2045/// parse via `filter_extra_attrs`, kept sorted here for programmatic
2046/// construction).
2047fn push_extra_attrs(s: &mut String, attrs: &[(String, String)]) {
2048    for (name, value) in attrs {
2049        s.push(',');
2050        s.push_str(name);
2051        s.push('=');
2052        if value.contains(',') || value.contains('"') || value.contains(char::is_whitespace) {
2053            s.push('"');
2054            s.push_str(value);
2055            s.push('"');
2056        } else {
2057            s.push_str(value);
2058        }
2059    }
2060}
2061
2062/// Render the `#EXT-X-START:...` line (RFC 8216bis §4.4.2.2, issue #872).
2063fn push_start_line(s: &mut String, start: &StartPoint) {
2064    s.push_str(&format!(
2065        "#EXT-X-START:TIME-OFFSET={}",
2066        format_signed_secs(start.time_offset)
2067    ));
2068    if start.precise {
2069        s.push_str(",PRECISE=YES");
2070    }
2071    push_extra_attrs(s, &start.extra_attrs);
2072    s.push('\n');
2073}
2074
2075/// Parse the `#EXT-X-START:<attribute-list>` value.
2076fn parse_start(rest: &str, line_no: usize, line: &str) -> Result<StartPoint> {
2077    let attrs = parse_attr_list(rest);
2078    let time_offset_str = require_attr(&attrs, "TIME-OFFSET", line_no, line, "EXT-X-START")?;
2079    let time_offset = parse_decimal(&time_offset_str, line_no, line, "TIME-OFFSET")?;
2080    let precise = attrs.get("PRECISE").map(String::as_str) == Some("YES");
2081    let extra_attrs = filter_extra_attrs(&attrs, &["TIME-OFFSET", "PRECISE"]);
2082    Ok(StartPoint {
2083        time_offset,
2084        precise,
2085        extra_attrs,
2086    })
2087}
2088
2089/// Render one `#EXT-X-SESSION-DATA:...` line (RFC 8216bis §4.4.6.4, issue #872).
2090fn push_session_data_line(s: &mut String, sd: &SessionData) {
2091    s.push_str(&format!("#EXT-X-SESSION-DATA:DATA-ID=\"{}\"", sd.data_id));
2092    match &sd.content {
2093        SessionDataContent::Value(v) => {
2094            s.push_str(&format!(",VALUE=\"{v}\""));
2095        }
2096        SessionDataContent::Uri { uri, format } => {
2097            s.push_str(&format!(",URI=\"{uri}\""));
2098            if *format == SessionDataFormat::Raw {
2099                s.push_str(",FORMAT=RAW");
2100            }
2101        }
2102    }
2103    if let Some(lang) = &sd.language {
2104        s.push_str(&format!(",LANGUAGE=\"{lang}\""));
2105    }
2106    push_extra_attrs(s, &sd.extra_attrs);
2107    s.push('\n');
2108}
2109
2110/// Parse an `#EXT-X-SESSION-DATA:<attribute-list>` value.
2111fn parse_session_data(rest: &str, line_no: usize, line: &str) -> Result<SessionData> {
2112    let attrs = parse_attr_list(rest);
2113    let data_id = require_attr(&attrs, "DATA-ID", line_no, line, "EXT-X-SESSION-DATA")?;
2114    let value = attrs.get("VALUE");
2115    let uri = attrs.get("URI");
2116    let content = match (value, uri) {
2117        (Some(v), None) => SessionDataContent::Value(v.clone()),
2118        (None, Some(u)) => {
2119            let format = match attrs.get("FORMAT").map(String::as_str) {
2120                Some("RAW") => SessionDataFormat::Raw,
2121                _ => SessionDataFormat::Json,
2122            };
2123            SessionDataContent::Uri {
2124                uri: u.clone(),
2125                format,
2126            }
2127        }
2128        (Some(_), Some(_)) => {
2129            return Err(Error::HlsParse {
2130                line_no,
2131                line: line.to_string(),
2132                reason: "EXT-X-SESSION-DATA must not contain both VALUE and URI".to_string(),
2133            });
2134        }
2135        (None, None) => {
2136            return Err(Error::HlsParse {
2137                line_no,
2138                line: line.to_string(),
2139                reason: "EXT-X-SESSION-DATA must contain either VALUE or URI".to_string(),
2140            });
2141        }
2142    };
2143    let language = attrs.get("LANGUAGE").cloned();
2144    let known: &[&str] = match &content {
2145        SessionDataContent::Value(_) => &["DATA-ID", "VALUE", "LANGUAGE"],
2146        SessionDataContent::Uri { .. } => &["DATA-ID", "URI", "FORMAT", "LANGUAGE"],
2147    };
2148    let extra_attrs = filter_extra_attrs(&attrs, known);
2149    Ok(SessionData {
2150        data_id,
2151        content,
2152        language,
2153        extra_attrs,
2154    })
2155}
2156
2157/// Render one `#EXT-X-SESSION-KEY:...` line (RFC 8216bis §4.4.6.5, issue #872).
2158fn push_session_key_line(s: &mut String, sk: &SessionKey) {
2159    s.push_str(&format!("#EXT-X-SESSION-KEY:METHOD={}", sk.method.name()));
2160    if let Some(uri) = &sk.uri {
2161        s.push_str(&format!(",URI=\"{uri}\""));
2162    }
2163    if let Some(iv) = &sk.iv {
2164        s.push_str(&format!(",IV=0x{}", hex_encode(iv)));
2165    }
2166    if let Some(kf) = &sk.keyformat {
2167        s.push_str(&format!(",KEYFORMAT=\"{kf}\""));
2168    }
2169    if let Some(kfv) = &sk.keyformatversions {
2170        s.push_str(&format!(",KEYFORMATVERSIONS=\"{kfv}\""));
2171    }
2172    push_extra_attrs(s, &sk.extra_attrs);
2173    s.push('\n');
2174}
2175
2176/// Parse an `#EXT-X-SESSION-KEY:<attribute-list>` value (same attribute set
2177/// as `#EXT-X-KEY`, RFC 8216bis §4.4.4.4, except METHOD MUST NOT be NONE).
2178fn parse_session_key(rest: &str, line_no: usize, line: &str) -> Result<SessionKey> {
2179    let attrs = parse_attr_list(rest);
2180    let method_str = require_attr(&attrs, "METHOD", line_no, line, "EXT-X-SESSION-KEY")?;
2181    let method = match method_str.as_str() {
2182        "NONE" => {
2183            return Err(Error::HlsParse {
2184                line_no,
2185                line: line.to_string(),
2186                reason: "EXT-X-SESSION-KEY METHOD MUST NOT be NONE (RFC 8216bis §4.4.6.5)"
2187                    .to_string(),
2188            });
2189        }
2190        "AES-128" => EncryptionMethod::Aes128,
2191        "SAMPLE-AES" => EncryptionMethod::SampleAes,
2192        "SAMPLE-AES-CTR" => EncryptionMethod::SampleAesCtr,
2193        "AES-256-GCM" => EncryptionMethod::Aes256Gcm,
2194        other => {
2195            return Err(Error::HlsParse {
2196                line_no,
2197                line: line.to_string(),
2198                reason: format!("EXT-X-SESSION-KEY METHOD value {other:?} is not recognized"),
2199            });
2200        }
2201    };
2202    let uri = attrs.get("URI").cloned();
2203    let iv = match attrs.get("IV") {
2204        Some(v) => Some(parse_iv(v, line_no, line)?),
2205        None => None,
2206    };
2207    let keyformat = attrs.get("KEYFORMAT").cloned();
2208    let keyformatversions = attrs.get("KEYFORMATVERSIONS").cloned();
2209    let extra_attrs = filter_extra_attrs(
2210        &attrs,
2211        &["METHOD", "URI", "IV", "KEYFORMAT", "KEYFORMATVERSIONS"],
2212    );
2213    Ok(SessionKey {
2214        method,
2215        uri,
2216        iv,
2217        keyformat,
2218        keyformatversions,
2219        extra_attrs,
2220    })
2221}
2222
2223/// Parse a `0x`-prefixed (or bare) hexadecimal-sequence attribute value into
2224/// exactly 16 bytes — the 128-bit IV of `#EXT-X-KEY`/`#EXT-X-SESSION-KEY`
2225/// (RFC 8216bis §4.4.4.4). Only the *encoder* half of hex lives in
2226/// `broadcast_common::hex` (see that module's doc comment); each consumer's
2227/// decode error type differs, so the decoder is local to this crate.
2228fn parse_iv(s: &str, line_no: usize, line: &str) -> Result<[u8; 16]> {
2229    let hex = s
2230        .strip_prefix("0x")
2231        .or_else(|| s.strip_prefix("0X"))
2232        .unwrap_or(s);
2233    if hex.len() != 32 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
2234        return Err(Error::HlsParse {
2235            line_no,
2236            line: line.to_string(),
2237            reason: format!("IV value {s:?} is not a 128-bit (32 hex digit) sequence"),
2238        });
2239    }
2240    let mut out = [0u8; 16];
2241    for i in 0..16 {
2242        // Safe: length and hex-digit-ness were just validated above.
2243        out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).expect("validated hex digits");
2244    }
2245    Ok(out)
2246}
2247
2248/// Format a possibly-negative seconds value (RFC 8216bis §4.2
2249/// signed-decimal-floating-point — `#EXT-X-START`'s `TIME-OFFSET`), reusing
2250/// [`format_secs`] for the magnitude.
2251fn format_signed_secs(v: f64) -> String {
2252    if v < 0.0 {
2253        format!("-{}", format_secs(-v))
2254    } else {
2255        format_secs(v)
2256    }
2257}
2258
2259/// Format a non-negative seconds value as an HLS decimal-floating-point
2260/// (RFC 8216bis §4.2), **losslessly**.
2261///
2262/// Prefers the historical compact millisecond form (`0.334`, `1.5`, `6` —
2263/// trailing zeros trimmed) whenever that form re-parses to bit-identical
2264/// `v`, so output for the overwhelmingly common ms-granular case is
2265/// unchanged. When it would not (a real LL-HLS playlist's `2.00004`, a real
2266/// segment's `9.9766`), falls back to `core`'s `Display for f64`, which
2267/// emits the shortest decimal string that round-trips exactly — never
2268/// scientific notation, so the result is always valid §4.2 syntax.
2269///
2270/// The millisecond-rounding this replaced silently corrupted any duration
2271/// finer than 1 ms: `2.00004` rendered as `2`. Caught by round-tripping the
2272/// real Apple `fixtures/hls/real/` playlists (issue #872), which no
2273/// hand-made 3-decimal fixture could have surfaced.
2274fn format_secs(v: f64) -> String {
2275    let millis = (v * 1000.0 + 0.5) as u64;
2276    let whole = millis / 1000;
2277    let frac = millis % 1000;
2278    let compact = if frac == 0 {
2279        format!("{whole}")
2280    } else {
2281        let mut f = format!("{frac:03}");
2282        while f.ends_with('0') {
2283            f.pop();
2284        }
2285        format!("{whole}.{f}")
2286    };
2287    if compact.parse::<f64>() == Ok(v) {
2288        return compact;
2289    }
2290    format!("{v}")
2291}
2292
2293/// Format an `#EXTINF` duration losslessly (RFC 8216bis §4.4.4.1).
2294///
2295/// Three tiers, in order:
2296///
2297/// 1. **An exactly-whole number of seconds renders as an integer** (`4.0` ->
2298///    `4`), so the playlist contains no floating-point duration value and is
2299///    honestly compatible with protocol version 1 (§8 row 3 — see
2300///    [`is_fractional_duration`], which is defined in terms of this
2301///    function). Rendering `4.000` here instead declared a float while
2302///    emitting no `EXT-X-VERSION`, locking a v1/v2 client into a value it
2303///    cannot parse.
2304///
2305///    §4.4.4.1 makes this a MUST, not merely an option: `duration` "is a
2306///    decimal-floating-point **or decimal-integer** number", and "if the
2307///    compatibility version number is less than 3, durations MUST be
2308///    integers". Emitting no `EXT-X-VERSION` tag means version 1, so an
2309///    integral render is the only conforming output. (§4.4.4.1 also SHOULDs
2310///    that durations be floating-point for accuracy — but that is authoring
2311///    advice subordinate to the MUST, and it is satisfied the moment a
2312///    caller supplies a genuinely fractional duration, which every real
2313///    keyframe-cutting segmenter does.)
2314/// 2. Otherwise the historical fixed 3-decimal rendering (`9.009` — the form
2315///    every RFC 8216 example and every existing consumer of this crate
2316///    expects) whenever it re-parses to bit-identical `v`.
2317/// 3. Otherwise the shortest exactly-round-tripping decimal, same rule as
2318///    [`format_secs`]. A hardcoded `{:.3}` alone loses real-world precision —
2319///    Apple's BipBop playlists carry `#EXTINF:9.9766`, which would render
2320///    back as `9.977` (issue #882).
2321fn format_extinf(v: f64) -> String {
2322    if let Some(whole) = whole_seconds(v) {
2323        return format!("{whole}");
2324    }
2325    let three = format!("{v:.3}");
2326    if three.parse::<f64>() == Ok(v) {
2327        return three;
2328    }
2329    format!("{v}")
2330}
2331
2332/// `Some(n)` if `v` is exactly the whole number of seconds `n`, else `None`.
2333///
2334/// Uses an integer cast rather than `f64::fract()`, which is `std`-only and
2335/// unavailable to this `no_std`+`alloc` crate (same constraint that shaped
2336/// [`format_secs`]). The round-trip comparison is what makes it exact: a
2337/// value with any fractional part, however small, fails `(v as u64) as f64
2338/// == v` and is rejected.
2339fn whole_seconds(v: f64) -> Option<u64> {
2340    if !v.is_finite() || !(0.0..WHOLE_SECONDS_CAST_LIMIT).contains(&v) {
2341        return None;
2342    }
2343    let whole = v as u64;
2344    (whole as f64 == v).then_some(whole)
2345}
2346
2347/// Upper bound for the `f64 -> u64` cast in [`whole_seconds`]. Beyond 2^53 an
2348/// `f64` cannot represent consecutive integers anyway, so a duration that
2349/// large is not a meaningful segment length; falling through to the decimal
2350/// path is the safe answer.
2351const WHOLE_SECONDS_CAST_LIMIT: f64 = 9_007_199_254_740_992.0; // 2^53
2352
2353/// Parse a decimal-integer or decimal-floating-point attribute/tag value
2354/// (RFC 8216bis §4.2), returning a structured, contextual
2355/// [`crate::Error::HlsParse`] on failure rather than panicking.
2356fn parse_decimal<T: core::str::FromStr>(
2357    s: &str,
2358    line_no: usize,
2359    line: &str,
2360    what: &str,
2361) -> Result<T> {
2362    s.trim().parse::<T>().map_err(|_| Error::HlsParse {
2363        line_no,
2364        line: line.to_string(),
2365        reason: format!("{what} value {s:?} is not a valid number"),
2366    })
2367}
2368
2369/// Split an HLS `<attribute-list>` (RFC 8216 §4.2: comma-separated
2370/// `AttributeName=AttributeValue` pairs, where a quoted-string value may
2371/// itself contain commas) into a name → value map. Quoted values are
2372/// returned with their surrounding `"` stripped; unquoted (enumerated-string
2373/// / decimal) values are returned as-is.
2374fn parse_attr_list(s: &str) -> BTreeMap<String, String> {
2375    let mut map = BTreeMap::new();
2376    let bytes = s.as_bytes();
2377    let len = bytes.len();
2378    let mut i = 0;
2379    while i < len {
2380        while i < len && (bytes[i] == b',' || bytes[i].is_ascii_whitespace()) {
2381            i += 1;
2382        }
2383        if i >= len {
2384            break;
2385        }
2386        let key_start = i;
2387        while i < len && bytes[i] != b'=' {
2388            i += 1;
2389        }
2390        if i >= len {
2391            // Trailing key with no '=': nothing sane to record, stop.
2392            break;
2393        }
2394        let key = &s[key_start..i];
2395        i += 1; // skip '='
2396        if i < len && bytes[i] == b'"' {
2397            i += 1;
2398            let value_start = i;
2399            while i < len && bytes[i] != b'"' {
2400                i += 1;
2401            }
2402            let value = &s[value_start..i];
2403            if i < len {
2404                i += 1; // skip closing '"'
2405            }
2406            map.insert(key.to_string(), value.to_string());
2407        } else {
2408            let value_start = i;
2409            while i < len && bytes[i] != b',' {
2410                i += 1;
2411            }
2412            map.insert(key.to_string(), s[value_start..i].to_string());
2413        }
2414    }
2415    map
2416}
2417
2418/// From an already-parsed attribute map, collect every attribute whose name
2419/// is not in `known` into a `Vec<(name, value)>`, sorted by name for
2420/// deterministic serialization.
2421fn filter_extra_attrs(attrs: &BTreeMap<String, String>, known: &[&str]) -> Vec<(String, String)> {
2422    attrs
2423        .iter()
2424        .filter(|(k, _)| !known.contains(&k.as_str()))
2425        .map(|(k, v)| (k.clone(), v.clone()))
2426        .collect()
2427}
2428
2429/// Fetch a required attribute from an already-parsed attribute map, or
2430/// return a contextual [`crate::Error::HlsParse`] naming the missing
2431/// attribute and the owning tag.
2432fn require_attr(
2433    attrs: &BTreeMap<String, String>,
2434    key: &str,
2435    line_no: usize,
2436    line: &str,
2437    tag: &str,
2438) -> Result<String> {
2439    attrs.get(key).cloned().ok_or_else(|| Error::HlsParse {
2440        line_no,
2441        line: line.to_string(),
2442        reason: format!("{tag} missing required {key} attribute"),
2443    })
2444}
2445
2446/// A variant stream entry in a master playlist.
2447#[derive(Debug, Clone, PartialEq, Default)]
2448pub struct Variant {
2449    /// `BANDWIDTH` in bits per second.
2450    pub bandwidth: u32,
2451    /// `CODECS` string (e.g. `"avc1.64001e,mp4a.40.2"`).
2452    pub codecs: String,
2453    /// `RESOLUTION` as `(width, height)`, if present.
2454    pub resolution: Option<(u32, u32)>,
2455    /// URI of the media playlist for this variant.
2456    pub uri: String,
2457    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
2458    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
2459    pub extra_attrs: Vec<(String, String)>,
2460}
2461
2462/// An I-frame-only rendition entry for a master playlist — RFC 8216 §4.3.4.2
2463/// (`#EXT-X-I-FRAME-STREAM-INF`).
2464///
2465/// Unlike [`Variant`] / `#EXT-X-STREAM-INF`, the URI is an *attribute* on the
2466/// tag line itself (not on a following line).  Rendered as:
2467/// ```text
2468/// #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=<n>[,CODECS="<c>"][,RESOLUTION=<w>x<h>],URI="<uri>"
2469/// ```
2470#[derive(Debug, Clone, PartialEq, Default)]
2471pub struct IFrameVariant {
2472    /// `BANDWIDTH` in bits per second (required).
2473    pub bandwidth: u32,
2474    /// `CODECS` RFC 6381 string (e.g. `"hvc1.1.6.L93.B0"`).  `None` to omit.
2475    pub codecs: Option<String>,
2476    /// `RESOLUTION` as `(width, height)`.  `None` to omit.
2477    pub resolution: Option<(u32, u32)>,
2478    /// URI of the I-frame-only media playlist.
2479    pub uri: String,
2480    /// Unmodeled attributes, retained so `REQ-` prefixed names can fire
2481    /// RFC 8216bis §8 row 12. Sorted by name on parse (deterministic).
2482    pub extra_attrs: Vec<(String, String)>,
2483}
2484
2485/// A master playlist (`#EXTM3U` / `#EXT-X-STREAM-INF` / ...).
2486#[derive(Debug, Clone, PartialEq, Default)]
2487pub struct MasterPlaylist {
2488    /// `#EXT-X-VERSION` — an explicit *floor*, not the rendered value. `0`
2489    /// means "no explicit floor": [`Self::to_m3u8`] renders exactly
2490    /// [`Self::computed_version`], or no tag at all when nothing triggers
2491    /// one. A nonzero value is raised — never lowered — to the computed
2492    /// minimum. See the module's "Protocol version derivation" docs
2493    /// (issue #871).
2494    pub version: u8,
2495    /// Ordered list of variant streams.
2496    pub variants: Vec<Variant>,
2497    /// Ordered list of I-frame-only renditions (RFC 8216 §4.3.4.2).
2498    ///
2499    /// Each entry is rendered as an `#EXT-X-I-FRAME-STREAM-INF` line with the
2500    /// URI as an attribute (not a following line).  An empty `Vec` (the
2501    /// default) produces no such lines.
2502    pub iframe_variants: Vec<IFrameVariant>,
2503    /// Extra tag lines emitted verbatim after the variant/I-frame-variant
2504    /// entries (e.g. `#EXT-X-MEDIA:...`) — the Multivariant-Playlist
2505    /// counterpart of [`MediaPlaylist::extra_tags`]. [`Self::parse`]
2506    /// preserves any unrecognized `#EXT-...` tag here (forward-compat)
2507    /// instead of dropping it; [`Self::computed_version`] scans these lines
2508    /// for the §8 rows this crate does not model as typed struct fields
2509    /// (rows 7/12/13 — `docs/version-compatibility.md`). Rows 8 and 11 were
2510    /// also scanned here until issue #872 gave `EXT-X-DEFINE` a typed
2511    /// representation; they now read [`Self::defines`] instead.
2512    pub extra_tags: Vec<String>,
2513    /// `#EXT-X-INDEPENDENT-SEGMENTS` (RFC 8216bis §4.4.2.1, issue #872).
2514    pub independent_segments: bool,
2515    /// `#EXT-X-START` (RFC 8216bis §4.4.2.2, issue #872).
2516    pub start: Option<StartPoint>,
2517    /// `#EXT-X-DEFINE` entries (RFC 8216bis §4.4.2.3, issue #872), in wire
2518    /// order. Feeds §8 row 11 via [`Self::computed_version`].
2519    pub defines: Vec<Define>,
2520    /// `#EXT-X-SESSION-DATA` entries (RFC 8216bis §4.4.6.4, issue #872), in
2521    /// wire order.
2522    pub session_data: Vec<SessionData>,
2523    /// `#EXT-X-SESSION-KEY` entries (RFC 8216bis §4.4.6.5, issue #872), in
2524    /// wire order.
2525    pub session_keys: Vec<SessionKey>,
2526    /// `#EXT-X-CONTENT-STEERING` (RFC 8216bis §4.4.6.6, issue #872) — at most
2527    /// one per Playlist.
2528    pub content_steering: Option<ContentSteering>,
2529}
2530
2531/// A parsed but not-yet-closed `#EXT-X-STREAM-INF` — `(bandwidth, codecs,
2532/// resolution)` — awaiting the URI line that turns it into a [`Variant`].
2533type PendingStreamInf = (u32, String, Option<(u32, u32)>, Vec<(String, String)>);
2534
2535impl MasterPlaylist {
2536    /// Render this master playlist as an RFC 8216 `#EXTM3U` string.
2537    ///
2538    /// After the regular `#EXT-X-STREAM-INF` variant lines, emits one
2539    /// `#EXT-X-I-FRAME-STREAM-INF` line per entry in
2540    /// [`Self::iframe_variants`] (RFC 8216 §4.3.4.2).  The URI is rendered
2541    /// as an attribute on the tag line itself — *not* on a following line.
2542    pub fn to_m3u8(&self) -> String {
2543        let mut s = String::new();
2544        s.push_str("#EXTM3U\n");
2545        if let Some(version) = self.effective_version() {
2546            s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
2547        }
2548
2549        for tag in &self.extra_tags {
2550            s.push_str(tag);
2551            s.push('\n');
2552        }
2553
2554        // §4.4.2 tags (issue #872).
2555        if self.independent_segments {
2556            s.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
2557        }
2558        for def in &self.defines {
2559            push_define_line(&mut s, def);
2560        }
2561        if let Some(start) = &self.start {
2562            push_start_line(&mut s, start);
2563        }
2564        // §4.4.6.4/.5/.6 Multivariant Playlist tags (issue #872).
2565        for sk in &self.session_keys {
2566            push_session_key_line(&mut s, sk);
2567        }
2568        for sd in &self.session_data {
2569            push_session_data_line(&mut s, sd);
2570        }
2571        if let Some(cs) = &self.content_steering {
2572            s.push_str(&format!(
2573                "#EXT-X-CONTENT-STEERING:SERVER-URI=\"{}\"",
2574                cs.server_uri
2575            ));
2576            if let Some(pid) = &cs.pathway_id {
2577                s.push_str(&format!(",PATHWAY-ID=\"{pid}\""));
2578            }
2579            push_extra_attrs(&mut s, &cs.extra_attrs);
2580            s.push('\n');
2581        }
2582
2583        for var in &self.variants {
2584            s.push_str(&format!(
2585                "#EXT-X-STREAM-INF:BANDWIDTH={},CODECS=\"{}\"",
2586                var.bandwidth, var.codecs,
2587            ));
2588            if let Some((w, h)) = var.resolution {
2589                s.push_str(&format!(",RESOLUTION={w}x{h}"));
2590            }
2591            push_extra_attrs(&mut s, &var.extra_attrs);
2592            s.push('\n');
2593            s.push_str(&var.uri);
2594            s.push('\n');
2595        }
2596
2597        // I-frame-only renditions — RFC 8216 §4.3.4.2.
2598        // URI is an attribute on the tag line, not a following URI line.
2599        for iv in &self.iframe_variants {
2600            s.push_str(&format!(
2601                "#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH={}",
2602                iv.bandwidth
2603            ));
2604            if let Some(c) = &iv.codecs {
2605                s.push_str(&format!(",CODECS=\"{c}\""));
2606            }
2607            if let Some((w, h)) = iv.resolution {
2608                s.push_str(&format!(",RESOLUTION={w}x{h}"));
2609            }
2610            push_extra_attrs(&mut s, &iv.extra_attrs);
2611            s.push_str(&format!(",URI=\"{}\"\n", iv.uri));
2612        }
2613
2614        s
2615    }
2616
2617    /// Parse an RFC 8216 `#EXTM3U` Multivariant (Master) Playlist — the
2618    /// symmetric inverse of [`Self::to_m3u8`].
2619    ///
2620    /// Recognizes `#EXT-X-VERSION`, `#EXT-X-STREAM-INF` + its following URI
2621    /// line, and `#EXT-X-I-FRAME-STREAM-INF`. `#EXT-X-MEDIA` (alternate
2622    /// audio/subtitle renditions), `#EXT-X-DEFINE`, and any other
2623    /// unrecognized `#EXT-...` tag are not modeled with typed fields, but
2624    /// are preserved verbatim into [`Self::extra_tags`] (forward-compat),
2625    /// mirroring [`MediaPlaylist::parse`]. A malformed
2626    /// `#EXT-X-STREAM-INF`/`#EXT-X-I-FRAME-STREAM-INF` (missing required
2627    /// attribute, unparsable value) or a variant URI with no preceding
2628    /// `#EXT-X-STREAM-INF` returns [`crate::Error::HlsParse`].
2629    pub fn parse(input: &str) -> Result<Self> {
2630        // `0` (not `1`): see the identical comment in `MediaPlaylist::parse`.
2631        let mut version: u8 = 0;
2632        let mut variants: Vec<Variant> = Vec::new();
2633        let mut iframe_variants: Vec<IFrameVariant> = Vec::new();
2634        let mut extra_tags: Vec<String> = Vec::new();
2635        let mut saw_extm3u = false;
2636        let mut pending_stream_inf: Option<PendingStreamInf> = None;
2637        // §4.4.2/§4.4.6.4/.5/.6 accumulators (issue #872).
2638        let mut independent_segments = false;
2639        let mut start: Option<StartPoint> = None;
2640        let mut defines: Vec<Define> = Vec::new();
2641        let mut session_data: Vec<SessionData> = Vec::new();
2642        let mut session_keys: Vec<SessionKey> = Vec::new();
2643        let mut content_steering: Option<ContentSteering> = None;
2644
2645        for (idx, raw_line) in input.lines().enumerate() {
2646            let line_no = idx + 1;
2647            let mut line = raw_line.trim_end_matches('\r');
2648            if line_no == 1 {
2649                line = line.strip_prefix('\u{feff}').unwrap_or(line);
2650            }
2651            let line = line.trim();
2652            if line.is_empty() {
2653                continue;
2654            }
2655
2656            if line == "#EXTM3U" {
2657                saw_extm3u = true;
2658            } else if let Some(rest) = line.strip_prefix("#EXT-X-VERSION:") {
2659                version = parse_decimal(rest, line_no, line, "EXT-X-VERSION")?;
2660            } else if let Some(rest) = line.strip_prefix("#EXT-X-STREAM-INF:") {
2661                let attrs = parse_attr_list(rest);
2662                let bandwidth_str =
2663                    require_attr(&attrs, "BANDWIDTH", line_no, line, "EXT-X-STREAM-INF")?;
2664                let bandwidth = parse_decimal(&bandwidth_str, line_no, line, "BANDWIDTH")?;
2665                let codecs = attrs.get("CODECS").cloned().unwrap_or_default();
2666                let resolution = match attrs.get("RESOLUTION") {
2667                    Some(v) => Some(parse_resolution(v, line_no, line)?),
2668                    None => None,
2669                };
2670                let extra_attrs =
2671                    filter_extra_attrs(&attrs, &["BANDWIDTH", "CODECS", "RESOLUTION"]);
2672                pending_stream_inf = Some((bandwidth, codecs, resolution, extra_attrs));
2673            } else if let Some(rest) = line.strip_prefix("#EXT-X-I-FRAME-STREAM-INF:") {
2674                let attrs = parse_attr_list(rest);
2675                let bandwidth_str = require_attr(
2676                    &attrs,
2677                    "BANDWIDTH",
2678                    line_no,
2679                    line,
2680                    "EXT-X-I-FRAME-STREAM-INF",
2681                )?;
2682                let bandwidth = parse_decimal(&bandwidth_str, line_no, line, "BANDWIDTH")?;
2683                let codecs = attrs.get("CODECS").cloned();
2684                let resolution = match attrs.get("RESOLUTION") {
2685                    Some(v) => Some(parse_resolution(v, line_no, line)?),
2686                    None => None,
2687                };
2688                let uri = require_attr(&attrs, "URI", line_no, line, "EXT-X-I-FRAME-STREAM-INF")?;
2689                let extra_attrs =
2690                    filter_extra_attrs(&attrs, &["BANDWIDTH", "CODECS", "RESOLUTION", "URI"]);
2691                iframe_variants.push(IFrameVariant {
2692                    bandwidth,
2693                    codecs,
2694                    resolution,
2695                    uri,
2696                    extra_attrs,
2697                });
2698            } else if line == "#EXT-X-INDEPENDENT-SEGMENTS" {
2699                independent_segments = true;
2700            } else if let Some(rest) = line.strip_prefix("#EXT-X-START:") {
2701                start = Some(parse_start(rest, line_no, line)?);
2702            } else if let Some(rest) = line.strip_prefix("#EXT-X-DEFINE:") {
2703                defines.push(parse_define(rest, line_no, line)?);
2704            } else if let Some(rest) = line.strip_prefix("#EXT-X-SESSION-DATA:") {
2705                session_data.push(parse_session_data(rest, line_no, line)?);
2706            } else if let Some(rest) = line.strip_prefix("#EXT-X-SESSION-KEY:") {
2707                session_keys.push(parse_session_key(rest, line_no, line)?);
2708            } else if let Some(rest) = line.strip_prefix("#EXT-X-CONTENT-STEERING:") {
2709                let attrs = parse_attr_list(rest);
2710                let server_uri = require_attr(
2711                    &attrs,
2712                    "SERVER-URI",
2713                    line_no,
2714                    line,
2715                    "EXT-X-CONTENT-STEERING",
2716                )?;
2717                let pathway_id = attrs.get("PATHWAY-ID").cloned();
2718                let extra_attrs = filter_extra_attrs(&attrs, &["SERVER-URI", "PATHWAY-ID"]);
2719                content_steering = Some(ContentSteering {
2720                    server_uri,
2721                    pathway_id,
2722                    extra_attrs,
2723                });
2724            } else if let Some(rest) = line.strip_prefix("#EXT") {
2725                let _ = rest;
2726                // A well-formed but still-unmodeled tag (e.g. #EXT-X-MEDIA):
2727                // preserve verbatim (forward-compat, and the substrate
2728                // `computed_version` scans for §8 rows 7/12/13), mirroring
2729                // `MediaPlaylist::parse`. NOTE: this arm must stay LAST of
2730                // the `#EXT` arms — every typed arm above is a more specific
2731                // prefix and would otherwise be shadowed by it.
2732                extra_tags.push(line.to_string());
2733            } else if line.starts_with('#') {
2734                // RFC 8216 §4.1: a non-"#EXT" '#' line is a comment — ignore.
2735            } else {
2736                let (bandwidth, codecs, resolution, extra_attrs) =
2737                    pending_stream_inf.take().ok_or_else(|| Error::HlsParse {
2738                        line_no,
2739                        line: line.to_string(),
2740                        reason: "variant URI with no preceding #EXT-X-STREAM-INF".to_string(),
2741                    })?;
2742                variants.push(Variant {
2743                    bandwidth,
2744                    codecs,
2745                    resolution,
2746                    uri: line.to_string(),
2747                    extra_attrs,
2748                });
2749            }
2750        }
2751
2752        if !saw_extm3u {
2753            return Err(Error::HlsParse {
2754                line_no: 1,
2755                line: String::new(),
2756                reason: "missing #EXTM3U header".to_string(),
2757            });
2758        }
2759
2760        Ok(MasterPlaylist {
2761            version,
2762            variants,
2763            iframe_variants,
2764            extra_tags,
2765            independent_segments,
2766            start,
2767            defines,
2768            session_data,
2769            session_keys,
2770            content_steering,
2771        })
2772    }
2773
2774    /// Compute the minimum `#EXT-X-VERSION` this Multivariant Playlist's
2775    /// actual content requires, per RFC 8216bis §8
2776    /// (`docs/version-compatibility.md`). `None` means fully compatible
2777    /// with version 1 (no tag required). See
2778    /// [`MediaPlaylist::computed_version`] for the Media-Playlist-only rows
2779    /// (2–6, 9–10) that do not apply to a Multivariant Playlist.
2780    pub fn computed_version(&self) -> Option<u8> {
2781        // Rows 7/13 (SERVICE INSTREAM-ID, non-CC INSTREAM-ID) — all
2782        // attributes of `EXT-X-MEDIA` or of tags this crate still does not
2783        // model, so `extra_tags` remains the substrate. Row 12 on *unmodeled*
2784        // tags is handled here; row 12 on modeled tags is checked below.
2785        let mut v = scan_tag_lines_for_version(&self.extra_tags);
2786
2787        // Row 11: EXT-X-DEFINE with a QUERYPARAM attribute — typed since
2788        // issue #872 (see `MediaPlaylist::computed_version` for why the
2789        // string scan can no longer see this tag).
2790        if self
2791            .defines
2792            .iter()
2793            .any(|d| matches!(d, Define::QueryParam { .. }))
2794        {
2795            bump_version(&mut v, VERSION_DEFINE_QUERYPARAM);
2796        }
2797
2798        // Row 8: variable substitution across every string this playlist
2799        // carries — opaque tag lines, variant/I-frame-variant URIs, and the
2800        // typed #872 fields (EXT-X-DEFINE values, EXT-X-SESSION-DATA
2801        // VALUE/URI, EXT-X-SESSION-KEY URI, EXT-X-CONTENT-STEERING URIs).
2802        if self
2803            .extra_tags
2804            .iter()
2805            .any(|t| contains_variable_substitution(t))
2806            || self
2807                .variants
2808                .iter()
2809                .any(|var| contains_variable_substitution(&var.uri))
2810            || self
2811                .iframe_variants
2812                .iter()
2813                .any(|iv| contains_variable_substitution(&iv.uri))
2814            || self.master_playlist_typed_strings_use_substitution()
2815        {
2816            bump_version(&mut v, VERSION_VARIABLE_SUBSTITUTION);
2817        }
2818
2819        // Row 12: REQ- attribute on any typed struct — modeled tags that
2820        // carry extra_attrs fields since issue #884.
2821        if any_typed_req_attr(
2822            self.start.as_ref(),
2823            &self.defines,
2824            &self.session_data,
2825            &self.session_keys,
2826            self.content_steering.as_ref(),
2827            &self.variants,
2828            &self.iframe_variants,
2829        ) {
2830            bump_version(&mut v, VERSION_REQ_ATTRIBUTE);
2831        }
2832
2833        v
2834    }
2835
2836    /// Row 8 helper — the Multivariant-Playlist counterpart of
2837    /// [`MediaPlaylist::media_playlist_typed_strings_use_substitution`],
2838    /// covering the string-bearing tags issue #872 gave typed
2839    /// representations (before which they sat in `extra_tags` and were
2840    /// covered by the opaque scan).
2841    fn master_playlist_typed_strings_use_substitution(&self) -> bool {
2842        let define_values = self.defines.iter().filter_map(|d| match d {
2843            Define::Name { value, .. } => Some(value),
2844            _ => None,
2845        });
2846        let session_data_strings = self.session_data.iter().map(|sd| match &sd.content {
2847            SessionDataContent::Value(v) => v,
2848            SessionDataContent::Uri { uri, .. } => uri,
2849        });
2850        let session_key_uris = self.session_keys.iter().filter_map(|k| k.uri.as_ref());
2851        let steering_uris = self.content_steering.iter().map(|cs| &cs.server_uri);
2852
2853        define_values
2854            .chain(session_data_strings)
2855            .chain(session_key_uris)
2856            .chain(steering_uris)
2857            .any(|s| contains_variable_substitution(s))
2858    }
2859
2860    /// The `#EXT-X-VERSION` value actually rendered by [`Self::to_m3u8`] —
2861    /// see [`MediaPlaylist::effective_version`] for the shared rule.
2862    fn effective_version(&self) -> Option<u8> {
2863        effective_version(self.version, self.computed_version())
2864    }
2865}
2866
2867/// Parse a `RESOLUTION=<w>x<h>` attribute value.
2868fn parse_resolution(v: &str, line_no: usize, line: &str) -> Result<(u32, u32)> {
2869    let mut split = v.splitn(2, 'x');
2870    let w = split.next().unwrap_or("");
2871    let h = split.next().ok_or_else(|| Error::HlsParse {
2872        line_no,
2873        line: line.to_string(),
2874        reason: format!("RESOLUTION value {v:?} is not of the form <width>x<height>"),
2875    })?;
2876    let width = parse_decimal(w, line_no, line, "RESOLUTION width")?;
2877    let height = parse_decimal(h, line_no, line, "RESOLUTION height")?;
2878    Ok((width, height))
2879}
2880
2881/// Auto-detect init-segment changes across a sequence of segments and mark the
2882/// first segment that follows an init change as discontinuous (RFC 8216 §4.3.4.3).
2883///
2884/// `entries` is an ordered list of `(init_bytes, segment)` pairs — one per
2885/// media segment in playlist order. For each segment after the first, if its
2886/// init bytes differ from the preceding segment's, `segment.discontinuous` is
2887/// set to `true`. The first segment is never marked (no preceding context).
2888///
2889/// This is the building block for playlist assemblers that splice content from
2890/// multiple sources with different `EXT-X-MAP` init segments: detect changes
2891/// once, then pass the updated `MediaSegment` list to [`MediaPlaylist`].
2892///
2893/// # Example
2894/// ```
2895/// use broadcast_hls::{mark_init_discontinuities, MediaSegment};
2896/// let init_a = b"moov_a" as &[u8];
2897/// let init_b = b"moov_b" as &[u8];
2898/// let mut seg0 = MediaSegment { uri: "s0.m4s".into(), duration: 5.0, discontinuous: false, parts: vec![], ..Default::default() };
2899/// let mut seg1 = MediaSegment { uri: "s1.m4s".into(), duration: 5.0, discontinuous: false, parts: vec![], ..Default::default() };
2900/// let mut seg2 = MediaSegment { uri: "s2.m4s".into(), duration: 5.0, discontinuous: false, parts: vec![], ..Default::default() };
2901/// let mut entries: Vec<(&[u8], &mut MediaSegment)> = vec![
2902///     (init_a, &mut seg0),
2903///     (init_b, &mut seg1),
2904///     (init_b, &mut seg2),
2905/// ];
2906/// mark_init_discontinuities(&mut entries);
2907/// assert!(!entries[0].1.discontinuous);
2908/// assert!(entries[1].1.discontinuous);   // init changed: a → b
2909/// assert!(!entries[2].1.discontinuous);  // same init
2910/// ```
2911pub fn mark_init_discontinuities(entries: &mut [(&[u8], &mut MediaSegment)]) {
2912    if entries.len() < 2 {
2913        return;
2914    }
2915    // Walk the slice as a sliding window: [prev | cur..].
2916    // `split_at_mut` gives two non-overlapping sub-slices so we can hold an
2917    // immutable read of `prev.0` while mutating `cur.1.discontinuous`.
2918    for i in 1..entries.len() {
2919        let (head, tail) = entries.split_at_mut(i);
2920        let prev_init: &[u8] = head[i - 1].0;
2921        let cur = &mut tail[0];
2922        if cur.0 != prev_init {
2923            cur.1.discontinuous = true;
2924        }
2925    }
2926}
2927
2928#[cfg(test)]
2929mod tests {
2930    use super::*;
2931
2932    fn seg(uri: &str, duration: f64) -> MediaSegment {
2933        MediaSegment {
2934            uri: uri.into(),
2935            duration,
2936            discontinuous: false,
2937            parts: vec![],
2938            ..Default::default()
2939        }
2940    }
2941
2942    fn seg_disc(uri: &str, duration: f64) -> MediaSegment {
2943        MediaSegment {
2944            uri: uri.into(),
2945            duration,
2946            discontinuous: true,
2947            parts: vec![],
2948            ..Default::default()
2949        }
2950    }
2951
2952    fn playlist(segments: Vec<MediaSegment>) -> MediaPlaylist {
2953        MediaPlaylist {
2954            version: 3,
2955            target_duration: 10,
2956            media_sequence: 0,
2957            discontinuity_sequence: 0,
2958            segments,
2959            endlist: true,
2960            extra_tags: vec![],
2961            low_latency: None,
2962            iframes_only: false,
2963            open_segment: None,
2964            ..Default::default()
2965        }
2966    }
2967
2968    #[test]
2969    fn media_playlist_basic() {
2970        let pl = MediaPlaylist {
2971            version: 3,
2972            target_duration: 10,
2973            media_sequence: 0,
2974            discontinuity_sequence: 0,
2975            segments: vec![
2976                seg("seg0.m4s", 9.009),
2977                seg("seg1.m4s", 9.009),
2978                seg("seg2.m4s", 3.003),
2979            ],
2980            endlist: true,
2981            extra_tags: vec![
2982                "#EXT-X-DATERANGE:ID=\"ad-1\",START-DATE=\"2024-01-01T00:00:00.000Z\",DURATION=15.0"
2983                    .into(),
2984            ],
2985            low_latency: None,
2986            iframes_only: false,
2987            open_segment: None,
2988            ..Default::default()
2989        };
2990        let out = pl.to_m3u8();
2991        assert!(out.starts_with("#EXTM3U\n"));
2992        assert!(out.contains("#EXT-X-TARGETDURATION:10\n"));
2993        assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:0\n"));
2994        assert_eq!(out.matches("#EXTINF:").count(), 3);
2995        assert!(out.ends_with("#EXT-X-ENDLIST\n"));
2996        // Check extra tag is present before segments.
2997        assert!(out.contains("#EXT-X-DATERANGE:ID=\"ad-1\""));
2998        // No discontinuity sequence when 0.
2999        assert!(!out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"));
3000    }
3001
3002    #[test]
3003    fn media_playlist_no_endlist() {
3004        let pl = MediaPlaylist {
3005            version: 7,
3006            target_duration: 6,
3007            media_sequence: 42,
3008            discontinuity_sequence: 0,
3009            segments: vec![seg("seg.m4s", 6.000)],
3010            endlist: false,
3011            extra_tags: vec![],
3012            low_latency: None,
3013            iframes_only: false,
3014            open_segment: None,
3015            ..Default::default()
3016        };
3017        let out = pl.to_m3u8();
3018        assert!(out.starts_with("#EXTM3U\n"));
3019        assert!(out.contains("#EXT-X-VERSION:7\n"));
3020        assert!(!out.contains("#EXT-X-ENDLIST"));
3021    }
3022
3023    #[test]
3024    fn master_playlist_basic() {
3025        let pl = MasterPlaylist {
3026            version: 6,
3027            variants: vec![
3028                Variant {
3029                    bandwidth: 300_000,
3030                    codecs: "avc1.64001e,mp4a.40.2".into(),
3031                    resolution: Some((640, 360)),
3032                    uri: "v300/index.m3u8".into(),
3033                    ..Default::default()
3034                },
3035                Variant {
3036                    bandwidth: 800_000,
3037                    codecs: "avc1.640028,mp4a.40.2".into(),
3038                    resolution: Some((1280, 720)),
3039                    uri: "v800/index.m3u8".into(),
3040                    ..Default::default()
3041                },
3042            ],
3043            iframe_variants: vec![],
3044            ..Default::default()
3045        };
3046        let out = pl.to_m3u8();
3047        assert!(out.starts_with("#EXTM3U\n"));
3048        assert_eq!(out.matches("#EXT-X-STREAM-INF:").count(), 2);
3049        assert!(out.contains("v300/index.m3u8"));
3050        assert!(out.contains("v800/index.m3u8"));
3051        assert!(out.contains("RESOLUTION=640x360"));
3052        assert!(out.contains("RESOLUTION=1280x720"));
3053    }
3054
3055    #[test]
3056    fn master_playlist_no_resolution() {
3057        let pl = MasterPlaylist {
3058            version: 6,
3059            variants: vec![Variant {
3060                bandwidth: 1_000_000,
3061                codecs: "avc1.640028".into(),
3062                resolution: None,
3063                uri: "v1k/index.m3u8".into(),
3064                extra_attrs: Vec::new(),
3065            }],
3066            iframe_variants: vec![],
3067            ..Default::default()
3068        };
3069        let out = pl.to_m3u8();
3070        assert!(!out.contains("RESOLUTION"));
3071        assert!(out.contains("#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.640028\""));
3072    }
3073
3074    /// The 3-decimal `#EXTINF` form (`9.009` — what every RFC 8216 example
3075    /// and every existing consumer expects) is kept for genuinely fractional
3076    /// durations.
3077    #[test]
3078    fn extinf_three_decimals() {
3079        let pl = playlist(vec![seg("s.m4s", 9.009)]);
3080        let out = pl.to_m3u8();
3081        assert!(out.contains("#EXTINF:9.009,\n"), "{out}");
3082    }
3083
3084    /// RFC 8216bis §8 row 3: "A Media Playlist MUST indicate an
3085    /// EXT-X-VERSION of 3 or higher if it contains: Floating-point EXTINF
3086    /// duration values." The requirement is about what the playlist
3087    /// **contains**, so a whole number of seconds must render as an integer
3088    /// — otherwise `to_m3u8` emitted `#EXTINF:9.000,` (a floating-point
3089    /// value) while `computed_version` reported `None`, telling a v1/v2
3090    /// client the playlist was compatible with it and then handing it a
3091    /// float it cannot parse.
3092    ///
3093    /// MUTATION VERIFIED: restoring the old `format!("{v:.3}")`-first body
3094    /// of `format_extinf` makes the `#EXTINF:9,` assertion below fail
3095    /// (`9.000` is rendered instead). Recompiled and re-run to confirm,
3096    /// then reverted.
3097    #[test]
3098    fn integral_extinf_renders_as_an_integer_and_needs_no_version() {
3099        // `version: 0` — no explicit floor, so the rendered tag (or its
3100        // absence) is exactly what the derivation asks for.
3101        let pl = MediaPlaylist {
3102            version: 0,
3103            ..playlist(vec![seg("s.m4s", 9.0)])
3104        };
3105        let out = pl.to_m3u8();
3106        assert!(out.contains("#EXTINF:9,\n"), "{out}");
3107        assert!(!out.contains("9.000"), "{out}");
3108        assert_eq!(
3109            pl.computed_version(),
3110            None,
3111            "a playlist with no floating-point EXTINF trips no §8 row: {out}"
3112        );
3113        assert!(!out.contains("#EXT-X-VERSION"), "{out}");
3114        // ...and it still re-parses to the identical f64.
3115        let reparsed = MediaPlaylist::parse(&out).expect("round-trip parse");
3116        assert_eq!(reparsed.segments[0].duration, 9.0);
3117    }
3118
3119    /// The renderer and the §8 row-3 predicate must never disagree about
3120    /// whether a duration is floating-point — the divergence that caused the
3121    /// bug above. Pins the exact four values from the report, plus the
3122    /// sub-millisecond case that diverged in the *other* direction (the
3123    /// old integer-millisecond predicate called `4.0004` integral while
3124    /// issue #882's precision fallback rendered it as `4.0004`).
3125    #[test]
3126    fn extinf_rendering_and_version_derivation_never_diverge() {
3127        for (duration, expected_text, expected_version) in [
3128            (4.0_f64, "#EXTINF:4,", None),
3129            (4.004, "#EXTINF:4.004,", Some(3)),
3130            (9.9766, "#EXTINF:9.9766,", Some(3)), // issue #882 regression guard
3131            (4.0004, "#EXTINF:4.0004,", Some(3)), // sub-ms: predicate used to say None
3132        ] {
3133            let pl = playlist(vec![seg("s.m4s", duration)]);
3134            let out = pl.to_m3u8();
3135            assert!(out.contains(expected_text), "{duration}: {out}");
3136            assert_eq!(pl.computed_version(), expected_version, "{duration}: {out}");
3137            // The invariant, stated directly: a rendered decimal point and a
3138            // row-3 requirement are the same thing.
3139            assert_eq!(
3140                out.contains(expected_text) && expected_text.contains('.'),
3141                expected_version == Some(3),
3142                "{duration}: rendered text and §8 row 3 must agree: {out}"
3143            );
3144            // Bit-exact round-trip of the duration itself.
3145            let reparsed = MediaPlaylist::parse(&out).expect("round-trip parse");
3146            assert_eq!(reparsed.segments[0].duration, duration, "{out}");
3147        }
3148    }
3149
3150    /// Regression (issue #872): durations finer than 1 ms must survive
3151    /// rendering. `to_m3u8` used a hardcoded `{:.3}` for `#EXTINF` and
3152    /// integer-millisecond math for every other seconds value, so real
3153    /// content silently lost precision — Apple's BipBop playlists carry
3154    /// `#EXTINF:9.9766` (rendered back as `9.977`) and RFC 8216bis §9.11's
3155    /// LL-HLS example carries `DURATION=2.00004` (rendered back as `2`).
3156    /// Found by round-tripping the real `fixtures/hls/real/` playlists; no
3157    /// hand-made 3-decimal fixture could have surfaced it.
3158    #[test]
3159    fn sub_millisecond_durations_survive_rendering() {
3160        // #EXTINF — the value that actually failed against real Apple data.
3161        let pl = playlist(vec![seg("main.ts", 9.9766)]);
3162        let out = pl.to_m3u8();
3163        assert!(
3164            out.contains("#EXTINF:9.9766,\n"),
3165            "EXTINF must not be truncated to 3 decimals:\n{out}"
3166        );
3167        assert_eq!(
3168            MediaPlaylist::parse(&out).unwrap().segments[0].duration,
3169            9.9766,
3170            "duration must survive a round trip bit-exactly"
3171        );
3172
3173        // The ms-granular common case keeps its historical compact form.
3174        assert_eq!(format_secs(0.334), "0.334");
3175        assert_eq!(format_secs(1.5), "1.5");
3176        assert_eq!(format_secs(6.0), "6");
3177        // ...and the sub-ms case is now lossless rather than rounded to it.
3178        assert_eq!(format_secs(2.00004), "2.00004");
3179        assert_eq!(format_secs(4.00008), "4.00008");
3180        assert_eq!(format_signed_secs(-10.5), "-10.5");
3181        assert_eq!(format_signed_secs(-2.00004), "-2.00004");
3182    }
3183
3184    /// A whole real-shaped LL-HLS playlist built from RFC 8216bis §9.11's
3185    /// actual sub-millisecond part/segment durations must round-trip. The
3186    /// spec's own §9.11 fixture can't cover this (it is unparsable — its
3187    /// `...` elision line, see `tests/hls_fixture_corpus.rs`), so the values
3188    /// are exercised here instead.
3189    #[test]
3190    fn round_trip_rfc_9_11_sub_millisecond_part_durations() {
3191        let pl = MediaPlaylist {
3192            version: 9,
3193            target_duration: 4,
3194            segments: vec![MediaSegment {
3195                uri: "fileSequence271.mp4".into(),
3196                duration: 4.00008,
3197                parts: vec![
3198                    PartSpec {
3199                        uri: "filePart271.0.mp4".into(),
3200                        duration: 2.00004,
3201                        independent: true,
3202                        ..Default::default()
3203                    },
3204                    PartSpec {
3205                        uri: "filePart271.1.mp4".into(),
3206                        duration: 0.50001,
3207                        ..Default::default()
3208                    },
3209                ],
3210                ..Default::default()
3211            }],
3212            low_latency: Some(LowLatencyConfig {
3213                part_target: 2.00002,
3214                part_hold_back: 6.00006,
3215                ..Default::default()
3216            }),
3217            ..Default::default()
3218        };
3219        let text = pl.to_m3u8();
3220        assert!(text.contains("#EXTINF:4.00008,"), "{text}");
3221        assert!(text.contains("DURATION=2.00004"), "{text}");
3222        assert!(text.contains("DURATION=0.50001"), "{text}");
3223        assert!(text.contains("PART-TARGET=2.00002"), "{text}");
3224        assert_eq!(
3225            MediaPlaylist::parse(&text).expect("must parse"),
3226            pl,
3227            "sub-ms LL-HLS durations must round-trip:\n{text}"
3228        );
3229    }
3230
3231    // --- discontinuity tag tests ---
3232
3233    #[test]
3234    fn discontinuity_tag_emitted_before_extinf() {
3235        // seg1 is discontinuous; the tag must appear before its #EXTINF.
3236        let pl = playlist(vec![
3237            seg("s0.m4s", 5.0),
3238            seg_disc("s1.m4s", 5.0),
3239            seg("s2.m4s", 5.0),
3240        ]);
3241        let out = pl.to_m3u8();
3242        assert_eq!(out.matches("#EXT-X-DISCONTINUITY\n").count(), 1);
3243        // The tag must immediately precede the #EXTINF for s1.
3244        let disc_pos = out.find("#EXT-X-DISCONTINUITY\n").unwrap();
3245        let extinf_pos = out.find("#EXTINF:5.000,\n#s1.m4s\n").unwrap_or_else(|| {
3246            // Find the position of "s1.m4s" in the output and trace back to its #EXTINF.
3247            let s1_pos = out.find("s1.m4s\n").unwrap();
3248            // The #EXTINF line starts 11 chars before "5.000,\n" — find it preceding s1.
3249            out[..s1_pos].rfind("#EXTINF:").unwrap()
3250        });
3251        assert!(
3252            disc_pos < extinf_pos,
3253            "#EXT-X-DISCONTINUITY must appear before #EXTINF of s1"
3254        );
3255        // The discontinuity tag must be the line immediately before #EXTINF:
3256        let tag_end = disc_pos + "#EXT-X-DISCONTINUITY\n".len();
3257        assert!(
3258            out[tag_end..].starts_with("#EXTINF:"),
3259            "#EXT-X-DISCONTINUITY must be immediately before #EXTINF, got: {:?}",
3260            &out[tag_end..tag_end + 20]
3261        );
3262    }
3263
3264    #[test]
3265    fn no_discontinuity_tag_when_all_continuous() {
3266        let pl = playlist(vec![
3267            seg("s0.m4s", 5.0),
3268            seg("s1.m4s", 5.0),
3269            seg("s2.m4s", 5.0),
3270        ]);
3271        let out = pl.to_m3u8();
3272        assert!(
3273            !out.contains("#EXT-X-DISCONTINUITY\n"),
3274            "no tag when all segments are continuous"
3275        );
3276    }
3277
3278    #[test]
3279    fn discontinuity_sequence_emitted_when_nonzero() {
3280        let pl = MediaPlaylist {
3281            version: 3,
3282            target_duration: 6,
3283            media_sequence: 5,
3284            discontinuity_sequence: 2,
3285            segments: vec![seg("s5.m4s", 6.0)],
3286            endlist: false,
3287            extra_tags: vec![],
3288            low_latency: None,
3289            iframes_only: false,
3290            open_segment: None,
3291            ..Default::default()
3292        };
3293        let out = pl.to_m3u8();
3294        assert!(
3295            out.contains("#EXT-X-DISCONTINUITY-SEQUENCE:2\n"),
3296            "header must be present when n>0"
3297        );
3298    }
3299
3300    #[test]
3301    fn discontinuity_sequence_absent_when_zero() {
3302        let pl = playlist(vec![seg("s0.m4s", 6.0)]);
3303        let out = pl.to_m3u8();
3304        assert!(
3305            !out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"),
3306            "header must be absent when n==0"
3307        );
3308    }
3309
3310    // --- LL-HLS render tests (issue #702: OpenSegment) ---
3311
3312    fn ll_config() -> LowLatencyConfig {
3313        LowLatencyConfig {
3314            part_target: 0.5,
3315            part_hold_back: 1.5,
3316            preload_hint_part: None,
3317            ..Default::default()
3318        }
3319    }
3320
3321    #[test]
3322    fn ll_hls_renders_server_control_part_inf_and_parts() {
3323        let pl = MediaPlaylist {
3324            version: 9,
3325            target_duration: 4,
3326            media_sequence: 0,
3327            discontinuity_sequence: 0,
3328            segments: vec![MediaSegment {
3329                uri: "seg-1-4.m4s".into(),
3330                duration: 4.0,
3331                discontinuous: false,
3332                parts: vec![PartSpec {
3333                    uri: "part-1-1.m4s".into(),
3334                    duration: 0.5,
3335                    independent: true,
3336                    ..Default::default()
3337                }],
3338                ..Default::default()
3339            }],
3340            endlist: false,
3341            extra_tags: vec![],
3342            low_latency: Some(ll_config()),
3343            iframes_only: false,
3344            open_segment: None,
3345            ..Default::default()
3346        };
3347        let out = pl.to_m3u8();
3348        assert!(out.contains("#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES"));
3349        assert!(out.contains("#EXT-X-PART-INF:PART-TARGET="));
3350        assert!(out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-1.m4s\""));
3351    }
3352
3353    #[test]
3354    fn open_segment_renders_parts_without_extinf() {
3355        let pl = MediaPlaylist {
3356            version: 9,
3357            target_duration: 4,
3358            media_sequence: 0,
3359            discontinuity_sequence: 0,
3360            segments: vec![MediaSegment {
3361                uri: "seg-1-4.m4s".into(),
3362                duration: 4.0,
3363                discontinuous: false,
3364                parts: vec![],
3365                ..Default::default()
3366            }],
3367            endlist: false,
3368            extra_tags: vec![],
3369            low_latency: Some(ll_config()),
3370            iframes_only: false,
3371            open_segment: Some(OpenSegment::new(vec![PartSpec {
3372                uri: "part-1-5.0.m4s".into(),
3373                duration: 0.5,
3374                independent: true,
3375                ..Default::default()
3376            }])),
3377            ..Default::default()
3378        };
3379        let out = pl.to_m3u8();
3380        // The open part is rendered as an #EXT-X-PART line.
3381        assert!(
3382            out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES"),
3383            "open segment's part must render:\n{out}"
3384        );
3385        // The closed segment is still rendered with its #EXTINF (a whole
3386        // 4.0 s renders as the integer `4` — see
3387        // `integral_extinf_renders_as_an_integer_and_needs_no_version`).
3388        assert!(out.contains("#EXTINF:4,\n"), "{out}");
3389        assert!(out.contains("seg-1-4.m4s"));
3390        // The open part's URI never appears on an #EXTINF/plain-URI line — only
3391        // inside its #EXT-X-PART line (there is no #EXTINF for an open segment).
3392        assert!(
3393            !out.contains("#EXTINF:0.500,\npart-1-5.0.m4s"),
3394            "open segment must not be rendered as a closed #EXTINF segment:\n{out}"
3395        );
3396        // Exact count: only 1 closed segment, so exactly 1 #EXTINF occurrence.
3397        assert_eq!(
3398            out.matches("#EXTINF:").count(),
3399            1,
3400            "only closed segments should have #EXTINF lines; open segment must not:\n{out}"
3401        );
3402        let lines: Vec<&str> = out.lines().collect();
3403        for (i, line) in lines.iter().enumerate() {
3404            if *line == "part-1-5.0.m4s" {
3405                panic!("open part URI must not appear on its own URI line: {out}");
3406            }
3407            if line.starts_with("#EXTINF") && i + 1 < lines.len() {
3408                assert_ne!(
3409                    lines[i + 1],
3410                    "part-1-5.0.m4s",
3411                    "open part URI must not follow an #EXTINF line:\n{out}"
3412                );
3413            }
3414        }
3415    }
3416
3417    #[test]
3418    fn open_segment_not_rendered_without_low_latency() {
3419        let pl = MediaPlaylist {
3420            version: 9,
3421            target_duration: 4,
3422            media_sequence: 0,
3423            discontinuity_sequence: 0,
3424            segments: vec![seg("seg-1-4.m4s", 4.0)],
3425            endlist: false,
3426            extra_tags: vec![],
3427            low_latency: None,
3428            iframes_only: false,
3429            open_segment: Some(OpenSegment::new(vec![PartSpec {
3430                uri: "part-1-5.0.m4s".into(),
3431                duration: 0.5,
3432                independent: true,
3433                ..Default::default()
3434            }])),
3435            ..Default::default()
3436        };
3437        let out = pl.to_m3u8();
3438        assert!(
3439            !out.contains("part-1-5.0.m4s"),
3440            "open segment parts must not render without low_latency:\n{out}"
3441        );
3442        assert!(!out.contains("#EXT-X-PART:"));
3443    }
3444
3445    #[test]
3446    fn preload_hint_rendered_from_low_latency() {
3447        let mut ll = ll_config();
3448        ll.preload_hint_part = Some("part-1-5.1.m4s".into());
3449        let pl = MediaPlaylist {
3450            version: 9,
3451            target_duration: 4,
3452            media_sequence: 0,
3453            discontinuity_sequence: 0,
3454            segments: vec![seg("seg-1-4.m4s", 4.0)],
3455            endlist: false,
3456            extra_tags: vec![],
3457            low_latency: Some(ll),
3458            iframes_only: false,
3459            open_segment: None,
3460            ..Default::default()
3461        };
3462        let out = pl.to_m3u8();
3463        assert!(out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\""));
3464    }
3465
3466    #[test]
3467    fn open_segment_parts_precede_preload_hint() {
3468        let mut ll = ll_config();
3469        ll.preload_hint_part = Some("part-1-5.1.m4s".into());
3470        let pl = MediaPlaylist {
3471            version: 9,
3472            target_duration: 4,
3473            media_sequence: 0,
3474            discontinuity_sequence: 0,
3475            segments: vec![MediaSegment {
3476                uri: "seg-1-4.m4s".into(),
3477                duration: 4.0,
3478                discontinuous: false,
3479                parts: vec![],
3480                ..Default::default()
3481            }],
3482            endlist: false,
3483            extra_tags: vec![],
3484            low_latency: Some(ll),
3485            iframes_only: false,
3486            open_segment: Some(OpenSegment::new(vec![PartSpec {
3487                uri: "part-1-5.0.m4s".into(),
3488                duration: 0.5,
3489                independent: true,
3490                ..Default::default()
3491            }])),
3492            ..Default::default()
3493        };
3494        let out = pl.to_m3u8();
3495        // Both the open-segment part and preload-hint must be present.
3496        assert!(
3497            out.contains("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES"),
3498            "open-segment part must be present:\n{out}"
3499        );
3500        assert!(
3501            out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\""),
3502            "preload-hint must be present:\n{out}"
3503        );
3504        // The open-segment #EXT-X-PART line must appear BEFORE the #EXT-X-PRELOAD-HINT line.
3505        let part_pos = out
3506            .find("#EXT-X-PART:DURATION=0.5,URI=\"part-1-5.0.m4s\",INDEPENDENT=YES")
3507            .expect("open-segment part line not found");
3508        let preload_pos = out
3509            .find("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-5.1.m4s\"")
3510            .expect("preload-hint line not found");
3511        assert!(
3512            part_pos < preload_pos,
3513            "open-segment #EXT-X-PART must precede #EXT-X-PRELOAD-HINT:\npart at {}, preload at {}\noutput:\n{out}",
3514            part_pos,
3515            preload_pos
3516        );
3517    }
3518
3519    /// Issue #717 slice 5 follow-up fix: `#EXT-X-MAP` applies "until the
3520    /// next `EXT-X-MAP` tag or the end of the Playlist" (RFC 8216bis
3521    /// §4.4.4.5) — including to the *open* (never-yet-closed) segment, even
3522    /// when NO segment has closed yet (the very first segment of a
3523    /// freshly-tuned-into live stream). Before this fix, `OpenSegment`
3524    /// carried no `map` field at all, so a client parsing a playlist with
3525    /// only an open segment had no way to learn the init segment's URI
3526    /// until that segment's first *closed* appearance — needlessly
3527    /// delaying every part's playback until then.
3528    #[test]
3529    fn open_segment_inherits_map_when_no_segment_has_closed_yet() {
3530        let text = "#EXTM3U\n\
3531#EXT-X-VERSION:9\n\
3532#EXT-X-TARGETDURATION:4\n\
3533#EXT-X-MEDIA-SEQUENCE:1\n\
3534#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5\n\
3535#EXT-X-PART-INF:PART-TARGET=0.5\n\
3536#EXT-X-MAP:URI=\"init-1.mp4\"\n\
3537#EXT-X-PART:DURATION=0.5,URI=\"part-1-1.0.m4s\",INDEPENDENT=YES\n\
3538#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-1-1.1.m4s\"\n";
3539        let pl = MediaPlaylist::parse(text).expect("must parse");
3540        assert!(pl.segments.is_empty(), "no segment has closed yet");
3541        let open = pl.open_segment.as_ref().expect("one open segment");
3542        assert_eq!(
3543            open.map,
3544            Some(MapTag {
3545                uri: "init-1.mp4".into(),
3546                byte_range: None,
3547                extra_attrs: Vec::new(),
3548            }),
3549            "the open segment must inherit the EXT-X-MAP that precedes it, \
3550             even though no segment has closed yet"
3551        );
3552    }
3553
3554    /// Round trip the same no-closed-segments-yet shape through
3555    /// `to_m3u8`/`parse`, proving the renderer emits the `#EXT-X-MAP` line
3556    /// for a bare open segment (not just for closed ones).
3557    #[test]
3558    fn round_trip_open_segment_only_with_map() {
3559        let pl = MediaPlaylist {
3560            version: 9,
3561            target_duration: 4,
3562            media_sequence: 1,
3563            discontinuity_sequence: 0,
3564            segments: vec![],
3565            open_segment: Some(
3566                OpenSegment::new(vec![PartSpec {
3567                    uri: "part-1-1.0.m4s".into(),
3568                    duration: 0.5,
3569                    independent: true,
3570                    ..Default::default()
3571                }])
3572                .with_map(MapTag {
3573                    uri: "init-1.mp4".into(),
3574                    byte_range: None,
3575                    extra_attrs: Vec::new(),
3576                }),
3577            ),
3578            endlist: false,
3579            extra_tags: vec![],
3580            low_latency: Some(ll_config()),
3581            iframes_only: false,
3582            ..Default::default()
3583        };
3584        let text = pl.to_m3u8();
3585        assert!(
3586            text.contains("#EXT-X-MAP:URI=\"init-1.mp4\""),
3587            "renderer must emit EXT-X-MAP for a bare open segment:\n{text}"
3588        );
3589        let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
3590        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
3591    }
3592
3593    // --- parsing (issue #717 slice 1): round-trip + real-world-sample tests ---
3594
3595    fn ll_config_full() -> LowLatencyConfig {
3596        LowLatencyConfig {
3597            part_target: 0.5,
3598            part_hold_back: 1.5, // already at the 3x floor: idempotent through render.
3599            preload_hint_part: Some("part-9.2.m4s".into()),
3600            preload_hint_type: PreloadHintType::Part,
3601            preload_hint_byte_range_start: Some(0),
3602            preload_hint_byte_range_length: Some(1000),
3603            can_skip_until: Some(24.0),
3604            can_block_reload: true,
3605            hold_back: None,
3606            can_skip_dateranges: false,
3607            ..Default::default()
3608        }
3609    }
3610
3611    #[test]
3612    fn round_trip_live_ll_playlist_with_parts_preload_and_server_control() {
3613        let map = MapTag {
3614            uri: "init.mp4".into(),
3615            byte_range: Some(ByteRange {
3616                length: 800,
3617                offset: Some(0),
3618            }),
3619            extra_attrs: Vec::new(),
3620        };
3621        let pl = MediaPlaylist {
3622            version: 9,
3623            target_duration: 4,
3624            media_sequence: 100,
3625            discontinuity_sequence: 0,
3626            segments: vec![MediaSegment {
3627                uri: "seg-9.m4s".into(),
3628                duration: 4.0,
3629                discontinuous: false,
3630                parts: vec![
3631                    PartSpec {
3632                        uri: "part-9.0.m4s".into(),
3633                        duration: 0.5,
3634                        independent: true,
3635                        byte_range: None,
3636                        gap: false,
3637                        ..Default::default()
3638                    },
3639                    PartSpec {
3640                        uri: "part-9.1.m4s".into(),
3641                        duration: 0.5,
3642                        independent: false,
3643                        byte_range: Some(ByteRange {
3644                            length: 500,
3645                            offset: Some(1000),
3646                        }),
3647                        gap: false,
3648                        ..Default::default()
3649                    },
3650                ],
3651                byte_range: None,
3652                map: Some(map.clone()),
3653                ..Default::default()
3654            }],
3655            // `#EXT-X-MAP` applies "until the next EXT-X-MAP or the end of
3656            // the Playlist" (RFC 8216bis §4.4.4.5) — the open segment is
3657            // still governed by the same map as the preceding closed
3658            // segment (no new `#EXT-X-MAP` appears between them), so it
3659            // must carry it too for a lossless round trip.
3660            open_segment: Some(
3661                OpenSegment::new(vec![PartSpec {
3662                    uri: "part-10.0.m4s".into(),
3663                    duration: 0.5,
3664                    independent: true,
3665                    byte_range: None,
3666                    gap: true,
3667                    extra_attrs: Vec::new(),
3668                }])
3669                .with_map(map.clone()),
3670            ),
3671            endlist: false,
3672            extra_tags: vec![],
3673            low_latency: Some(ll_config_full()),
3674            iframes_only: false,
3675            rendition_reports: vec![RenditionReport {
3676                uri: "../audio/playlist.m3u8".into(),
3677                last_msn: 100,
3678                last_part: Some(1),
3679                extra_attrs: Vec::new(),
3680            }],
3681            skip: None,
3682            ..Default::default()
3683        };
3684        let text = pl.to_m3u8();
3685        let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
3686        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
3687    }
3688
3689    #[test]
3690    fn round_trip_vod_playlist_with_byteranges_map_and_endlist() {
3691        let map = MapTag {
3692            uri: "init.mp4".into(),
3693            byte_range: None,
3694            extra_attrs: Vec::new(),
3695        };
3696        let pl = MediaPlaylist {
3697            version: 6,
3698            target_duration: 10,
3699            media_sequence: 0,
3700            discontinuity_sequence: 0,
3701            segments: vec![
3702                MediaSegment {
3703                    uri: "media.ts".into(),
3704                    duration: 10.0,
3705                    discontinuous: false,
3706                    parts: vec![],
3707                    byte_range: Some(ByteRange {
3708                        length: 500_000,
3709                        offset: Some(0),
3710                    }),
3711                    map: Some(map.clone()),
3712                    ..Default::default()
3713                },
3714                MediaSegment {
3715                    uri: "media.ts".into(),
3716                    duration: 10.0,
3717                    discontinuous: false,
3718                    parts: vec![],
3719                    // No offset: continues immediately after the previous
3720                    // sub-range of the same resource (RFC 8216bis §4.4.4.2).
3721                    byte_range: Some(ByteRange {
3722                        length: 500_000,
3723                        offset: None,
3724                    }),
3725                    // Same map as the previous segment — to_m3u8 must dedup
3726                    // (emit the tag only once) and parse must carry it forward.
3727                    map: Some(map.clone()),
3728                    ..Default::default()
3729                },
3730            ],
3731            open_segment: None,
3732            endlist: true,
3733            extra_tags: vec![
3734                "#EXT-X-DATERANGE:ID=\"ad-1\",START-DATE=\"2024-01-01T00:00:00.000Z\",DURATION=15.0"
3735                    .into(),
3736            ],
3737            low_latency: None,
3738            iframes_only: false,
3739            rendition_reports: vec![],
3740            skip: None,
3741            ..Default::default()
3742        };
3743        let text = pl.to_m3u8();
3744        // The map is only emitted once (dedup), not once per segment.
3745        assert_eq!(
3746            text.matches("#EXT-X-MAP:").count(),
3747            1,
3748            "identical map on consecutive segments must render once:\n{text}"
3749        );
3750        let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
3751        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
3752    }
3753
3754    /// Round-trips the remaining new §4.4.2/§4.4.3.5/§4.4.4.7/§4.4.4.8 tags
3755    /// that live on [`MediaPlaylist`] (issue #872): INDEPENDENT-SEGMENTS,
3756    /// START, DEFINE (IMPORT form — only valid in a Media Playlist),
3757    /// PLAYLIST-TYPE, GAP, and BITRATE (carry-forward + dedup, like MAP).
3758    #[test]
3759    fn round_trip_media_playlist_with_new_872_tags() {
3760        let pl = MediaPlaylist {
3761            version: 6,
3762            target_duration: 10,
3763            media_sequence: 0,
3764            discontinuity_sequence: 0,
3765            independent_segments: true,
3766            start: Some(StartPoint {
3767                time_offset: 5.5,
3768                precise: false,
3769                extra_attrs: Vec::new(),
3770            }),
3771            defines: vec![Define::Import {
3772                name: "base".into(),
3773                extra_attrs: Vec::new(),
3774            }],
3775            playlist_type: Some(PlaylistType::Vod),
3776            segments: vec![
3777                MediaSegment {
3778                    uri: "seg0.ts".into(),
3779                    duration: 10.0,
3780                    bitrate: Some(2000),
3781                    ..Default::default()
3782                },
3783                MediaSegment {
3784                    uri: "seg1.ts".into(),
3785                    duration: 10.0,
3786                    gap: true,
3787                    bitrate: Some(2000),
3788                    ..Default::default()
3789                },
3790                MediaSegment {
3791                    uri: "seg2.ts".into(),
3792                    duration: 10.0,
3793                    bitrate: Some(1800),
3794                    ..Default::default()
3795                },
3796            ],
3797            endlist: true,
3798            ..Default::default()
3799        };
3800        let text = pl.to_m3u8();
3801        assert!(text.contains("#EXT-X-INDEPENDENT-SEGMENTS\n"));
3802        assert!(text.contains("#EXT-X-DEFINE:IMPORT=\"base\"\n"));
3803        assert!(text.contains("#EXT-X-START:TIME-OFFSET=5.5\n"));
3804        assert!(
3805            !text.contains("PRECISE"),
3806            "PRECISE=NO must be omitted:\n{text}"
3807        );
3808        assert!(text.contains("#EXT-X-PLAYLIST-TYPE:VOD\n"));
3809        assert!(text.contains("#EXT-X-GAP\n"));
3810        // BITRATE carries forward + dedups: 2000 (seg0), unchanged for seg1
3811        // (no re-emit), then 1800 for seg2 — exactly 2 EXT-X-BITRATE lines.
3812        assert_eq!(
3813            text.matches("#EXT-X-BITRATE:").count(),
3814            2,
3815            "unchanged bitrate must not re-emit:\n{text}"
3816        );
3817        assert!(text.contains("#EXT-X-BITRATE:2000\n"));
3818        assert!(text.contains("#EXT-X-BITRATE:1800\n"));
3819        let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
3820        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
3821    }
3822
3823    /// `#EXT-X-PLAYLIST-TYPE` with an unrecognized value must error rather
3824    /// than silently default (issue #872): unlike some other attributes,
3825    /// there is no spec-sanctioned fallback for a garbage mutability token.
3826    #[test]
3827    fn parse_rejects_unrecognized_playlist_type() {
3828        let text = "#EXTM3U\n\
3829#EXT-X-VERSION:3\n\
3830#EXT-X-TARGETDURATION:6\n\
3831#EXT-X-PLAYLIST-TYPE:LIVE\n\
3832#EXTINF:6.000,\n\
3833s0.m4s\n";
3834        let err = MediaPlaylist::parse(text).expect_err("unrecognized PLAYLIST-TYPE must error");
3835        assert!(matches!(err, Error::HlsParse { .. }));
3836    }
3837
3838    /// `#EXT-X-DEFINE` with zero or more than one of NAME/IMPORT/QUERYPARAM
3839    /// must error (RFC 8216bis §4.4.2.3, issue #872).
3840    #[test]
3841    fn parse_rejects_define_with_wrong_attribute_count() {
3842        let none = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-DEFINE:VALUE=\"x\"\n\
3843#EXT-X-TARGETDURATION:6\n#EXTINF:6.000,\ns0.m4s\n";
3844        let err = MediaPlaylist::parse(none).expect_err("DEFINE with none of the three must error");
3845        assert!(matches!(err, Error::HlsParse { .. }));
3846
3847        let both = "#EXTM3U\n#EXT-X-VERSION:3\n\
3848#EXT-X-DEFINE:NAME=\"a\",VALUE=\"1\",IMPORT=\"b\"\n\
3849#EXT-X-TARGETDURATION:6\n#EXTINF:6.000,\ns0.m4s\n";
3850        let err = MediaPlaylist::parse(both).expect_err("DEFINE with two of the three must error");
3851        assert!(matches!(err, Error::HlsParse { .. }));
3852    }
3853
3854    /// `#EXT-X-SESSION-DATA` requires exactly one of VALUE/URI (issue #872).
3855    #[test]
3856    fn parse_rejects_session_data_with_wrong_content_count() {
3857        let neither = "#EXTM3U\n#EXT-X-SESSION-DATA:DATA-ID=\"x\"\n";
3858        let err = MasterPlaylist::parse(neither)
3859            .expect_err("SESSION-DATA with neither VALUE nor URI must error");
3860        assert!(matches!(err, Error::HlsParse { .. }));
3861
3862        let both = "#EXTM3U\n#EXT-X-SESSION-DATA:DATA-ID=\"x\",VALUE=\"v\",URI=\"u\"\n";
3863        let err = MasterPlaylist::parse(both)
3864            .expect_err("SESSION-DATA with both VALUE and URI must error");
3865        assert!(matches!(err, Error::HlsParse { .. }));
3866    }
3867
3868    /// `#EXT-X-SESSION-KEY`'s `IV` must be exactly 32 hex digits (issue #872).
3869    #[test]
3870    fn parse_rejects_malformed_session_key_iv() {
3871        let text = "#EXTM3U\n\
3872#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"k\",IV=0xnotahexvalue\n";
3873        let err = MasterPlaylist::parse(text).expect_err("malformed IV must error");
3874        assert!(matches!(err, Error::HlsParse { .. }));
3875    }
3876
3877    #[test]
3878    fn round_trip_multivariant_playlist() {
3879        let pl = MasterPlaylist {
3880            version: 7,
3881            variants: vec![
3882                Variant {
3883                    bandwidth: 300_000,
3884                    codecs: "avc1.64001e,mp4a.40.2".into(),
3885                    resolution: Some((640, 360)),
3886                    uri: "v300/index.m3u8".into(),
3887                    ..Default::default()
3888                },
3889                Variant {
3890                    bandwidth: 800_000,
3891                    codecs: "avc1.640028,mp4a.40.2".into(),
3892                    resolution: Some((1280, 720)),
3893                    uri: "v800/index.m3u8".into(),
3894                    ..Default::default()
3895                },
3896            ],
3897            iframe_variants: vec![IFrameVariant {
3898                bandwidth: 50_000,
3899                codecs: Some("avc1.64001e".into()),
3900                resolution: Some((640, 360)),
3901                uri: "v300/iframe.m3u8".into(),
3902                extra_attrs: Vec::new(),
3903            }],
3904            ..Default::default()
3905        };
3906        let text = pl.to_m3u8();
3907        let parsed = MasterPlaylist::parse(&text).expect("parse must succeed");
3908        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
3909    }
3910
3911    /// Round-trips all 6 new §4.4.2/§4.4.6 Multivariant Playlist tags
3912    /// together (issue #872): INDEPENDENT-SEGMENTS, START, DEFINE (NAME/
3913    /// VALUE + QUERYPARAM forms), SESSION-DATA (VALUE + URI/FORMAT forms),
3914    /// SESSION-KEY, CONTENT-STEERING.
3915    #[test]
3916    fn round_trip_multivariant_playlist_with_new_872_tags() {
3917        // version 11 is not arbitrary: the QUERYPARAM `EXT-X-DEFINE` below
3918        // triggers §8 row 11, so `computed_version()` derives 11 and
3919        // `to_m3u8` renders it. Setting the floor to anything lower would
3920        // still render 11 (issue #880's floor semantics: raised, never
3921        // lowered), which would then re-parse to 11 and break the identity
3922        // round trip below — so the floor must match the derived minimum.
3923        let pl = MasterPlaylist {
3924            version: 11,
3925            independent_segments: true,
3926            start: Some(StartPoint {
3927                time_offset: -10.5,
3928                precise: true,
3929                extra_attrs: Vec::new(),
3930            }),
3931            defines: vec![
3932                Define::Name {
3933                    name: "base".into(),
3934                    value: "https://cdn.example.com/video12".into(),
3935                    extra_attrs: Vec::new(),
3936                },
3937                Define::QueryParam {
3938                    name: "token".into(),
3939                    extra_attrs: Vec::new(),
3940                },
3941            ],
3942            session_data: vec![
3943                SessionData {
3944                    data_id: "com.example.lyrics".into(),
3945                    content: SessionDataContent::Uri {
3946                        uri: "lyrics.json".into(),
3947                        format: SessionDataFormat::Json,
3948                    },
3949                    language: None,
3950                    extra_attrs: Vec::new(),
3951                },
3952                SessionData {
3953                    data_id: "com.example.title".into(),
3954                    content: SessionDataContent::Value("This is an example".into()),
3955                    language: Some("en".into()),
3956                    extra_attrs: Vec::new(),
3957                },
3958            ],
3959            session_keys: vec![
3960                SessionKey {
3961                    method: EncryptionMethod::Aes128,
3962                    uri: Some("https://priv.example.com/key.php?r=52".into()),
3963                    iv: None,
3964                    keyformat: Some("identity".into()),
3965                    keyformatversions: Some("1".into()),
3966                    extra_attrs: Vec::new(),
3967                },
3968                SessionKey {
3969                    method: EncryptionMethod::SampleAesCtr,
3970                    uri: Some("skd://key2".into()),
3971                    iv: Some([
3972                        0x9c, 0x7d, 0xb8, 0x77, 0x85, 0x70, 0xd0, 0x5c, 0x3a, 0x5e, 0x3d, 0x2c,
3973                        0x8a, 0xe5, 0x5e, 0x46,
3974                    ]),
3975                    keyformat: None,
3976                    keyformatversions: None,
3977                    extra_attrs: Vec::new(),
3978                },
3979            ],
3980            content_steering: Some(ContentSteering {
3981                server_uri: "/steering?video=00012".into(),
3982                pathway_id: Some("CDN-A".into()),
3983                extra_attrs: Vec::new(),
3984            }),
3985            variants: vec![Variant {
3986                bandwidth: 1_280_000,
3987                codecs: "avc1.64001e,mp4a.40.2".into(),
3988                resolution: Some((640, 360)),
3989                uri: "low/index.m3u8".into(),
3990                extra_attrs: Vec::new(),
3991            }],
3992            iframe_variants: vec![],
3993            extra_tags: vec![],
3994        };
3995        assert_eq!(
3996            pl.computed_version(),
3997            Some(11),
3998            "EXT-X-DEFINE with QUERYPARAM must derive §8 row 11 from the \
3999             typed `defines` field (issue #872 + #880 integration)"
4000        );
4001        let text = pl.to_m3u8();
4002        assert!(text.contains("#EXT-X-INDEPENDENT-SEGMENTS\n"));
4003        assert!(text.contains("#EXT-X-START:TIME-OFFSET=-10.5,PRECISE=YES\n"));
4004        assert!(
4005            text.contains(
4006                "#EXT-X-DEFINE:NAME=\"base\",VALUE=\"https://cdn.example.com/video12\"\n"
4007            )
4008        );
4009        assert!(text.contains("#EXT-X-DEFINE:QUERYPARAM=\"token\"\n"));
4010        assert!(
4011            text.contains(
4012                "#EXT-X-SESSION-DATA:DATA-ID=\"com.example.lyrics\",URI=\"lyrics.json\"\n"
4013            )
4014        );
4015        assert!(text.contains(
4016            "#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES-CTR,URI=\"skd://key2\",IV=0x9c7db8778570d05c3a5e3d2c8ae55e46\n"
4017        ));
4018        assert!(text.contains(
4019            "#EXT-X-CONTENT-STEERING:SERVER-URI=\"/steering?video=00012\",PATHWAY-ID=\"CDN-A\"\n"
4020        ));
4021        let parsed = MasterPlaylist::parse(&text).expect("parse must succeed");
4022        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
4023    }
4024
4025    /// Real-world sample: RFC 8216bis §9.11 "Low-Latency Playlist" appendix
4026    /// example, verbatim for the segment/part/discontinuity/preload-hint/
4027    /// rendition-report lines (only the elided `...` header lines were filled
4028    /// in with plausible values, since the spec elides them for brevity).
4029    #[test]
4030    fn real_world_sample_ll_playlist_from_rfc8216bis_appendix() {
4031        let text = "\
4032#EXTM3U
4033#EXT-X-VERSION:9
4034#EXT-X-TARGETDURATION:4
4035#EXT-X-MEDIA-SEQUENCE:266
4036#EXT-X-PART-INF:PART-TARGET=2.00002
4037#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=6.00006
4038#EXTINF:4.00008,
4039fileSequence268.mp4
4040#EXTINF:4.00008,
4041fileSequence269.mp4
4042#EXTINF:4.00008,
4043fileSequence270.mp4
4044#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"filePart271.0.mp4\"
4045#EXT-X-PART:DURATION=2.00004,URI=\"filePart271.1.mp4\"
4046#EXTINF:4.00008,
4047fileSequence271.mp4
4048#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"filePart272.0.mp4\"
4049#EXT-X-PART:DURATION=0.50001,URI=\"filePart272.1.mp4\"
4050#EXTINF:2.50005,
4051fileSequence272.mp4
4052#EXT-X-DISCONTINUITY
4053#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"midRoll273.0.mp4\"
4054#EXT-X-PART:DURATION=2.00004,URI=\"midRoll273.1.mp4\"
4055#EXTINF:4.00008,
4056midRoll273.mp4
4057#EXT-X-PART:DURATION=2.00004,INDEPENDENT=YES,URI=\"midRoll274.0.mp4\"
4058#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"midRoll274.1.mp4\"
4059#EXT-X-RENDITION-REPORT:URI=\"/1M/LL-HLS.m3u8\",LAST-MSN=274,LAST-PART=1
4060";
4061        let pl = MediaPlaylist::parse(text).expect("real-world LL sample must parse");
4062        assert_eq!(pl.version, 9);
4063        assert_eq!(pl.target_duration, 4);
4064        assert_eq!(pl.media_sequence, 266);
4065        // 5 closed segments: 268, 269, 270, 271, 272 + the discontinuous
4066        // midRoll273 = 6; midRoll274 has parts but never closes with an
4067        // EXTINF/URI, so it becomes the open (in-progress) segment.
4068        assert_eq!(pl.segments.len(), 6, "{:?}", pl.segments);
4069        assert_eq!(pl.segments[4].uri, "fileSequence272.mp4");
4070        assert_eq!(pl.segments[4].parts.len(), 2);
4071        assert!(pl.segments[4].parts[0].independent);
4072        assert!(!pl.segments[4].parts[1].independent);
4073        assert_eq!(pl.segments[5].uri, "midRoll273.mp4");
4074        assert!(
4075            pl.segments[5].discontinuous,
4076            "midRoll273 follows #EXT-X-DISCONTINUITY"
4077        );
4078        let open = pl.open_segment.as_ref().expect("midRoll274 is open");
4079        assert_eq!(open.parts.len(), 1);
4080        assert_eq!(open.parts[0].uri, "midRoll274.0.mp4");
4081        let ll = pl.low_latency.as_ref().expect("LL config must be present");
4082        assert_eq!(ll.preload_hint_part.as_deref(), Some("midRoll274.1.mp4"));
4083        assert!(
4084            ll.can_block_reload,
4085            "CAN-BLOCK-RELOAD=YES in the fixture must parse to true"
4086        );
4087        assert_eq!(pl.rendition_reports.len(), 1);
4088        assert_eq!(pl.rendition_reports[0].uri, "/1M/LL-HLS.m3u8");
4089        assert_eq!(pl.rendition_reports[0].last_msn, 274);
4090        assert_eq!(pl.rendition_reports[0].last_part, Some(1));
4091    }
4092
4093    /// Real-world-shaped sample: a Playlist Delta Update (`#EXT-X-SKIP`),
4094    /// hand-written per RFC 8216bis §4.4.5.2's confirmed attribute grammar
4095    /// (no full numeric example is given in the spec appendix for this tag).
4096    #[test]
4097    fn real_world_sample_delta_update_with_skip() {
4098        let text = "#EXTM3U\n\
4099#EXT-X-VERSION:9\n\
4100#EXT-X-TARGETDURATION:4\n\
4101#EXT-X-MEDIA-SEQUENCE:1000\n\
4102#EXT-X-PART-INF:PART-TARGET=0.5\n\
4103#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,CAN-SKIP-UNTIL=24.0,PART-HOLD-BACK=1.5\n\
4104#EXT-X-SKIP:SKIPPED-SEGMENTS=996,RECENTLY-REMOVED-DATERANGES=\"ad-1\tad-2\"\n\
4105#EXTINF:4.00000,\n\
4106fileSequence1996.mp4\n\
4107#EXTINF:4.00000,\n\
4108fileSequence1997.mp4\n";
4109        let pl = MediaPlaylist::parse(text).expect("delta update sample must parse");
4110        assert_eq!(pl.media_sequence, 1000);
4111        assert_eq!(pl.segments.len(), 2);
4112        assert_eq!(pl.segments[0].uri, "fileSequence1996.mp4");
4113        let skip = pl.skip.as_ref().expect("EXT-X-SKIP must be captured");
4114        assert_eq!(skip.skipped_segments, 996);
4115        assert_eq!(skip.recently_removed_daterange_ids, vec!["ad-1", "ad-2"]);
4116        let ll = pl.low_latency.as_ref().expect("LL config must be present");
4117        assert_eq!(ll.can_skip_until, Some(24.0));
4118        assert!(!pl.endlist);
4119    }
4120
4121    /// Issue #717 slice 1 fix: an origin that advertises LL-HLS tags but
4122    /// explicitly declines blocking reload (`CAN-BLOCK-RELOAD=NO`) must
4123    /// parse to `can_block_reload == false` — a client inferring support
4124    /// from `low_latency.is_some()` alone would get this wrong.
4125    #[test]
4126    fn real_world_sample_can_block_reload_no_is_parsed_not_inferred() {
4127        let text = "#EXTM3U\n\
4128#EXT-X-VERSION:9\n\
4129#EXT-X-TARGETDURATION:4\n\
4130#EXT-X-MEDIA-SEQUENCE:0\n\
4131#EXT-X-PART-INF:PART-TARGET=0.5\n\
4132#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=NO,PART-HOLD-BACK=1.5\n\
4133#EXTINF:4.00000,\n\
4134seg0.mp4\n";
4135        let pl = MediaPlaylist::parse(text).expect("must parse");
4136        let ll = pl
4137            .low_latency
4138            .as_ref()
4139            .expect("LL config must be present (PART-INF seen)");
4140        assert!(
4141            !ll.can_block_reload,
4142            "CAN-BLOCK-RELOAD=NO must not be inferred as true just because low_latency is Some"
4143        );
4144    }
4145
4146    /// RFC 8216bis §4.4.3.8: an absent `CAN-BLOCK-RELOAD` attribute (or an
4147    /// entirely absent `#EXT-X-SERVER-CONTROL` tag) means the server does
4148    /// NOT support blocking reload — default `false`, distinct from
4149    /// [`LowLatencyConfig::default()`]'s convenience value of `true`.
4150    #[test]
4151    fn parse_defaults_can_block_reload_false_when_attribute_absent() {
4152        let text = "#EXTM3U\n\
4153#EXT-X-VERSION:9\n\
4154#EXT-X-TARGETDURATION:4\n\
4155#EXT-X-MEDIA-SEQUENCE:0\n\
4156#EXT-X-PART-INF:PART-TARGET=0.5\n\
4157#EXTINF:4.00000,\n\
4158seg0.mp4\n";
4159        let pl = MediaPlaylist::parse(text).expect("must parse");
4160        let ll = pl
4161            .low_latency
4162            .as_ref()
4163            .expect("LL config must be present (PART-INF seen)");
4164        assert!(
4165            !ll.can_block_reload,
4166            "absent CAN-BLOCK-RELOAD/SERVER-CONTROL must default to false, not true"
4167        );
4168    }
4169
4170    /// Round trip a `CAN-BLOCK-RELOAD=NO` config through `to_m3u8`/`parse`
4171    /// to prove the renderer emits the actual value (not a hardcoded YES).
4172    #[test]
4173    fn round_trip_can_block_reload_no() {
4174        let pl = MediaPlaylist {
4175            version: 9,
4176            target_duration: 4,
4177            media_sequence: 0,
4178            discontinuity_sequence: 0,
4179            segments: vec![MediaSegment {
4180                uri: "seg0.mp4".into(),
4181                duration: 4.0,
4182                ..Default::default()
4183            }],
4184            low_latency: Some(LowLatencyConfig {
4185                part_target: 0.5,
4186                part_hold_back: 1.5,
4187                can_block_reload: false,
4188                ..Default::default()
4189            }),
4190            ..Default::default()
4191        };
4192        let text = pl.to_m3u8();
4193        assert!(
4194            text.contains("CAN-BLOCK-RELOAD=NO"),
4195            "renderer must emit the actual value:\n{text}"
4196        );
4197        let parsed = MediaPlaylist::parse(&text).expect("parse must succeed");
4198        assert_eq!(parsed, pl, "round trip must be lossless:\n{text}");
4199    }
4200
4201    #[test]
4202    fn parse_ignores_unrecognized_tag_by_preserving_it_into_extra_tags() {
4203        let text = "#EXTM3U\n\
4204#EXT-X-VERSION:3\n\
4205#EXT-X-TARGETDURATION:6\n\
4206#EXT-X-MEDIA-SEQUENCE:0\n\
4207#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:00.000Z\n\
4208#EXTINF:6.000,\n\
4209s0.m4s\n\
4210#EXT-X-ENDLIST\n";
4211        let pl = MediaPlaylist::parse(text).expect("unrecognized tag must not error");
4212        assert!(
4213            pl.extra_tags
4214                .iter()
4215                .any(|t| t.starts_with("#EXT-X-PROGRAM-DATE-TIME:")),
4216            "unrecognized tag must be preserved verbatim, not dropped: {:?}",
4217            pl.extra_tags
4218        );
4219    }
4220
4221    #[test]
4222    fn parse_rejects_missing_targetduration() {
4223        let text = "#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:6.000,\ns0.m4s\n";
4224        let err = MediaPlaylist::parse(text).expect_err("missing TARGETDURATION must error");
4225        assert!(matches!(err, Error::HlsParse { .. }));
4226    }
4227
4228    #[test]
4229    fn parse_rejects_malformed_part_missing_duration() {
4230        let text = "#EXTM3U\n\
4231#EXT-X-VERSION:9\n\
4232#EXT-X-TARGETDURATION:4\n\
4233#EXT-X-PART-INF:PART-TARGET=0.5\n\
4234#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5\n\
4235#EXT-X-PART:URI=\"part.m4s\"\n\
4236#EXTINF:4.000,\n\
4237seg.m4s\n";
4238        let err = MediaPlaylist::parse(text).expect_err("EXT-X-PART without DURATION must error");
4239        let Error::HlsParse { reason, .. } = err;
4240        assert!(reason.contains("DURATION"), "{reason}");
4241    }
4242
4243    #[test]
4244    fn parse_rejects_variant_uri_with_no_preceding_stream_inf() {
4245        let text = "#EXTM3U\n#EXT-X-VERSION:6\nv300/index.m3u8\n";
4246        let err = MasterPlaylist::parse(text).expect_err("orphan variant URI must error");
4247        assert!(matches!(err, Error::HlsParse { .. }));
4248    }
4249
4250    #[test]
4251    fn parse_master_playlist_preserves_ext_x_media_into_extra_tags() {
4252        // #EXT-X-MEDIA is not modeled with typed fields, but must not cause
4253        // a parse error, and must be preserved verbatim (not dropped) since
4254        // `MasterPlaylist` now has its own `extra_tags`.
4255        let text = "#EXTM3U\n\
4256#EXT-X-VERSION:7\n\
4257#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aac\",NAME=\"English\",DEFAULT=YES,URI=\"eng.m3u8\"\n\
4258#EXT-X-STREAM-INF:BANDWIDTH=300000,CODECS=\"avc1.64001e,mp4a.40.2\"\n\
4259v300/index.m3u8\n";
4260        let pl = MasterPlaylist::parse(text).expect("EXT-X-MEDIA must be ignored, not error");
4261        assert_eq!(pl.variants.len(), 1);
4262        assert_eq!(pl.variants[0].uri, "v300/index.m3u8");
4263        assert!(
4264            pl.extra_tags.iter().any(|t| t.starts_with("#EXT-X-MEDIA:")),
4265            "unrecognized tag must be preserved verbatim, not dropped: {:?}",
4266            pl.extra_tags
4267        );
4268    }
4269
4270    // -----------------------------------------------------------------------
4271    // Protocol version derivation (RFC 8216bis §8, issue #871) — table-driven
4272    // per docs/version-compatibility.md, plus the five named regression
4273    // cases from that issue.
4274    // -----------------------------------------------------------------------
4275
4276    /// A minimal, otherwise-untriggering Media Playlist: integer-second
4277    /// duration, no map/byte-range/iframes-only/skip/extra_tags. Every
4278    /// table-driven test starts here and flips exactly one trigger.
4279    fn base_media_playlist() -> MediaPlaylist {
4280        MediaPlaylist {
4281            version: 0,
4282            target_duration: 6,
4283            media_sequence: 0,
4284            segments: vec![seg("s0.m4s", 6.0)],
4285            endlist: true,
4286            ..Default::default()
4287        }
4288    }
4289
4290    /// Extract the `#EXT-X-VERSION:<n>` value from rendered text, or `None`
4291    /// if no such line is present.
4292    fn rendered_version(m3u8: &str) -> Option<u8> {
4293        m3u8.lines()
4294            .find_map(|l| l.strip_prefix("#EXT-X-VERSION:"))
4295            .map(|v| v.parse::<u8>().expect("version must be a valid u8"))
4296    }
4297
4298    #[test]
4299    fn version_row1_no_trigger_omits_the_tag() {
4300        let out = base_media_playlist().to_m3u8();
4301        assert_eq!(rendered_version(&out), None, "no trigger:\n{out}");
4302    }
4303
4304    #[test]
4305    fn version_row2_key_iv_triggers_v2() {
4306        let mut pl = base_media_playlist();
4307        pl.extra_tags = vec![
4308            "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k\",IV=0x00000000000000000000000000000001"
4309                .into(),
4310        ];
4311        let out = pl.to_m3u8();
4312        assert_eq!(rendered_version(&out), Some(2), "{out}");
4313    }
4314
4315    #[test]
4316    fn version_row3_float_extinf_triggers_v3() {
4317        let mut pl = base_media_playlist();
4318        pl.segments = vec![seg("s0.m4s", 6.5)];
4319        let out = pl.to_m3u8();
4320        assert_eq!(rendered_version(&out), Some(3), "{out}");
4321    }
4322
4323    #[test]
4324    fn version_row4_byterange_triggers_v4() {
4325        let mut pl = base_media_playlist();
4326        pl.segments[0].byte_range = Some(ByteRange {
4327            length: 1000,
4328            offset: Some(0),
4329        });
4330        let out = pl.to_m3u8();
4331        assert_eq!(rendered_version(&out), Some(4), "{out}");
4332    }
4333
4334    #[test]
4335    fn version_row4_iframes_only_triggers_v4() {
4336        let mut pl = base_media_playlist();
4337        pl.iframes_only = true;
4338        let out = pl.to_m3u8();
4339        assert_eq!(rendered_version(&out), Some(4), "{out}");
4340    }
4341
4342    #[test]
4343    fn version_row5_sample_aes_triggers_v5() {
4344        let mut pl = base_media_playlist();
4345        pl.extra_tags = vec!["#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"https://k\",KEYID=0x01".into()];
4346        let out = pl.to_m3u8();
4347        assert_eq!(rendered_version(&out), Some(5), "{out}");
4348    }
4349
4350    #[test]
4351    fn version_row5_keyformat_triggers_v5() {
4352        let mut pl = base_media_playlist();
4353        pl.extra_tags = vec![
4354            "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k\",KEYFORMAT=\"com.example\",\
4355             KEYFORMATVERSIONS=\"1\""
4356                .into(),
4357        ];
4358        let out = pl.to_m3u8();
4359        assert_eq!(rendered_version(&out), Some(5), "{out}");
4360    }
4361
4362    #[test]
4363    fn version_row5_map_with_iframes_only_triggers_v5_not_v6() {
4364        let mut pl = base_media_playlist();
4365        pl.iframes_only = true;
4366        pl.segments[0].map = Some(MapTag {
4367            uri: "init.mp4".into(),
4368            byte_range: None,
4369            extra_attrs: Vec::new(),
4370        });
4371        let out = pl.to_m3u8();
4372        assert_eq!(rendered_version(&out), Some(5), "{out}");
4373    }
4374
4375    #[test]
4376    fn version_row6_map_without_iframes_only_triggers_v6() {
4377        let mut pl = base_media_playlist();
4378        pl.segments[0].map = Some(MapTag {
4379            uri: "init.mp4".into(),
4380            byte_range: None,
4381            extra_attrs: Vec::new(),
4382        });
4383        let out = pl.to_m3u8();
4384        assert_eq!(rendered_version(&out), Some(6), "{out}");
4385    }
4386
4387    #[test]
4388    fn version_row7_media_service_instream_id_triggers_v7_multivariant_only() {
4389        let mut pl = MasterPlaylist {
4390            version: 0,
4391            variants: vec![Variant {
4392                bandwidth: 300_000,
4393                codecs: "avc1.64001e".into(),
4394                resolution: None,
4395                uri: "v300/index.m3u8".into(),
4396                extra_attrs: Vec::new(),
4397            }],
4398            iframe_variants: vec![],
4399            extra_tags: vec![],
4400            ..Default::default()
4401        };
4402        pl.extra_tags = vec![
4403            "#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID=\"cc\",NAME=\"CC1\",\
4404             INSTREAM-ID=\"SERVICE1\""
4405                .into(),
4406        ];
4407        let out = pl.to_m3u8();
4408        assert_eq!(rendered_version(&out), Some(7), "{out}");
4409    }
4410
4411    #[test]
4412    fn version_row8_variable_substitution_triggers_v8() {
4413        let mut pl = base_media_playlist();
4414        pl.segments = vec![seg("seg-{$id}.m4s", 6.0)];
4415        let out = pl.to_m3u8();
4416        assert_eq!(rendered_version(&out), Some(8), "{out}");
4417    }
4418
4419    #[test]
4420    fn version_row8_variable_substitution_triggers_v8_multivariant() {
4421        let pl = MasterPlaylist {
4422            version: 0,
4423            variants: vec![Variant {
4424                bandwidth: 300_000,
4425                codecs: "avc1.64001e".into(),
4426                resolution: None,
4427                uri: "{$base}/index.m3u8".into(),
4428                extra_attrs: Vec::new(),
4429            }],
4430            iframe_variants: vec![],
4431            extra_tags: vec![],
4432            ..Default::default()
4433        };
4434        let out = pl.to_m3u8();
4435        assert_eq!(rendered_version(&out), Some(8), "{out}");
4436    }
4437
4438    #[test]
4439    fn version_row9_skip_triggers_v9() {
4440        let mut pl = base_media_playlist();
4441        pl.skip = Some(SkipInfo {
4442            skipped_segments: 5,
4443            recently_removed_daterange_ids: vec![],
4444            ..Default::default()
4445        });
4446        let out = pl.to_m3u8();
4447        assert_eq!(rendered_version(&out), Some(9), "{out}");
4448    }
4449
4450    #[test]
4451    fn version_row10_skip_replacing_daterange_triggers_v10() {
4452        let mut pl = base_media_playlist();
4453        pl.skip = Some(SkipInfo {
4454            skipped_segments: 5,
4455            recently_removed_daterange_ids: vec!["ad-1".into()],
4456            ..Default::default()
4457        });
4458        let out = pl.to_m3u8();
4459        assert_eq!(rendered_version(&out), Some(10), "{out}");
4460    }
4461
4462    #[test]
4463    fn version_row11_define_queryparam_triggers_v11() {
4464        let mut pl = base_media_playlist();
4465        pl.extra_tags = vec!["#EXT-X-DEFINE:QUERYPARAM=\"auth\"".into()];
4466        let out = pl.to_m3u8();
4467        assert_eq!(rendered_version(&out), Some(11), "{out}");
4468    }
4469
4470    #[test]
4471    fn version_row11_define_queryparam_triggers_v11_multivariant() {
4472        let pl = MasterPlaylist {
4473            version: 0,
4474            variants: vec![Variant {
4475                bandwidth: 300_000,
4476                codecs: "avc1.64001e".into(),
4477                resolution: None,
4478                uri: "v300/index.m3u8".into(),
4479                ..Default::default()
4480            }],
4481            iframe_variants: vec![],
4482            extra_tags: vec!["#EXT-X-DEFINE:QUERYPARAM=\"auth\"".into()],
4483            ..Default::default()
4484        };
4485        let out = pl.to_m3u8();
4486        assert_eq!(rendered_version(&out), Some(11), "{out}");
4487    }
4488
4489    #[test]
4490    fn version_row12_req_attribute_triggers_v12() {
4491        let mut pl = base_media_playlist();
4492        pl.extra_tags = vec!["#EXT-X-FUTURE-FEATURE:REQ-CODEC=\"av01\"".into()];
4493        let out = pl.to_m3u8();
4494        assert_eq!(rendered_version(&out), Some(12), "{out}");
4495    }
4496
4497    #[test]
4498    fn version_row12_req_attribute_triggers_v12_multivariant() {
4499        let pl = MasterPlaylist {
4500            version: 0,
4501            variants: vec![Variant {
4502                bandwidth: 300_000,
4503                codecs: "avc1.64001e".into(),
4504                resolution: None,
4505                uri: "v300/index.m3u8".into(),
4506                ..Default::default()
4507            }],
4508            iframe_variants: vec![],
4509            extra_tags: vec!["#EXT-X-FUTURE-FEATURE:REQ-CODEC=\"av01\"".into()],
4510            ..Default::default()
4511        };
4512        let out = pl.to_m3u8();
4513        assert_eq!(rendered_version(&out), Some(12), "{out}");
4514    }
4515
4516    #[test]
4517    fn version_row13_media_instream_id_non_cc_triggers_v13_multivariant_only() {
4518        let pl = MasterPlaylist {
4519            version: 0,
4520            variants: vec![Variant {
4521                bandwidth: 300_000,
4522                codecs: "avc1.64001e".into(),
4523                resolution: None,
4524                uri: "v300/index.m3u8".into(),
4525                ..Default::default()
4526            }],
4527            iframe_variants: vec![],
4528            extra_tags: vec![
4529                "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"Eng\",INSTREAM-ID=\"CC1\"".into(),
4530            ],
4531            ..Default::default()
4532        };
4533        let out = pl.to_m3u8();
4534        assert_eq!(rendered_version(&out), Some(13), "{out}");
4535    }
4536
4537    #[test]
4538    fn version_multivariant_no_trigger_omits_the_tag() {
4539        let pl = MasterPlaylist {
4540            version: 0,
4541            variants: vec![Variant {
4542                bandwidth: 300_000,
4543                codecs: "avc1.64001e".into(),
4544                resolution: None,
4545                uri: "v300/index.m3u8".into(),
4546                ..Default::default()
4547            }],
4548            iframe_variants: vec![],
4549            extra_tags: vec![],
4550            ..Default::default()
4551        };
4552        let out = pl.to_m3u8();
4553        assert_eq!(rendered_version(&out), None, "{out}");
4554    }
4555
4556    // --- The five named regression cases from issue #871 ---
4557
4558    /// Case 1: the specific regression this issue exists for. Shaped like
4559    /// `hls-runtime`'s actual LL-HLS media playlist (fMP4 segments, an
4560    /// `EXT-X-MAP` conveyed via `extra_tags` exactly as that origin emits
4561    /// it, `low_latency` config, no `EXT-X-I-FRAMES-ONLY`, no `EXT-X-SKIP`)
4562    /// — must render **6**, never the old hardcoded **9**.
4563    #[test]
4564    fn named_case_1_fmp4_low_latency_renders_6_not_9() {
4565        let pl = MediaPlaylist {
4566            version: 0, // hls-runtime no longer supplies an explicit floor.
4567            target_duration: 4,
4568            media_sequence: 100,
4569            segments: vec![MediaSegment {
4570                uri: "seg-1-100.m4s".into(),
4571                duration: 4.0,
4572                ..Default::default()
4573            }],
4574            open_segment: Some(OpenSegment::new(vec![PartSpec {
4575                uri: "part-1-101.0.m4s".into(),
4576                duration: 0.5,
4577                independent: true,
4578                ..Default::default()
4579            }])),
4580            extra_tags: vec!["#EXT-X-MAP:URI=\"init-1.mp4\"".into()],
4581            low_latency: Some(LowLatencyConfig {
4582                part_target: 0.5,
4583                part_hold_back: 1.5,
4584                ..Default::default()
4585            }),
4586            iframes_only: false,
4587            ..Default::default()
4588        };
4589        let out = pl.to_m3u8();
4590        assert_eq!(
4591            rendered_version(&out),
4592            Some(6),
4593            "fMP4 + low-latency must render 6, not the old hardcoded 9:\n{out}"
4594        );
4595        assert!(!out.contains("#EXT-X-VERSION:9"), "{out}");
4596    }
4597
4598    /// Case 2: a classic MPEG-TS playlist (no fMP4/LL features at all) whose
4599    /// segment durations are genuine floating-point values renders **3**.
4600    #[test]
4601    fn named_case_2_classic_mpegts_float_extinf_renders_3() {
4602        let pl = MediaPlaylist {
4603            version: 0,
4604            target_duration: 10,
4605            media_sequence: 0,
4606            segments: vec![
4607                seg("seg0.ts", 9.009),
4608                seg("seg1.ts", 9.009),
4609                seg("seg2.ts", 3.003),
4610            ],
4611            endlist: true,
4612            ..Default::default()
4613        };
4614        let out = pl.to_m3u8();
4615        assert_eq!(rendered_version(&out), Some(3), "{out}");
4616    }
4617
4618    /// Case 3: adding `EXT-X-SKIP` to an otherwise-untriggering playlist
4619    /// raises the rendered version to **9** automatically.
4620    #[test]
4621    fn named_case_3_adding_skip_raises_version_to_9() {
4622        let mut pl = base_media_playlist();
4623        assert_eq!(
4624            rendered_version(&pl.to_m3u8()),
4625            None,
4626            "sanity: no trigger before adding EXT-X-SKIP"
4627        );
4628        pl.skip = Some(SkipInfo {
4629            skipped_segments: 3,
4630            recently_removed_daterange_ids: vec![],
4631            ..Default::default()
4632        });
4633        let out = pl.to_m3u8();
4634        assert_eq!(rendered_version(&out), Some(9), "{out}");
4635    }
4636
4637    /// Case 4: a playlist triggering nothing renders NO `EXT-X-VERSION` tag
4638    /// at all (RFC 8216bis §8's opening rule).
4639    #[test]
4640    fn named_case_4_no_trigger_renders_no_version_tag() {
4641        let out = base_media_playlist().to_m3u8();
4642        assert!(!out.contains("#EXT-X-VERSION"), "{out}");
4643    }
4644
4645    /// Case 5: `SAMPLE-AES` (the CBCS CENC key tag this crate's own
4646    /// `cenc_ext_x_key` produces) renders **5**.
4647    #[test]
4648    fn named_case_5_sample_aes_renders_5() {
4649        let tag = cenc_ext_x_key(CencScheme::Cbcs, &[0xab; 16], "https://k.example/key")
4650            .expect("cbcs must emit an EXT-X-KEY tag");
4651        let mut pl = base_media_playlist();
4652        pl.extra_tags = vec![tag];
4653        let out = pl.to_m3u8();
4654        assert_eq!(rendered_version(&out), Some(5), "{out}");
4655    }
4656
4657    // --- F1 + F4: HOLD-BACK and CAN-SKIP-DATERANGES attribute coverage ---
4658
4659    /// RFC 8216bis §4.4.3.8: `CAN-SKIP-DATERANGES=YES` with `CAN-SKIP-UNTIL`
4660    /// must survive parse -> serialize -> re-parse losslessly.
4661    #[test]
4662    fn parse_and_round_trip_can_skip_dateranges_yes() {
4663        let text = "#EXTM3U\n\
4664#EXT-X-VERSION:9\n\
4665#EXT-X-TARGETDURATION:4\n\
4666#EXT-X-PART-INF:PART-TARGET=0.5\n\
4667#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,CAN-SKIP-UNTIL=24.0,CAN-SKIP-DATERANGES=YES\n\
4668#EXTINF:4.000,\n\
4669seg0.m4s\n";
4670        let pl = MediaPlaylist::parse(text).expect("parse must succeed");
4671        let ll = pl.low_latency.as_ref().expect("must have low_latency");
4672        assert!(
4673            ll.can_skip_dateranges,
4674            "CAN-SKIP-DATERANGES=YES must parse to true"
4675        );
4676        let round = pl.to_m3u8();
4677        assert!(
4678            round.contains("CAN-SKIP-DATERANGES=YES"),
4679            "render must emit CAN-SKIP-DATERANGES=YES:\n{round}"
4680        );
4681        let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
4682        let re_ll = reparse
4683            .low_latency
4684            .as_ref()
4685            .expect("must have low_latency on reparse");
4686        assert!(
4687            re_ll.can_skip_dateranges,
4688            "CAN-SKIP-DATERANGES=YES must survive round trip"
4689        );
4690    }
4691
4692    /// RFC 8216bis §4.4.3.8: `CAN-SKIP-DATERANGES` suppressed when
4693    /// `CAN-SKIP-UNTIL` is absent (REQUIRES relationship).
4694    #[test]
4695    fn can_skip_dateranges_not_rendered_without_can_skip_until() {
4696        let pl = MediaPlaylist {
4697            version: 9,
4698            target_duration: 4,
4699            media_sequence: 0,
4700            segments: vec![],
4701            low_latency: Some(LowLatencyConfig {
4702                part_target: 0.5,
4703                part_hold_back: 1.5,
4704                can_skip_until: None,
4705                can_skip_dateranges: true,
4706                can_block_reload: true,
4707                ..Default::default()
4708            }),
4709            ..Default::default()
4710        };
4711        let out = pl.to_m3u8();
4712        assert!(
4713            !out.contains("CAN-SKIP-DATERANGES"),
4714            "CAN-SKIP-DATERANGES must NOT render without CAN-SKIP-UNTIL:\n{out}"
4715        );
4716    }
4717
4718    /// RFC 8216bis §4.4.3.8: `HOLD-BACK` attribute survives parse ->
4719    /// serialize -> re-parse losslessly.
4720    #[test]
4721    fn parse_and_round_trip_hold_back() {
4722        let text = "#EXTM3U\n\
4723#EXT-X-VERSION:9\n\
4724#EXT-X-TARGETDURATION:4\n\
4725#EXT-X-PART-INF:PART-TARGET=0.5\n\
4726#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,HOLD-BACK=12.0\n\
4727#EXTINF:4.000,\n\
4728seg0.m4s\n";
4729        let pl = MediaPlaylist::parse(text).expect("parse must succeed");
4730        let ll = pl.low_latency.as_ref().expect("must have low_latency");
4731        assert_eq!(ll.hold_back, Some(12.0), "HOLD-BACK=12.0 must parse");
4732        let round = pl.to_m3u8();
4733        // format_secs renders 12.0 as "12" (no trailing zero).
4734        assert!(
4735            round.contains("HOLD-BACK=12"),
4736            "render must emit HOLD-BACK=12:\n{round}"
4737        );
4738        let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
4739        let re_ll = reparse
4740            .low_latency
4741            .as_ref()
4742            .expect("must have low_latency on reparse");
4743        assert_eq!(
4744            re_ll.hold_back,
4745            Some(12.0),
4746            "HOLD-BACK must survive round trip"
4747        );
4748    }
4749
4750    /// `HOLD-BACK` omitted when `None` (spec default: 3× Target Duration).
4751    #[test]
4752    fn hold_back_omitted_when_none() {
4753        let pl = MediaPlaylist {
4754            version: 9,
4755            target_duration: 4,
4756            media_sequence: 0,
4757            segments: vec![MediaSegment {
4758                uri: "seg0.m4s".into(),
4759                duration: 4.0,
4760                ..Default::default()
4761            }],
4762            low_latency: Some(LowLatencyConfig {
4763                part_target: 0.5,
4764                part_hold_back: 1.5,
4765                hold_back: None,
4766                can_block_reload: true,
4767                ..Default::default()
4768            }),
4769            ..Default::default()
4770        };
4771        let out = pl.to_m3u8();
4772        assert!(
4773            !out.contains(",HOLD-BACK="),
4774            "HOLD-BACK must be absent when None:\n{out}"
4775        );
4776    }
4777
4778    // --- F3: EXT-X-SESSION-KEY METHOD=NONE rejection ---
4779
4780    /// RFC 8216bis §4.4.6.5: `EXT-X-SESSION-KEY` MUST NOT have a METHOD of
4781    /// NONE. Parsing a playlist with `METHOD=NONE` must error.
4782    #[test]
4783    fn session_key_method_none_is_rejected() {
4784        let text = "#EXTM3U\n\
4785#EXT-X-VERSION:6\n\
4786#EXT-X-STREAM-INF:BANDWIDTH=5000000\n\
4787v300/index.m3u8\n\
4788#EXT-X-SESSION-KEY:METHOD=NONE,URI=\"https://k.example/key\"\n";
4789        let err = MasterPlaylist::parse(text)
4790            .expect_err("EXT-X-SESSION-KEY with METHOD=NONE must be rejected");
4791        let Error::HlsParse { reason, .. } = err;
4792        assert!(reason.contains("NONE"), "error must mention NONE: {reason}");
4793    }
4794
4795    /// `EXT-X-SESSION-KEY` with a real method (AES-128) is accepted and
4796    /// round-trips.
4797    #[test]
4798    fn session_key_real_method_is_accepted_and_round_trips() {
4799        let text = "#EXTM3U\n\
4800#EXT-X-VERSION:6\n\
4801#EXT-X-STREAM-INF:BANDWIDTH=5000000\n\
4802v300/index.m3u8\n\
4803#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"https://k.example/key\"\n";
4804        let pl = MasterPlaylist::parse(text).expect("legitimate session key must parse");
4805        assert_eq!(pl.session_keys.len(), 1);
4806        assert_eq!(pl.session_keys[0].method, EncryptionMethod::Aes128);
4807        let round = pl.to_m3u8();
4808        let reparse = MasterPlaylist::parse(&round).expect("reparse must succeed");
4809        assert_eq!(reparse.session_keys.len(), 1);
4810        assert_eq!(reparse.session_keys[0].method, EncryptionMethod::Aes128);
4811    }
4812
4813    /// Interaction test (rebase #893 + #894): EXT-X-SERVER-CONTROL with
4814    /// typed attributes (HOLD-BACK, CAN-SKIP-DATERANGES) plus a REQ-
4815    /// attribute must round-trip all three and compute version 12
4816    /// (RFC 8216bis §8 row 12 + §4.4.3.8). The REQ- attribute must
4817    /// survive in `sc_extra_attrs`/`extra_attrs` and not be swallowed
4818    /// by the typed field parser.
4819    #[test]
4820    fn server_control_typed_and_req_attrs_round_trip_and_version_12() {
4821        let text = "#EXTM3U\n\
4822#EXT-X-VERSION:12\n\
4823#EXT-X-TARGETDURATION:4\n\
4824#EXT-X-PART-INF:PART-TARGET=0.5,REQ-VIDEO=720p\n\
4825#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5,HOLD-BACK=12.0,CAN-SKIP-UNTIL=24.0,CAN-SKIP-DATERANGES=YES,REQ-LATENCY=ultra-low\n\
4826#EXTINF:4,\n\
4827seg0.m4s\n";
4828        let pl = MediaPlaylist::parse(text).expect("parse must succeed");
4829        let ll = pl.low_latency.as_ref().expect("must have low_latency");
4830
4831        // Typed attributes survive.
4832        assert_eq!(ll.hold_back, Some(12.0));
4833        assert!(ll.can_skip_dateranges);
4834
4835        // REQ-VIDEO from PART-INF → pi_extra_attrs.
4836        assert!(
4837            ll.pi_extra_attrs
4838                .iter()
4839                .any(|(k, v)| k == "REQ-VIDEO" && v == "720p"),
4840            "REQ-VIDEO must survive in pi_extra_attrs"
4841        );
4842
4843        // REQ-LATENCY from SERVER-CONTROL → sc_extra_attrs.
4844        assert!(
4845            ll.sc_extra_attrs
4846                .iter()
4847                .any(|(k, v)| k == "REQ-LATENCY" && v == "ultra-low"),
4848            "REQ-LATENCY must survive in sc_extra_attrs"
4849        );
4850
4851        // Aggregated extra_attrs contains both.
4852        assert!(ll.extra_attrs.iter().any(|(k, _)| k == "REQ-VIDEO"));
4853        assert!(ll.extra_attrs.iter().any(|(k, _)| k == "REQ-LATENCY"));
4854
4855        // Version must be 12.
4856        let round = pl.to_m3u8();
4857        assert!(
4858            round.contains("#EXT-X-VERSION:12"),
4859            "must emit version 12:\n{round}"
4860        );
4861
4862        // Round-trip lossless.
4863        let reparse = MediaPlaylist::parse(&round).expect("reparse must succeed");
4864        let re_ll = reparse
4865            .low_latency
4866            .as_ref()
4867            .expect("must have low_latency on reparse");
4868        assert_eq!(re_ll.hold_back, Some(12.0));
4869        assert!(re_ll.can_skip_dateranges);
4870        assert!(re_ll.sc_extra_attrs.iter().any(|(k, _)| k == "REQ-LATENCY"));
4871        assert!(re_ll.pi_extra_attrs.iter().any(|(k, _)| k == "REQ-VIDEO"));
4872    }
4873
4874    // --- #958 overflow-rejection tests ---
4875
4876    #[test]
4877    fn byterange_offset_plus_length_overflow_rejected() {
4878        let input = "#EXTM3U\n\
4879                      #EXT-X-TARGETDURATION:10\n\
4880                      #EXTINF:10,\n\
4881                      #EXT-X-BYTERANGE:1@18446744073709551615\n\
4882                      seg0.ts\n";
4883        let err = MediaPlaylist::parse(input).unwrap_err();
4884        let msg = err.to_string();
4885        assert!(msg.contains("overflows"), "expected overflow error: {msg}");
4886    }
4887
4888    #[test]
4889    fn byterange_exact_max_accepted() {
4890        let input = "#EXTM3U\n\
4891                      #EXT-X-TARGETDURATION:10\n\
4892                      #EXTINF:10,\n\
4893                      #EXT-X-BYTERANGE:1@18446744073709551614\n\
4894                      seg0.ts\n";
4895        let pl = MediaPlaylist::parse(input).expect("u64::MAX exactly must parse");
4896        assert_eq!(pl.segments[0].byte_range.as_ref().unwrap().length, 1);
4897        assert_eq!(
4898            pl.segments[0].byte_range.as_ref().unwrap().offset,
4899            Some(u64::MAX - 1)
4900        );
4901    }
4902
4903    #[test]
4904    fn byterange_no_offset_max_length_accepted() {
4905        let input = "#EXTM3U\n\
4906                      #EXT-X-TARGETDURATION:10\n\
4907                      #EXTINF:10,\n\
4908                      #EXT-X-BYTERANGE:18446744073709551615\n\
4909                      seg0.ts\n";
4910        let pl = MediaPlaylist::parse(input).expect("no offset means no overflow check");
4911        assert_eq!(pl.segments[0].byte_range.as_ref().unwrap().length, u64::MAX);
4912        assert!(pl.segments[0].byte_range.as_ref().unwrap().offset.is_none());
4913    }
4914
4915    #[test]
4916    fn map_byterange_overflow_rejected() {
4917        let input = "#EXTM3U\n\
4918                      #EXT-X-TARGETDURATION:10\n\
4919                      #EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"1@18446744073709551615\"\n\
4920                      #EXTINF:10,\n\
4921                      seg0.ts\n";
4922        let err = MediaPlaylist::parse(input).unwrap_err();
4923        assert!(
4924            err.to_string().contains("overflows"),
4925            "MAP BYTERANGE overflow: {err}"
4926        );
4927    }
4928
4929    #[test]
4930    fn part_byterange_overflow_rejected() {
4931        let input = "#EXTM3U\n\
4932                      #EXT-X-TARGETDURATION:10\n\
4933                      #EXT-X-PART-INF:PART-TARGET=1.0\n\
4934                      #EXT-X-PART:URI=\"p0.m4s\",DURATION=1.0,BYTERANGE=\"1@18446744073709551615\"\n\
4935                      #EXTINF:10,\n\
4936                      seg0.ts\n";
4937        let err = MediaPlaylist::parse(input).unwrap_err();
4938        assert!(
4939            err.to_string().contains("overflows"),
4940            "PART BYTERANGE overflow: {err}"
4941        );
4942    }
4943
4944    #[test]
4945    fn preload_hint_byterange_overflow_rejected() {
4946        let input = "#EXTM3U\n\
4947                      #EXT-X-TARGETDURATION:10\n\
4948                      #EXT-X-PART-INF:PART-TARGET=1.0\n\
4949                      #EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"next.m4s\",\
4950                      BYTERANGE-START=18446744073709551615,BYTERANGE-LENGTH=1\n\
4951                      #EXTINF:10,\n\
4952                      seg0.ts\n";
4953        let err = MediaPlaylist::parse(input).unwrap_err();
4954        assert!(
4955            err.to_string().contains("overflows"),
4956            "PRELOAD-HINT overflow: {err}"
4957        );
4958    }
4959}