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